diff --git a/src/app/helpers.py b/src/app/helpers.py index 9f0b2ab5f..c7c1416b7 100644 --- a/src/app/helpers.py +++ b/src/app/helpers.py @@ -170,6 +170,30 @@ def is_released_date(air_date, current_date=None): return normalized_air_date <= current_date +def next_released_season( + seasons, + completed_season_numbers, + after_season_number, + current_date=None, +): + """Return the first season to watch after after_season_number. + + ``seasons`` is a show's season metadata in airing order. A season is skipped + when the user completed it or when it has not aired yet. Returns that + season's metadata, or None when there is nothing left to watch. + """ + for season_data in seasons: + season_number = season_data["season_number"] + if season_number <= after_season_number: + continue + if season_number in completed_season_numbers: + continue + if not is_released_date(season_data.get("first_air_date"), current_date): + continue + return season_data + return None + + def _needs_image_refresh(item, new_image): """Return True when ``item.image`` should be replaced with ``new_image``.""" if item is None or not new_image or new_image == settings.IMG_NONE: diff --git a/src/app/models.py b/src/app/models.py index 33c4d7846..9cb5e1193 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -1203,90 +1203,84 @@ def _mark_in_progress_seasons_as_dropped(self): fields=["status"], ) - def _start_next_available_season( - self, - min_season_number=0, - ): - """Find the next available season to watch and set it to in-progress.""" + def _find_next_available_season(self, min_season_number=0): + """Return the next released season after min_season_number to watch. + + The result is a ``(season_number, season_data, season)`` tuple, where + ``season`` is the user's tracked Season or None, or None when every + later season is completed or has not aired yet. + """ min_season_number = int(min_season_number or 0) - current_date = timezone.localdate() existing_seasons = { season.item.season_number: season for season in self.seasons.filter( item__season_number__gt=min_season_number, - ).order_by("item__season_number") + ) } tv_metadata = providers.services.get_media_metadata( self.item.media_type, self.item.media_id, self.item.source, ) - related_seasons = tv_metadata.get("related", {}).get("seasons", []) - - season_started = False - started_season_number = None - - for season_data in related_seasons: - season_number = season_data["season_number"] - if season_number <= min_season_number: - continue + season_data = app.helpers.next_released_season( + tv_metadata.get("related", {}).get("seasons", []), + completed_season_numbers={ + number + for number, season in existing_seasons.items() + if season.status == Status.COMPLETED.value + }, + after_season_number=min_season_number, + current_date=timezone.localdate(), + ) + if season_data is None: + return None - next_unwatched_season = existing_seasons.get(season_number) - if ( - next_unwatched_season - and next_unwatched_season.status == Status.COMPLETED.value - ): - continue + season_number = season_data["season_number"] + return season_number, season_data, existing_seasons.get(season_number) - if not app.helpers.is_released_date( - season_data.get("first_air_date"), - current_date, - ): - continue + def _start_next_available_season( + self, + min_season_number=0, + ): + """Find the next available season to watch and set it to in-progress.""" + next_season = self._find_next_available_season(min_season_number) + if next_season is None: + return False - if next_unwatched_season is None: - item, _created = Item.objects.get_or_create( - media_id=self.item.media_id, - source=self.item.source, - media_type=MediaTypes.SEASON.value, - season_number=season_number, - defaults={ - "title": self.item.title, - "image": season_data["image"], - }, - ) + season_number, season_data, next_unwatched_season = next_season + started_season_number = None - next_unwatched_season = Season( - item=item, - user=self.user, - related_tv=self, - status=Status.IN_PROGRESS.value, - ) - bulk_create_with_history([next_unwatched_season], Season) - season_started = True - started_season_number = season_number - break + if next_unwatched_season is None: + item, _created = Item.objects.get_or_create( + media_id=self.item.media_id, + source=self.item.source, + media_type=MediaTypes.SEASON.value, + season_number=season_number, + defaults={ + "title": self.item.title, + "image": season_data["image"], + }, + ) - if next_unwatched_season.status != Status.IN_PROGRESS.value: - next_unwatched_season.status = Status.IN_PROGRESS.value - bulk_update_with_history( - [next_unwatched_season], - Season, - fields=["status"], - ) - season_started = True - started_season_number = season_number - else: - season_started = True - break + next_unwatched_season = Season( + item=item, + user=self.user, + related_tv=self, + status=Status.IN_PROGRESS.value, + ) + bulk_create_with_history([next_unwatched_season], Season) + started_season_number = season_number - if season_started and self.status != Status.IN_PROGRESS.value: - self.status = Status.IN_PROGRESS.value + elif next_unwatched_season.status != Status.IN_PROGRESS.value: + next_unwatched_season.status = Status.IN_PROGRESS.value bulk_update_with_history( - [self], - TV, + [next_unwatched_season], + Season, fields=["status"], ) + started_season_number = season_number + + self._set_in_progress() if started_season_number is not None: self.create_user_message( @@ -1298,17 +1292,38 @@ def _start_next_available_season( season_number=started_season_number, ) - return season_started + return True + + def _set_in_progress(self): + """Mark the TV show as in progress if it is not already.""" + if self.status != Status.IN_PROGRESS.value: + self.status = Status.IN_PROGRESS.value + bulk_update_with_history( + [self], + TV, + fields=["status"], + ) def _handle_completed_season( self, completed_season_number, ): """Start the next season, or complete the TV show if no seasons remain.""" - if self._start_next_available_season( - completed_season_number, - ): - return + if self.user.auto_start_next_season: + if self._start_next_available_season(completed_season_number): + return + else: + next_season = self._find_next_available_season(completed_season_number) + if next_season is not None: + # A later season is out, so the show is not complete, but + # starting it is left to the user. + self._set_in_progress() + self.create_user_message( + _("%(title)s Season %(season_number)s is available to watch."), + level=UserMessageLevel.INFO, + season_number=next_season[0], + ) + return incomplete_seasons_exist = ( self.seasons.filter( diff --git a/src/app/tests/models/test_episode.py b/src/app/tests/models/test_episode.py index 6d3935ecc..72a779cd7 100644 --- a/src/app/tests/models/test_episode.py +++ b/src/app/tests/models/test_episode.py @@ -375,6 +375,57 @@ def test_non_last_season_with_future_next_season_keeps_planning( self.assertEqual(next_season.status, Status.PLANNING.value) self.assertEqual(self.tv.status, Status.IN_PROGRESS.value) + @patch("app.models.providers.services.get_media_metadata") + def test_non_last_season_without_auto_start_keeps_next_season( + self, + mock_get_metadata, + ): + """With auto start off, watching the last episode does not start S2.""" + self.user.auto_start_next_season = False + self.user.save(update_fields=["auto_start_next_season"]) + mock_get_metadata.return_value = { + "season/1": { + "episodes": [{"episode_number": 1}], + }, + "related": { + "seasons": [ + { + "season_number": 1, + "first_air_date": datetime(2020, 1, 1, tzinfo=UTC), + }, + { + "season_number": 2, + "image": "season_img2.jpg", + "first_air_date": datetime(2020, 1, 1, tzinfo=UTC), + }, + ], + }, + } + + Episode.objects.create( + item=self.episode_item, + related_season=self.season, + end_date=timezone.now(), + ) + + self.season.refresh_from_db() + self.tv.refresh_from_db() + self.assertEqual(self.season.status, Status.COMPLETED.value) + self.assertFalse( + Season.objects.filter( + related_tv=self.tv, + item__season_number=2, + ).exists(), + ) + self.assertEqual(self.tv.status, Status.IN_PROGRESS.value) + self.assertTrue( + UserMessage.objects.filter( + user=self.user, + level=UserMessageLevel.INFO, + message=f"{self.tv} Season 2 is available to watch.", + ).exists(), + ) + @patch("app.models.providers.services.get_media_metadata") def test_rewatch_last_episode_does_not_duplicate_completion_messages( self, diff --git a/src/app/tests/models/test_season.py b/src/app/tests/models/test_season.py index 02c683ebc..17e0a8927 100644 --- a/src/app/tests/models/test_season.py +++ b/src/app/tests/models/test_season.py @@ -682,6 +682,125 @@ def test_completed_status_with_future_next_season_keeps_tv_in_progress( ).exists(), ) + def _completed_season_metadata(self, next_air_date): + """Return metadata for completing S1 when S2 aired on next_air_date.""" + return [ + { + "episodes": [ + { + "episode_number": 1, + "image": "img1.jpg", + "air_date": datetime(2020, 1, 1, tzinfo=UTC), + }, + ], + "image": "season_img.jpg", + }, + { + "related": { + "seasons": [ + { + "season_number": 1, + "image": "season_img.jpg", + "first_air_date": datetime(2020, 1, 1, tzinfo=UTC), + }, + { + "season_number": 2, + "image": "season_img2.jpg", + "first_air_date": next_air_date, + }, + ], + }, + }, + ] + + @patch("app.models.providers.services.get_media_metadata") + def test_completed_status_without_auto_start_leaves_next_season( + self, + mock_get_metadata, + ): + """With auto start off, completing a season does not start the next one.""" + self.user.auto_start_next_season = False + self.user.save() + mock_get_metadata.side_effect = self._completed_season_metadata( + datetime(2020, 6, 1, tzinfo=UTC), + ) + + self.season.status = Status.COMPLETED.value + self.season.save() + + self.assertFalse( + Season.objects.filter( + related_tv=self.tv, + item__season_number=2, + ).exists(), + ) + self.tv.refresh_from_db() + self.assertEqual(self.tv.status, Status.IN_PROGRESS.value) + self.assertFalse( + UserMessage.objects.filter( + user=self.user, + message__contains="was marked as in progress automatically", + ).exists(), + ) + self.assertTrue( + UserMessage.objects.filter( + user=self.user, + level=UserMessageLevel.INFO, + message=f"{self.tv} Season 2 is available to watch.", + ).exists(), + ) + + @patch("app.models.providers.services.get_media_metadata") + def test_completed_status_without_auto_start_keeps_planning_season( + self, + mock_get_metadata, + ): + """With auto start off, a tracked Planning season stays Planning.""" + self.user.auto_start_next_season = False + self.user.save() + next_season = Season.objects.create( + item=Item.objects.create( + media_id="123", + source=Sources.TMDB.value, + media_type=MediaTypes.SEASON.value, + title="Test Show", + image="http://example.com/image2.jpg", + season_number=2, + ), + user=self.user, + related_tv=self.tv, + status=Status.PLANNING.value, + ) + mock_get_metadata.side_effect = self._completed_season_metadata( + datetime(2020, 6, 1, tzinfo=UTC), + ) + + self.season.status = Status.COMPLETED.value + self.season.save() + + next_season.refresh_from_db() + self.assertEqual(next_season.status, Status.PLANNING.value) + self.tv.refresh_from_db() + self.assertEqual(self.tv.status, Status.IN_PROGRESS.value) + + @patch("app.models.providers.services.get_media_metadata") + def test_completed_last_season_without_auto_start_completes_tv_show( + self, + mock_get_metadata, + ): + """With auto start off, an unaired next season still completes the show.""" + self.user.auto_start_next_season = False + self.user.save() + mock_get_metadata.side_effect = self._completed_season_metadata( + datetime(2999, 1, 1, tzinfo=UTC), + ) + + self.season.status = Status.COMPLETED.value + self.season.save() + + self.tv.refresh_from_db() + self.assertEqual(self.tv.status, Status.COMPLETED.value) + def test_dropped_status_updates_tv_status(self): """Test setting status to DROPPED updates TV status.""" self.season.status = Status.DROPPED.value diff --git a/src/app/tests/test_helpers.py b/src/app/tests/test_helpers.py index 961df4fee..5808c5107 100644 --- a/src/app/tests/test_helpers.py +++ b/src/app/tests/test_helpers.py @@ -1,10 +1,11 @@ import json +from datetime import date from unittest.mock import MagicMock, patch from django import forms from django.contrib.auth import get_user_model from django.http import HttpRequest -from django.test import TestCase, override_settings +from django.test import SimpleTestCase, TestCase, override_settings from django.urls import reverse from app.helpers import ( @@ -13,6 +14,7 @@ form_error_messages, get_configured_app_url, minutes_to_hhmm, + next_released_season, redirect_back, ) from app.models import Item, MediaTypes, Movie, Sources, Status @@ -330,3 +332,42 @@ def test_hide_completed_recommendations_disabled(self): self.request, raw_items, "recommendations" ) self.assertEqual(len(enriched_items), 2) + + +class NextReleasedSeasonTests(SimpleTestCase): + """Pick the next season to watch from a show's season metadata.""" + + TODAY = date(2026, 6, 1) + SEASONS = [ + {"season_number": 0, "first_air_date": "2019-01-01"}, + {"season_number": 1, "first_air_date": "2020-01-01"}, + {"season_number": 2, "first_air_date": "2021-01-01"}, + {"season_number": 3, "first_air_date": "2022-01-01"}, + {"season_number": 4, "first_air_date": "2027-01-01"}, + ] + + def next_number(self, completed, after): + """Return the number of the next season, or None.""" + season = next_released_season(self.SEASONS, completed, after, self.TODAY) + return None if season is None else season["season_number"] + + def test_returns_the_season_after_the_completed_one(self): + """The season right after the completed one comes next.""" + self.assertEqual(self.next_number({1}, after=1), 2) + + def test_skips_completed_later_seasons(self): + """A later season the user already completed is skipped.""" + self.assertEqual(self.next_number({1, 2}, after=1), 3) + + def test_ignores_earlier_and_special_seasons(self): + """Specials and seasons before after_season_number are never picked.""" + self.assertEqual(self.next_number(set(), after=1), 2) + + def test_skips_unaired_seasons(self): + """A season that has not aired yet is not available.""" + self.assertIsNone(self.next_number({3}, after=3)) + + def test_missing_air_date_is_not_released(self): + """A season without an air date counts as not aired.""" + seasons = [{"season_number": 2}] + self.assertIsNone(next_released_season(seasons, set(), 1, self.TODAY)) diff --git a/src/templates/users/preferences.html b/src/templates/users/preferences.html index 2b9824196..75c3cdede 100644 --- a/src/templates/users/preferences.html +++ b/src/templates/users/preferences.html @@ -132,6 +132,29 @@
+ {% trans "When you complete a season, mark the next released season as in progress." %} +
+