Skip to content

Commit 028aca9

Browse files
committed
fix(specials): handle season 0 in Jellyfin scrobbles, Trakt import, and Next Up (#132)
1 parent e5800c3 commit 028aca9

6 files changed

Lines changed: 62 additions & 6 deletions

File tree

backend/routers/history.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,12 @@ def _group_last_watched(
568568
last_per_show: dict[int, tuple[int, int]] = {}
569569
last_watched_at: dict[int, datetime] = {}
570570
for show_id, season, episode, watched_at in rows:
571+
if season is None or episode is None:
572+
# Faulty history entry with an unknown season/episode (e.g. a
573+
# pre-fix scrobble that lost season 0) - skip it rather than let
574+
# it corrupt this show's position or crash the fallback lookup
575+
# below, which assumes season/episode are always ints.
576+
continue
571577
if show_id not in last_per_show:
572578
last_per_show[show_id] = (season, episode)
573579
if watched_at and (show_id not in last_watched_at or watched_at > last_watched_at[show_id]):

backend/routers/trakt.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ async def process_show(show_tmdb_id: int, entries: list[dict]):
612612
ep_data = entry.get("episode", {})
613613
season_num = ep_data.get("season")
614614
ep_num = ep_data.get("number")
615-
if season_num is None or season_num == 0 or ep_num is None:
615+
if season_num is None or ep_num is None:
616616
stats["skipped"] += 1
617617
continue
618618
try:

backend/routers/webhooks.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -521,9 +521,10 @@ def parse_jellyfin_payload(payload: dict) -> dict | None:
521521
position_ticks = payload.get("PlaybackPositionTicks") or payload.get("PositionTicks") or 0
522522
runtime_ticks = payload.get("RunTimeTicks") or 0
523523

524-
# SeasonNumber/EpisodeNumber are 1-indexed in the plugin template; 0 means absent
525-
season_num = payload.get("SeasonNumber") or None
526-
episode_num = payload.get("EpisodeNumber") or None
524+
# SeasonNumber/EpisodeNumber are absent from the payload for movies;
525+
# 0 is a valid season number (specials), so don't coerce it away.
526+
season_num = payload.get("SeasonNumber")
527+
episode_num = payload.get("EpisodeNumber")
527528

528529
return {
529530
"notification_type": notification_type,

backend/tests/test_next_up.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@ def test_keeps_most_recent_watched_at_across_rows(self):
6868
last_per_show, last_watched_at = _group_last_watched(rows)
6969
self.assertEqual(last_watched_at[1], newer)
7070

71+
def test_null_season_row_is_skipped_not_used_as_last_watched(self):
72+
# Regression for #132: a faulty history entry with a NULL season (e.g.
73+
# a pre-fix Season-0 scrobble) must not become a show's "furthest
74+
# watched" position — get_next_up would later pass that None straight
75+
# into an int comparison and crash the whole endpoint.
76+
rows = [
77+
(1, None, 3, datetime(2026, 1, 2, tzinfo=timezone.utc)),
78+
(1, 1, 5, datetime(2026, 1, 1, tzinfo=timezone.utc)),
79+
]
80+
last_per_show, last_watched_at = _group_last_watched(rows)
81+
self.assertEqual(last_per_show[1], (1, 5))
82+
83+
def test_show_with_only_null_season_rows_has_no_entry(self):
84+
rows = [(1, None, 1, None), (1, None, 2, None)]
85+
last_per_show, last_watched_at = _group_last_watched(rows)
86+
self.assertNotIn(1, last_per_show)
87+
7188

7289
class HasAiredTests(unittest.TestCase):
7390
"""Regression tests for #104: Next Up must not suggest an episode before

backend/tests/test_webhooks.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,37 @@ async def test_second_completed_event_for_same_media_within_window_is_skipped(se
6666
self.assertEqual(len(db.added), 0)
6767

6868

69+
class ParseJellyfinFlatPayloadSeasonZeroTests(unittest.TestCase):
70+
"""Regression test for #132: a Season 0 (specials) episode has
71+
SeasonNumber: 0 in the flat webhook payload, which a falsy check like
72+
`payload.get("SeasonNumber") or None` incorrectly coerces to None."""
73+
74+
def test_season_zero_is_preserved_not_coerced_to_none(self):
75+
payload = {
76+
"NotificationType": "PlaybackStart",
77+
"ItemType": "Episode",
78+
"ItemId": "abc123",
79+
"Name": "Behind the Scenes",
80+
"SeriesName": "Some Show",
81+
"SeasonNumber": 0,
82+
"EpisodeNumber": 1,
83+
"Provider_tmdb": "999",
84+
}
85+
data = parse_jellyfin_payload(payload)
86+
self.assertIsNotNone(data)
87+
self.assertEqual(data["season_number"], 0)
88+
89+
def test_movie_has_no_season_number(self):
90+
payload = {
91+
"NotificationType": "PlaybackStart",
92+
"ItemType": "Movie",
93+
"ItemId": "xyz",
94+
"Name": "A Movie",
95+
}
96+
data = parse_jellyfin_payload(payload)
97+
self.assertIsNone(data["season_number"])
98+
99+
69100
class ParseJellyfinUserDataSavedPayloadTests(unittest.TestCase):
70101
"""Regression test for #69: Jellyfin's official Webhook plugin has no
71102
"MarkPlayed" event — manually toggling watched/unwatched raises

frontend/src/components/HistoryCard.astro

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ const year = media.release_date?.slice(0, 4);
2121
const rawPoster = isEpisode ? (media.show_poster_path || media.poster_path) : media.poster_path;
2222
const posterPath = tmdbImageUrl(rawPoster, "w500");
2323
24+
const hasSeasonEpisode = media.season_number != null && media.episode_number != null;
2425
const href = isEpisode
25-
? (!media.tvdb_sourced && media.show_tmdb_id)
26+
? (!media.tvdb_sourced && media.show_tmdb_id && hasSeasonEpisode)
2627
? `/show/${media.show_tmdb_id}/season/${media.season_number}/${media.episode_number}`
27-
: media.show_tvdb_id
28+
: media.show_tvdb_id && hasSeasonEpisode
2829
? `/show/tvdb/${media.show_tvdb_id}/season/${media.season_number}/${media.episode_number}`
2930
: `/media/episode/${media.tmdb_id}`
3031
: `/media/movie/${media.tmdb_id}`;

0 commit comments

Comments
 (0)