diff --git a/backend/src/librarysync/core/next_episode.py b/backend/src/librarysync/core/next_episode.py index f0f8a1a..b7102f8 100644 --- a/backend/src/librarysync/core/next_episode.py +++ b/backend/src/librarysync/core/next_episode.py @@ -14,6 +14,28 @@ SHOW_MEDIA_TYPES = ("tv", "anime") +def _coerce_finale_type(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value in {1, 2, 3} else None + if isinstance(value, str): + cleaned = value.strip() + if cleaned in {"1", "2", "3"}: + return int(cleaned) + return None + + +def _episode_finale_type(episode: EpisodeItem) -> int | None: + raw = episode.raw if isinstance(getattr(episode, "raw", None), dict) else {} + simkl = raw.get("simkl") + if isinstance(simkl, dict): + finale_type = _coerce_finale_type(simkl.get("finale_type")) + if finale_type is not None: + return finale_type + return _coerce_finale_type(raw.get("finale_type")) + + def select_next_episode( released_episodes: Iterable[EpisodeItem], watched_episode_ids: set[str], @@ -35,6 +57,7 @@ def episode_to_payload(episode: EpisodeItem) -> dict: "episode_number": episode.episode_number, "title": episode.title, "air_date": episode.air_date.isoformat() if episode.air_date else None, + "finale_type": _episode_finale_type(episode), } diff --git a/backend/src/librarysync/jobs/simkl_import.py b/backend/src/librarysync/jobs/simkl_import.py index 187ff3d..8bd21cb 100644 --- a/backend/src/librarysync/jobs/simkl_import.py +++ b/backend/src/librarysync/jobs/simkl_import.py @@ -4,7 +4,7 @@ import logging import re from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone from typing import Any from sqlalchemy import select @@ -102,6 +102,7 @@ class EpisodeSummary: tmdb_id: str | None tvdb_id: str | None simkl_id: str | None + air_date: date | None raw: dict[str, Any] @@ -322,6 +323,26 @@ def _extract_activity_timestamp(payload: dict[str, Any] | None) -> datetime | No return None +def _parse_date_value(value: object) -> date | None: + if value is None: + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + parsed = _parse_datetime(value) + if parsed: + return parsed.date() + if isinstance(value, str): + cleaned = value.strip() + if len(cleaned) >= 10: + try: + return datetime.strptime(cleaned[:10], "%Y-%m-%d").date() + except ValueError: + return None + return None + + def _extract_activity_block_timestamp( payload: dict[str, Any] | None, key: str ) -> datetime | None: @@ -1279,6 +1300,7 @@ async def _get_or_create_episode_item( season_number=episode.season_number, episode_number=episode.episode_number, title=episode.title, + air_date=episode.air_date, tmdb_id=episode.tmdb_id, tvdb_id=episode.tvdb_id, imdb_id=episode.imdb_id, @@ -1410,6 +1432,8 @@ async def _apply_episode_updates( await _maybe_set_episode_id(db, item, "tvdb_id", episode.tvdb_id) if episode.title and not item.title: item.title = episode.title + if episode.air_date and item.air_date != episode.air_date: + item.air_date = episode.air_date item.raw = _merge_episode_raw(item.raw, episode.simkl_id, episode.raw) @@ -1782,13 +1806,22 @@ def _extract_episode_summary(entry: dict[str, Any]) -> EpisodeSummary | None: tmdb_id=_coerce_str(ids.get("tmdb")), tvdb_id=_coerce_str(ids.get("tvdb")), simkl_id=_coerce_str(ids.get("simkl")), + air_date=_extract_episode_air_date(payload), raw=_sanitize_simkl_payload(payload), ) +def _extract_episode_air_date(payload: dict[str, Any]) -> date | None: + for key in ("air_date", "aired", "date", "release_date"): + parsed = _parse_date_value(payload.get(key)) + if parsed: + return parsed + return None + + def _sanitize_simkl_payload(payload: dict[str, Any]) -> dict[str, Any]: keep: dict[str, Any] = {} - for key in ("title", "year", "season", "episode", "number"): + for key in ("title", "year", "season", "episode", "number", "air_date", "aired", "date", "finale_type"): if key in payload: keep[key] = payload[key] ids = payload.get("ids") diff --git a/backend/src/librarysync/static/page-home.js b/backend/src/librarysync/static/page-home.js index 6187d20..5888b80 100644 --- a/backend/src/librarysync/static/page-home.js +++ b/backend/src/librarysync/static/page-home.js @@ -98,6 +98,13 @@ async function loadUpNext() { } } +function getFinaleBadgeLabel(finaleType) { + if (finaleType === 1) return "Mid-season finale"; + if (finaleType === 2) return "Season finale"; + if (finaleType === 3) return "Series finale"; + return ""; +} + function buildUpNextRow(item) { const nextEpisode = item.next_episode || {}; const episodeLabel = formatSeasonEpisode( @@ -146,6 +153,14 @@ function buildUpNextRow(item) { titleRow.appendChild(badge); } + const finaleLabel = getFinaleBadgeLabel(nextEpisode.finale_type); + if (finaleLabel) { + const badge = document.createElement("span"); + badge.className = "up-next-new-badge"; + badge.textContent = finaleLabel; + titleRow.appendChild(badge); + } + // Meta line: episode title ยท aired date const metaParts = []; if (nextEpisode.title) { diff --git a/backend/tests/test_next_episode.py b/backend/tests/test_next_episode.py index 19a6d41..41a9da2 100644 --- a/backend/tests/test_next_episode.py +++ b/backend/tests/test_next_episode.py @@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -def _episode(episode_id, season, episode_number, air_date=None, title="Episode"): +def _episode(episode_id, season, episode_number, air_date=None, title="Episode", raw=None): return SimpleNamespace( id=episode_id, show_media_item_id="show-1", @@ -24,6 +24,7 @@ def _episode(episode_id, season, episode_number, air_date=None, title="Episode") episode_number=episode_number, title=title, air_date=air_date, + raw=raw, ) @@ -54,12 +55,23 @@ def test_serializes_episode_fields(self): "episode_number": 5, "title": "Finale", "air_date": "2024-03-01", + "finale_type": None, } def test_handles_missing_air_date(self): episode = _episode("e1", 1, 1, air_date=None) assert episode_to_payload(episode)["air_date"] is None + def test_serializes_finale_type_from_simkl_raw(self): + episode = _episode( + "e1", + 1, + 10, + air_date=date(2024, 3, 1), + raw={"simkl": {"finale_type": "3"}}, + ) + assert episode_to_payload(episode)["finale_type"] == 3 + class TestOrderUpNextItems: def _item(self, media_item_id, last_watched_at, is_new_release): diff --git a/backend/tests/test_simkl_import.py b/backend/tests/test_simkl_import.py new file mode 100644 index 0000000..1a87e62 --- /dev/null +++ b/backend/tests/test_simkl_import.py @@ -0,0 +1,53 @@ +import asyncio +from datetime import date + +from librarysync.db.models import EpisodeItem +from librarysync.jobs import simkl_import + + +def test_extract_episode_summary_parses_utc_air_date_and_preserves_finale_type() -> None: + summary = simkl_import._extract_episode_summary( + { + "episode": { + "season": 2, + "episode": 8, + "title": "Finale", + "aired": "2026-01-31T23:15:00Z", + "finale_type": 2, + "ids": {"simkl": 456}, + } + } + ) + + assert summary is not None + assert summary.air_date == date(2026, 1, 31) + assert summary.raw["aired"] == "2026-01-31T23:15:00Z" + assert summary.raw["finale_type"] == 2 + + +def test_apply_episode_updates_sets_air_date_and_merges_simkl_raw() -> None: + item = EpisodeItem( + show_media_item_id="show-1", + season_number=1, + episode_number=1, + title=None, + air_date=None, + raw=None, + ) + summary = simkl_import.EpisodeSummary( + season_number=1, + episode_number=1, + title="Pilot", + imdb_id=None, + tmdb_id=None, + tvdb_id=None, + simkl_id="789", + air_date=date(2026, 2, 1), + raw={"finale_type": 1}, + ) + + asyncio.run(simkl_import._apply_episode_updates(item=item, episode=summary, db=None)) + + assert item.title == "Pilot" + assert item.air_date == date(2026, 2, 1) + assert item.raw == {"simkl_id": "789", "simkl": {"finale_type": 1}}