Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/app/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
153 changes: 84 additions & 69 deletions src/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
51 changes: 51 additions & 0 deletions src/app/tests/models/test_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading