Skip to content

Commit 7417479

Browse files
committed
Merge remote-tracking branch 'origin/latest' into latest
2 parents 21c0177 + b5047ed commit 7417479

17 files changed

Lines changed: 750 additions & 74 deletions

src/app/management/commands/populate_runtime_data.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def handle(self, *args, **options):
5555
],
5656
source__in=[
5757
"tmdb",
58+
"tvdb",
5859
"mal",
5960
"simkl",
6061
], # Only process items from providers that have runtime data

src/app/season_details_views.py

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,34 @@ def _scoped_season_item_qs():
342342
if episode_number is None:
343343
continue
344344

345+
# Parse the episode's air date once — reused below both for the
346+
# "aired but runtime unknown" heuristic and to persist
347+
# release_datetime. Without this, release_datetime is never set
348+
# for any episode the user hasn't individually tracked/watched
349+
# (that's the only other code path that extracts it), so an
350+
# episode created here from a page view stays permanently NULL
351+
# even once the provider does have its air date.
352+
air_date_dt = None
353+
raw_air_date = episode.get("air_date")
354+
if raw_air_date:
355+
try:
356+
if isinstance(raw_air_date, str):
357+
date_obj = datetime.strptime(raw_air_date, "%Y-%m-%d") # noqa: DTZ007 # date-only value; no timezone applies
358+
air_date_dt = timezone.make_aware(
359+
date_obj,
360+
timezone.get_current_timezone(),
361+
)
362+
elif hasattr(raw_air_date, "year"):
363+
air_date_dt = (
364+
raw_air_date
365+
if timezone.is_aware(raw_air_date)
366+
else timezone.make_aware(raw_air_date)
367+
)
368+
except (ValueError, TypeError):
369+
air_date_dt = None
370+
if air_date_dt and air_date_dt.year <= MIN_PLAUSIBLE_YEAR:
371+
air_date_dt = None
372+
345373
# Get or create episode item — retry on race condition
346374
lookup = {
347375
"media_id": media_id,
@@ -360,6 +388,7 @@ def _scoped_season_item_qs():
360388
defaults={
361389
"title": season_metadata.get("title", ""),
362390
"image": settings.IMG_NONE,
391+
"release_datetime": air_date_dt,
363392
},
364393
)
365394
except IntegrityError:
@@ -371,27 +400,9 @@ def _scoped_season_item_qs():
371400
runtime_minutes = (
372401
int(episode["runtime"]) if episode["runtime"] > 0 else None
373402
)
374-
elif episode.get("air_date"):
375-
# Check if episode has aired
376-
try:
377-
if isinstance(episode["air_date"], str):
378-
date_obj = datetime.strptime(episode["air_date"], "%Y-%m-%d") # noqa: DTZ007 # date-only value; no timezone applies
379-
air_date_dt = timezone.make_aware(
380-
date_obj,
381-
timezone.get_current_timezone(),
382-
)
383-
else:
384-
air_date_dt = episode["air_date"]
385-
386-
if (
387-
air_date_dt
388-
and air_date_dt.year > MIN_PLAUSIBLE_YEAR
389-
and air_date_dt <= current_datetime
390-
):
391-
# Episode has aired but no runtime - mark as unknown (use 999998)
392-
runtime_minutes = 999998
393-
except (ValueError, TypeError):
394-
pass
403+
elif air_date_dt and air_date_dt <= current_datetime:
404+
# Episode has aired but no runtime - mark as unknown (use 999998)
405+
runtime_minutes = 999998
395406

396407
# Extract provider rating from raw episode data (already in memory, no extra API call)
397408
score = None
@@ -412,20 +423,34 @@ def _scoped_season_item_qs():
412423
episode_item.provider_rating != score
413424
or episode_item.provider_rating_count != score_count
414425
)
426+
# Only fill in a missing date, never overwrite one that's already
427+
# set — this lets an episode created before its air date was
428+
# known self-heal the next time anyone views the season page,
429+
# without a dedicated backfill task.
430+
release_datetime_changed = (
431+
air_date_dt is not None and episode_item.release_datetime is None
432+
)
415433

416434
if runtime_changed:
417435
episode_item.runtime_minutes = runtime_minutes
418436
if rating_changed:
419437
episode_item.provider_rating = score
420438
episode_item.provider_rating_count = score_count
439+
if release_datetime_changed:
440+
episode_item.release_datetime = air_date_dt
421441

422-
if runtime_changed or rating_changed:
442+
if runtime_changed or rating_changed or release_datetime_changed:
423443
episodes_to_update.append(episode_item)
424444

425445
if episodes_to_update:
426446
Item.objects.bulk_update(
427447
episodes_to_update,
428-
["runtime_minutes", "provider_rating", "provider_rating_count"],
448+
[
449+
"runtime_minutes",
450+
"provider_rating",
451+
"provider_rating_count",
452+
"release_datetime",
453+
],
429454
batch_size=100,
430455
)
431456
# Invalidate time_left + media_list cache for all users

src/app/signals.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@
5252

5353
RUNTIME_UNKNOWN_FAILED = 999999 # runtime completely unknown / failed lookup
5454

55-
RUNTIME_BACKFILL_SOURCES = ("tmdb", "mal", "simkl")
56-
GENRE_BACKFILL_SOURCES = ("tmdb", "mal", "simkl", "igdb", "bgg")
55+
RUNTIME_BACKFILL_SOURCES = ("tmdb", "tvdb", "mal", "simkl")
56+
GENRE_BACKFILL_SOURCES = ("tmdb", "tvdb", "mal", "simkl", "igdb", "bgg")
5757
DISCOVER_PRIORITY_HISTORY_DEBOUNCE_SECONDS = 15
5858
DISCOVER_PRIORITY_HISTORY_COUNTDOWN = 15
5959
DISCOVER_PRIORITY_STATISTICS_DEBOUNCE_SECONDS = 20

src/app/tasks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@
168168

169169
RELEASE_BACKFILL_SOURCES = (
170170
Sources.TMDB.value,
171+
Sources.TVDB.value,
171172
Sources.MAL.value,
172173
Sources.MANGAUPDATES.value,
173174
Sources.IGDB.value,

src/app/tasks_genre.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
GENRE_BACKFILL_SOURCES = (
3434
Sources.TMDB.value,
35+
Sources.TVDB.value,
3536
Sources.MAL.value,
3637
"simkl",
3738
Sources.IGDB.value,

src/app/tasks_runtime.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
BACKGROUND_TASK_PRIORITY = getattr(settings, "CELERY_TASK_PRIORITY_BACKGROUND", 9)
3232

33-
RUNTIME_BACKFILL_SOURCES = ("tmdb", "mal", "simkl")
33+
RUNTIME_BACKFILL_SOURCES = ("tmdb", "tvdb", "mal", "simkl")
3434
RUNTIME_BACKFILL_QUEUE_TTL = 60 * 60 # 1 hour
3535

3636
SEASON_KEY_TUPLE_LENGTH = 3 # (media_id, source, season_number)

src/app/templatetags/app_tags.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,28 +1137,35 @@ def media_view_url(view_name, media):
11371137
if not is_dict and not hasattr(media, "source"):
11381138
return ""
11391139

1140+
media_type = media["media_type"] if is_dict else media.media_type
1141+
11401142
# Build kwargs using either dict access or object attribute
11411143
kwargs = {
11421144
"source": media["source"] if is_dict else media.source,
11431145
"media_type": (
1144-
media.get("route_media_type") or media["media_type"]
1146+
media.get("route_media_type") or media_type
11451147
if is_dict
1146-
else getattr(media, "route_media_type", None) or media.media_type
1148+
else getattr(media, "route_media_type", None) or media_type
11471149
),
11481150
"media_id": str(media["media_id"] if is_dict else media.media_id),
11491151
}
11501152

1151-
# Handle season/episode numbers if they exist
1152-
if is_dict:
1153-
if "season_number" in media:
1154-
kwargs["season_number"] = media["season_number"]
1155-
if "episode_number" in media:
1156-
kwargs["episode_number"] = media["episode_number"]
1157-
else:
1158-
season_number = getattr(media, "season_number", None)
1159-
episode_number = getattr(media, "episode_number", None)
1153+
# Providers may include season_number=0 on top-level TV/anime metadata.
1154+
# Only season and episode objects use those values as URL path segments.
1155+
if media_type in (MediaTypes.SEASON.value, MediaTypes.EPISODE.value):
1156+
season_number = (
1157+
media.get("season_number")
1158+
if is_dict
1159+
else getattr(media, "season_number", None)
1160+
)
11601161
if season_number is not None:
11611162
kwargs["season_number"] = season_number
1163+
if media_type == MediaTypes.EPISODE.value:
1164+
episode_number = (
1165+
media.get("episode_number")
1166+
if is_dict
1167+
else getattr(media, "episode_number", None)
1168+
)
11621169
if episode_number is not None:
11631170
kwargs["episode_number"] = episode_number
11641171

src/app/tests/test_templatetags.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,26 @@ def test_media_view_url(self):
880880
expected_tv_modal,
881881
)
882882

883+
# TMDB includes season_number=0 on top-level anime metadata. It is not
884+
# a season route and must not become /lists_modal/.../<id>/0.
885+
anime_dict = {
886+
"source": Sources.TMDB.value,
887+
"media_type": MediaTypes.ANIME.value,
888+
"media_id": "83611",
889+
"season_number": 0,
890+
}
891+
self.assertEqual(
892+
app_tags.media_view_url("lists_modal", anime_dict),
893+
reverse(
894+
"lists_modal",
895+
kwargs={
896+
"source": Sources.TMDB.value,
897+
"media_type": MediaTypes.ANIME.value,
898+
"media_id": "83611",
899+
},
900+
),
901+
)
902+
883903
def test_unicode_icon(self):
884904
"""Test the unicode_icon tag for all media types."""
885905
# Test all media types from MediaTypes

src/app/tests/views/test_media_details.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5421,6 +5421,154 @@ def test_season_details_secondary_fragment_renders_episodes(
54215421
)
54225422
mock_process_episodes.assert_called_once()
54235423

5424+
@patch("app.providers.services.get_media_metadata")
5425+
@patch("app.providers.tmdb.process_episodes")
5426+
def test_season_details_persists_and_self_heals_episode_release_datetime(
5427+
self,
5428+
mock_process_episodes,
5429+
mock_get_metadata,
5430+
):
5431+
"""Viewing a season page must persist release_datetime for every episode.
5432+
5433+
Regression test: episode Items created while browsing a season page
5434+
(as opposed to individually tracking/watching one) previously only
5435+
picked up runtime/rating from the live provider payload and silently
5436+
dropped release_datetime, leaving it NULL forever unless that exact
5437+
episode was later tracked or a manual metadata sync ran. This left
5438+
"not caught up" categorization permanently wrong for any unwatched
5439+
episode created this way.
5440+
"""
5441+
tv_item = Item.objects.create(
5442+
media_id="1668",
5443+
source=Sources.TMDB.value,
5444+
media_type=MediaTypes.TV.value,
5445+
title="Test TV Show",
5446+
image="http://example.com/show.jpg",
5447+
)
5448+
related_tv = TV.objects.create(
5449+
item=tv_item,
5450+
user=self.user,
5451+
status=Status.IN_PROGRESS.value,
5452+
)
5453+
season_item = Item.objects.create(
5454+
media_id="1668",
5455+
source=Sources.TMDB.value,
5456+
media_type=MediaTypes.SEASON.value,
5457+
title="Test TV Show",
5458+
image="http://example.com/show.jpg",
5459+
season_number=1,
5460+
)
5461+
Season.objects.create(
5462+
item=season_item,
5463+
user=self.user,
5464+
status=Status.IN_PROGRESS.value,
5465+
related_tv=related_tv,
5466+
)
5467+
5468+
# Episode 1 already has a trusted release_datetime — must not be
5469+
# clobbered by a differing value from the live payload.
5470+
already_dated_episode = Item.objects.create(
5471+
media_id="1668",
5472+
source=Sources.TMDB.value,
5473+
media_type=MediaTypes.EPISODE.value,
5474+
title="No Shortcuts",
5475+
image=settings.IMG_NONE,
5476+
season_number=1,
5477+
episode_number=1,
5478+
release_datetime=timezone.make_aware(datetime(2023, 1, 1)),
5479+
)
5480+
# Episode 2 exists (e.g. created by an earlier page view before its
5481+
# air date was known) but was never given a release_datetime.
5482+
stale_episode = Item.objects.create(
5483+
media_id="1668",
5484+
source=Sources.TMDB.value,
5485+
media_type=MediaTypes.EPISODE.value,
5486+
title="Test TV Show",
5487+
image=settings.IMG_NONE,
5488+
season_number=1,
5489+
episode_number=2,
5490+
release_datetime=None,
5491+
)
5492+
5493+
mock_get_metadata.side_effect = lambda *_args, **_kwargs: {
5494+
"title": "Test TV Show",
5495+
"media_id": "1668",
5496+
"source": Sources.TMDB.value,
5497+
"media_type": MediaTypes.TV.value,
5498+
"image": "http://example.com/image.jpg",
5499+
"season/1": {
5500+
"title": "Test TV Show",
5501+
"season_title": "Season 1",
5502+
"media_id": "1668",
5503+
"media_type": MediaTypes.SEASON.value,
5504+
"source": Sources.TMDB.value,
5505+
"image": "http://example.com/season.jpg",
5506+
"episodes": [
5507+
{
5508+
"episode_number": 1,
5509+
"name": "No Shortcuts",
5510+
"air_date": "2023-06-01", # differs from the stored date
5511+
"runtime": 48,
5512+
},
5513+
{
5514+
"episode_number": 2,
5515+
"name": "Episode 2",
5516+
"air_date": "2023-01-08",
5517+
"runtime": 42,
5518+
},
5519+
{
5520+
"episode_number": 3,
5521+
"name": "Episode 3",
5522+
"air_date": "2023-01-15",
5523+
"runtime": 44,
5524+
},
5525+
],
5526+
},
5527+
}
5528+
mock_process_episodes.return_value = []
5529+
5530+
response = self.client.get(
5531+
reverse(
5532+
"season_details",
5533+
kwargs={
5534+
"source": Sources.TMDB.value,
5535+
"media_id": "1668",
5536+
"title": "test-tv-show",
5537+
"season_number": 1,
5538+
},
5539+
),
5540+
{"fragment": "secondary"},
5541+
)
5542+
5543+
self.assertEqual(response.status_code, 200)
5544+
5545+
already_dated_episode.refresh_from_db()
5546+
self.assertEqual(
5547+
already_dated_episode.release_datetime,
5548+
timezone.make_aware(datetime(2023, 1, 1)),
5549+
"an already-set release_datetime must not be overwritten",
5550+
)
5551+
5552+
stale_episode.refresh_from_db()
5553+
self.assertEqual(
5554+
stale_episode.release_datetime,
5555+
timezone.make_aware(datetime(2023, 1, 8)),
5556+
"a NULL release_datetime must self-heal from the live payload",
5557+
)
5558+
5559+
new_episode = Item.objects.get(
5560+
media_id="1668",
5561+
source=Sources.TMDB.value,
5562+
media_type=MediaTypes.EPISODE.value,
5563+
season_number=1,
5564+
episode_number=3,
5565+
)
5566+
self.assertEqual(
5567+
new_episode.release_datetime,
5568+
timezone.make_aware(datetime(2023, 1, 15)),
5569+
"a newly created episode must get release_datetime from the same payload",
5570+
)
5571+
54245572
@patch("app.views.trakt_popularity_service.refresh_trakt_popularity")
54255573
@patch("app.providers.tmdb.get_tvdb_episode_image_map")
54265574
@patch("app.helpers.get_tmdb_backdrop_image")

0 commit comments

Comments
 (0)