Skip to content
Draft
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
23 changes: 23 additions & 0 deletions backend/src/librarysync/core/next_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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),
}


Expand Down
37 changes: 35 additions & 2 deletions backend/src/librarysync/jobs/simkl_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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")
Expand Down
15 changes: 15 additions & 0 deletions backend/src/librarysync/static/page-home.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 13 additions & 1 deletion backend/tests/test_next_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@
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",
season_number=season,
episode_number=episode_number,
title=title,
air_date=air_date,
raw=raw,
)


Expand Down Expand Up @@ -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):
Expand Down
53 changes: 53 additions & 0 deletions backend/tests/test_simkl_import.py
Original file line number Diff line number Diff line change
@@ -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}}