From 2fb8b93cd8a18beda68a043353e3381759526191 Mon Sep 17 00:00:00 2001 From: Julien Beugras Date: Fri, 6 Nov 2026 14:14:58 +0100 Subject: [PATCH 1/3] Add a preference to turn off starting the next season automatically Completing a season marks the next released season as in progress. Users who do not start the next season right away, or who log past seasons while backfilling history, had no way to turn this off (#1355). The new "Start next season automatically" preference defaults to on, so nothing changes for existing users. When it is off, completing a season leaves later seasons untouched; the show stays in progress while a released season remains, and is still completed when none does. Closes #1355 --- src/app/models.py | 22 +++- src/app/tests/models/test_season.py | 112 ++++++++++++++++++ src/templates/users/preferences.html | 23 ++++ .../0060_user_auto_start_next_season.py | 18 +++ src/users/models.py | 6 + src/users/tests/views/test_sidebar.py | 12 ++ src/users/views.py | 1 + 7 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 src/users/migrations/0060_user_auto_start_next_season.py diff --git a/src/app/models.py b/src/app/models.py index 33c4d78460..0961162928 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -1206,8 +1206,14 @@ def _mark_in_progress_seasons_as_dropped(self): def _start_next_available_season( self, min_season_number=0, + *, + start=True, ): - """Find the next available season to watch and set it to in-progress.""" + """Find the next available season to watch and set it to in-progress. + + With ``start=False`` nothing is created or changed: the return value + only says whether a released season the user has not completed exists. + """ min_season_number = int(min_season_number or 0) current_date = timezone.localdate() existing_seasons = { @@ -1244,6 +1250,9 @@ def _start_next_available_season( ): continue + if not start: + return True + if next_unwatched_season is None: item, _created = Item.objects.get_or_create( media_id=self.item.media_id, @@ -1305,9 +1314,18 @@ def _handle_completed_season( completed_season_number, ): """Start the next season, or complete the TV show if no seasons remain.""" - if self._start_next_available_season( + if self.user.auto_start_next_season: + if self._start_next_available_season(completed_season_number): + return + elif self._start_next_available_season( completed_season_number, + start=False, ): + # A later season is out, so the show is not complete; leave + # starting it to the user. + if self.status != Status.IN_PROGRESS.value: + self.status = Status.IN_PROGRESS.value + bulk_update_with_history([self], TV, fields=["status"]) return incomplete_seasons_exist = ( diff --git a/src/app/tests/models/test_season.py b/src/app/tests/models/test_season.py index 02c683ebc0..6a7ad0daca 100644 --- a/src/app/tests/models/test_season.py +++ b/src/app/tests/models/test_season.py @@ -682,6 +682,118 @@ 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(), + ) + + @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/templates/users/preferences.html b/src/templates/users/preferences.html index 2b98241969..75c3cdede4 100644 --- a/src/templates/users/preferences.html +++ b/src/templates/users/preferences.html @@ -132,6 +132,29 @@

{% trans "Preferences" %}

+ {# Auto Start Next Season #} +
+
+
+
+ {% include "app/icons/right-arrow.svg" with classes="w-5 h-5 mr-2" %} + {% trans "Start next season automatically" %} +
+

+ {% trans "When you complete a season, mark the next released season as in progress." %} +

+
+ +
+
+ {# Hide Zero Ratings #}
diff --git a/src/users/migrations/0060_user_auto_start_next_season.py b/src/users/migrations/0060_user_auto_start_next_season.py new file mode 100644 index 0000000000..a61986ffdf --- /dev/null +++ b/src/users/migrations/0060_user_auto_start_next_season.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-11-06 13:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0059_update_italian_language_code'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='auto_start_next_season', + field=models.BooleanField(default=True, help_text='Start the next season automatically when one is completed'), + ), + ] diff --git a/src/users/models.py b/src/users/models.py index c378644e81..1e630a48ed 100644 --- a/src/users/models.py +++ b/src/users/models.py @@ -363,6 +363,12 @@ class User(AbstractUser): help_text=_("Hide completed media in recommendations"), ) + # Start the next season when one is completed + auto_start_next_season = models.BooleanField( + default=True, + help_text=_("Start the next season automatically when one is completed"), + ) + # Hide zero ratings hide_zero_rating = models.BooleanField( default=False, diff --git a/src/users/tests/views/test_sidebar.py b/src/users/tests/views/test_sidebar.py index a886a86363..a887ddcd04 100644 --- a/src/users/tests/views/test_sidebar.py +++ b/src/users/tests/views/test_sidebar.py @@ -73,6 +73,18 @@ def test_sidebar_post_show_adult_titles(self): self.user.refresh_from_db() self.assertTrue(self.user.show_adult_titles) + def test_sidebar_post_auto_start_next_season(self): + """Test POST request saves and clears the next season preference.""" + self.assertTrue(self.user.auto_start_next_season) + + self.client.post(reverse("preferences"), {}) + self.user.refresh_from_db() + self.assertFalse(self.user.auto_start_next_season) + + self.client.post(reverse("preferences"), {"auto_start_next_season": "on"}) + self.user.refresh_from_db() + self.assertTrue(self.user.auto_start_next_season) + def test_sidebar_post_demo_user(self): """Test POST request from a demo user to preferences.""" self.user.is_demo = True diff --git a/src/users/views.py b/src/users/views.py index 42bd5244a0..813620bd8e 100644 --- a/src/users/views.py +++ b/src/users/views.py @@ -255,6 +255,7 @@ def preferences(request): "hide_completed_recommendations" in request.POST ) request.user.hide_zero_rating = "hide_zero_rating" in request.POST + request.user.auto_start_next_season = "auto_start_next_season" in request.POST request.user.date_format = request.POST.get( "date_format", DateFormatChoices.ISO, From e49d3598cc6dc1cbda5875f3ee3f287a5fb7bd6d Mon Sep 17 00:00:00 2001 From: Julien Beugras Date: Fri, 6 Nov 2026 14:36:40 +0100 Subject: [PATCH 2/3] Separate finding the next season from starting it _find_next_available_season now returns the next released season to watch, and _start_next_available_season uses it, so the preference check no longer needs a flag that turns "start" into "look". When the preference is off, completing a season now also tells the user that the next season is available, like the other automatic status changes do. Adds a test for completing a season by watching its last episode with the preference off. --- src/app/models.py | 137 ++++++++++++++------------- src/app/tests/models/test_episode.py | 51 ++++++++++ src/app/tests/models/test_season.py | 7 ++ 3 files changed, 128 insertions(+), 67 deletions(-) diff --git a/src/app/models.py b/src/app/models.py index 0961162928..9cc893cf77 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -1203,16 +1203,12 @@ def _mark_in_progress_seasons_as_dropped(self): fields=["status"], ) - def _start_next_available_season( - self, - min_season_number=0, - *, - start=True, - ): - """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. - With ``start=False`` nothing is created or changed: the return value - only says whether a released season the user has not completed exists. + 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() @@ -1229,19 +1225,13 @@ def _start_next_available_season( ) 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 - next_unwatched_season = existing_seasons.get(season_number) - if ( - next_unwatched_season - and next_unwatched_season.status == Status.COMPLETED.value - ): + season = existing_seasons.get(season_number) + if season and season.status == Status.COMPLETED.value: continue if not app.helpers.is_released_date( @@ -1250,52 +1240,53 @@ def _start_next_available_season( ): continue - if not start: - return True + return season_number, season_data, season - 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"], - }, - ) + return 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 + 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.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 + season_number, season_data, next_unwatched_season = next_season + started_season_number = None - if season_started and self.status != Status.IN_PROGRESS.value: - self.status = Status.IN_PROGRESS.value + 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"], + }, + ) + + 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 + + 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( @@ -1307,7 +1298,17 @@ 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, @@ -1317,16 +1318,18 @@ def _handle_completed_season( if self.user.auto_start_next_season: if self._start_next_available_season(completed_season_number): return - elif self._start_next_available_season( - completed_season_number, - start=False, - ): - # A later season is out, so the show is not complete; leave - # starting it to the user. - if self.status != Status.IN_PROGRESS.value: - self.status = Status.IN_PROGRESS.value - bulk_update_with_history([self], TV, fields=["status"]) - 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 6d3935ecc4..72a779cd7c 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 6a7ad0daca..17e0a8927b 100644 --- a/src/app/tests/models/test_season.py +++ b/src/app/tests/models/test_season.py @@ -742,6 +742,13 @@ def test_completed_status_without_auto_start_leaves_next_season( 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( From 4b99844712640a33cd9c21046ebfa13d84f208d7 Mon Sep 17 00:00:00 2001 From: Julien Beugras Date: Fri, 6 Nov 2026 15:06:03 +0100 Subject: [PATCH 3/3] Keep the next-season rule free of database and provider calls next_released_season picks the next season to watch from a show's season metadata, the seasons the user completed and today's date. The TV model fetches that data and passes it in, so the rule is tested directly without mocking TMDB. --- src/app/helpers.py | 24 +++++++++++++++++++ src/app/models.py | 36 ++++++++++++----------------- src/app/tests/test_helpers.py | 43 ++++++++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 22 deletions(-) diff --git a/src/app/helpers.py b/src/app/helpers.py index 9f0b2ab5fc..c7c1416b70 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 9cc893cf77..9cb5e11936 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -1211,38 +1211,32 @@ def _find_next_available_season(self, min_season_number=0): 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", []) - - for season_data in related_seasons: - season_number = season_data["season_number"] - if season_number <= min_season_number: - continue - - season = existing_seasons.get(season_number) - if season and season.status == Status.COMPLETED.value: - continue - - if not app.helpers.is_released_date( - season_data.get("first_air_date"), - current_date, - ): - continue - - return season_number, season_data, season + 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 - return None + season_number = season_data["season_number"] + return season_number, season_data, existing_seasons.get(season_number) def _start_next_available_season( self, diff --git a/src/app/tests/test_helpers.py b/src/app/tests/test_helpers.py index 961df4feea..5808c5107a 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))