diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index e532baa7..ecdc5840 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -104,13 +104,16 @@ class TMDBClient: if ert: meta["runtime_minutes"] = ert[0] meta["tvdb_id"] = _int(ext.get("tvdb_id")) - # Per-season posters — the reliable source of distinct season art - # (the media server usually lacks it). Backfilled into seasons. + # The FULL season list (poster may be None) — drives both the + # season-poster backfill and the episode cascade (so missing + # episodes/seasons get represented, not just what's on the server). seasons = [] for s in (dr.get("seasons") or []): - pp, sn = s.get("poster_path"), s.get("season_number") - if pp and sn is not None: - seasons.append({"season_number": sn, "poster_url": self.IMG + pp}) + sn = s.get("season_number") + if sn is None: + continue + seasons.append({"season_number": sn, + "poster_url": (self.IMG + s["poster_path"]) if s.get("poster_path") else None}) if seasons: meta["seasons"] = seasons self._add_credits(meta, dr.get("credits") or {}, dr.get("created_by") or []) @@ -222,12 +225,15 @@ class TMDBClient: en = e.get("episode_number") if en is None: continue - ep = {"episode_number": en, "overview": e.get("overview"), + ep = {"episode_number": en, "title": e.get("name"), "overview": e.get("overview"), + "air_date": e.get("air_date") or None, "runtime_minutes": e.get("runtime"), "rating": e.get("vote_average") or None} if e.get("still_path"): ep["still_url"] = self.IMG + e["still_path"] out.append(ep) - return {"overview": data.get("overview"), "episodes": out} + return {"overview": data.get("overview"), + "poster_url": (self.IMG + data["poster_path"]) if data.get("poster_path") else None, + "episodes": out} class TVDBClient: diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 18a2df88..41d55ead 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -88,7 +88,8 @@ class VideoEnrichmentEngine: self.db.enrichment_apply("tmdb", "show", show_id, matched=True, external_id=result["id"], metadata=result.get("metadata")) try: - w._cascade_episodes(show_id, result["id"]) # episode stills too + nums = [s["season_number"] for s in (result.get("metadata") or {}).get("seasons") or []] + w._cascade_episodes(show_id, result["id"], nums) # full list: owned + missing except Exception: logger.exception("refresh_show_art: episode cascade failed for show %s", show_id) return {"ok": True} diff --git a/core/video/enrichment/worker.py b/core/video/enrichment/worker.py index 0d032fb4..f8c3bdc6 100644 --- a/core/video/enrichment/worker.py +++ b/core/video/enrichment/worker.py @@ -132,26 +132,31 @@ class VideoEnrichmentWorker: # from the same provider (one call per season), so episodes ride along # with their show instead of being a separate (huge) queue. if item["kind"] == "show" and hasattr(self.client, "season_episodes"): - self._cascade_episodes(item["id"], result["id"]) + nums = [s["season_number"] for s in (result.get("metadata") or {}).get("seasons") or []] + self._cascade_episodes(item["id"], result["id"], nums) else: self.db.enrichment_apply(self.service, item["kind"], item["id"], matched=False) self.stats["not_found"] += 1 logger.info("No %s match for %s '%s'", self.display_name, item["kind"], item["title"]) return True - def _cascade_episodes(self, show_id, tv_id) -> None: - """Backfill a show's episodes from the provider (one call per season). - Best-effort: a season failure never aborts the show's enrichment.""" - try: - seasons = self.db.show_season_numbers(show_id) - except Exception: - logger.exception("episode backfill: season list failed for show %s", show_id) - return + def _cascade_episodes(self, show_id, tv_id, season_numbers=None) -> None: + """Backfill a show's FULL episode list from the provider (one call per + season) — owned + missing. Best-effort: a season failure never aborts the + show's enrichment. Falls back to the known seasons if none are passed.""" + seasons = season_numbers + if not seasons: + try: + seasons = self.db.show_season_numbers(show_id) + except Exception: + logger.exception("episode backfill: season list failed for show %s", show_id) + return for snum in seasons: try: data = self.client.season_episodes(tv_id, snum) if data and data.get("episodes"): - self.db.backfill_episodes(show_id, snum, data["episodes"], data.get("overview")) + self.db.backfill_episodes(show_id, snum, data["episodes"], + data.get("overview"), data.get("poster_url")) except Exception: logger.exception("episode backfill failed: show %s season %s", show_id, snum) diff --git a/database/video_database.py b/database/video_database.py index c11fce4e..f677b573 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -337,34 +337,50 @@ class VideoDatabase: conn.close() def backfill_episodes(self, show_id: int, season_number: int, episodes: list, - season_overview: str | None = None) -> int: - """Gap-only backfill of episode still/overview/rating (and the season's - overview) from enrichment — never clobbers what the server provided. + season_overview: str | None = None, season_poster: str | None = None) -> int: + """UPSERT a season's episodes from the metadata provider so the show's + FULL episode list is represented — owned episodes (from the server) keep + has_file=1, and episodes the server doesn't have are inserted as MISSING + (has_file=0). Existing rows get gap-only metadata fills (never clobbered); + the season row is created if it didn't exist (a fully-missing season). Returns the number of episode rows touched.""" conn = self._get_connection() touched = 0 try: + conn.execute("INSERT OR IGNORE INTO seasons (show_id, season_number) VALUES (?, ?)", + (show_id, season_number)) + season_id = conn.execute("SELECT id FROM seasons WHERE show_id=? AND season_number=?", + (show_id, season_number)).fetchone()["id"] + if season_overview or season_poster: + conn.execute("UPDATE seasons SET overview=COALESCE(NULLIF(overview, ''), ?), " + "poster_url=COALESCE(NULLIF(poster_url, ''), ?) WHERE id=?", + (season_overview, season_poster, season_id)) for e in (episodes or []): en = e.get("episode_number") if en is None: continue - sets, params = [], [] - for col in ("still_url", "overview", "rating"): - val = e.get(col) - if val is None: - continue - sets.append(f"{col}=COALESCE(NULLIF({col}, ''), ?)") - params.append(val) - if not sets: - continue - params += [show_id, season_number, en] - cur = conn.execute( - f"UPDATE episodes SET {', '.join(sets)} " - "WHERE show_id=? AND season_number=? AND episode_number=?", params) - touched += cur.rowcount - if season_overview: - conn.execute("UPDATE seasons SET overview=COALESCE(NULLIF(overview, ''), ?) " - "WHERE show_id=? AND season_number=?", (season_overview, show_id, season_number)) + row = conn.execute( + "SELECT id FROM episodes WHERE show_id=? AND season_number=? AND episode_number=?", + (show_id, season_number, en)).fetchone() + if row: + sets, params = [], [] + for col in ("title", "still_url", "overview", "air_date", "rating", "runtime_minutes"): + if e.get(col) is None: + continue + sets.append(f"{col}=COALESCE(NULLIF({col}, ''), ?)") + params.append(e[col]) + if sets: + params += [row["id"]] + conn.execute(f"UPDATE episodes SET {', '.join(sets)} WHERE id=?", params) + touched += 1 + else: + conn.execute( + "INSERT INTO episodes (show_id, season_id, season_number, episode_number, title, " + "overview, air_date, runtime_minutes, still_url, rating, has_file) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)", + (show_id, season_id, season_number, en, e.get("title"), e.get("overview"), + e.get("air_date"), e.get("runtime_minutes"), e.get("still_url"), e.get("rating"))) + touched += 1 conn.commit() return touched finally: @@ -724,9 +740,12 @@ class VideoDatabase: ).fetchone()["id"] self._set_media_file(conn, "episode_id", ep_id, ep.get("file")) - # Prune episodes/seasons that vanished from the server for this show. + # Prune only SERVER-originated rows that vanished (server_id set) — the + # full episode/season list now includes enrichment-added MISSING items + # (server_id NULL), which the scan must never remove. for row in conn.execute( - "SELECT season_number, episode_number FROM episodes WHERE show_id=?", (show_id,) + "SELECT season_number, episode_number FROM episodes " + "WHERE show_id=? AND server_id IS NOT NULL", (show_id,) ).fetchall(): if (row["season_number"], row["episode_number"]) not in seen_eps: conn.execute( @@ -734,7 +753,7 @@ class VideoDatabase: (show_id, row["season_number"], row["episode_number"]), ) for row in conn.execute( - "SELECT season_number FROM seasons WHERE show_id=?", (show_id,) + "SELECT season_number FROM seasons WHERE show_id=? AND server_id IS NOT NULL", (show_id,) ).fetchall(): if row["season_number"] not in seen_seasons: conn.execute("DELETE FROM seasons WHERE show_id=? AND season_number=?", diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 636c6c44..b8f2f0e6 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -206,9 +206,9 @@ def test_upsert_movie_inserts_updates_and_attaches_file(db): def test_upsert_show_tree_builds_seasons_episodes_and_prunes(db): item = {"server_id": "s1", "title": "Show", "seasons": [ {"season_number": 1, "server_id": "se1", "episodes": [ - {"episode_number": 1, "title": "E1", "air_date": "2020-01-01", + {"episode_number": 1, "title": "E1", "server_id": "ep1", "air_date": "2020-01-01", "file": {"relative_path": "e1.mkv", "size_bytes": 10}}, - {"episode_number": 2, "title": "E2", "air_date": "2020-01-08"}]}]} + {"episode_number": 2, "title": "E2", "server_id": "ep2", "air_date": "2020-01-08"}]}]} sid = db.upsert_show_tree("plex", item) with db.connect() as c: assert c.execute("SELECT COUNT(*) FROM episodes WHERE show_id=?", (sid,)).fetchone()[0] == 2 @@ -610,6 +610,46 @@ def test_backfill_episodes_gap_only_and_season_overview(db): (sid,)).fetchone()["overview"] == "Season one" +def test_backfill_inserts_missing_episodes_as_unowned(db): + # Server has only E1; the provider's season has E1-E3 → E2/E3 are inserted + # MISSING (has_file=0) so the page shows what we have AND what we need. + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [ + {"season_number": 1, "episodes": [{"episode_number": 1, "file": {"relative_path": "e1.mkv"}}]}]}) + db.backfill_episodes(sid, 1, [ + {"episode_number": 1, "title": "One"}, + {"episode_number": 2, "title": "Two", "air_date": "2020-01-08"}, + {"episode_number": 3, "title": "Three"}]) + s1 = db.show_detail(sid)["seasons"][0] + assert (s1["episode_total"], s1["episode_owned"]) == (3, 1) # full list, 1 owned + by = {e["episode_number"]: e for e in s1["episodes"]} + assert by[1]["owned"] is True and by[2]["owned"] is False and by[3]["owned"] is False + assert by[2]["title"] == "Two" and by[2]["air_date"] == "2020-01-08" + + +def test_backfill_creates_fully_missing_season(db): + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [ + {"season_number": 1, "episodes": [{"episode_number": 1}]}]}) + db.backfill_episodes(sid, 2, [{"episode_number": 1, "title": "S2E1"}], + season_poster="https://img/s2.jpg") # season 2 not on the server + d = db.show_detail(sid) + s2 = [s for s in d["seasons"] if s["season_number"] == 2][0] + assert (s2["episode_total"], s2["episode_owned"]) == (1, 0) and s2["has_poster"] is True + + +def test_missing_episodes_survive_a_rescan_prune(db): + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [ + {"season_number": 1, "episodes": [{"episode_number": 1, "file": {"relative_path": "e1.mkv"}}]}]}) + db.backfill_episodes(sid, 1, [{"episode_number": 1}, {"episode_number": 2}, {"episode_number": 3}]) + # Re-scan: the server still only reports E1; the prune must NOT remove the + # enrichment-added missing episodes (server_id NULL). + db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [ + {"season_number": 1, "episodes": [{"episode_number": 1, "file": {"relative_path": "e1.mkv"}}]}]}) + with db.connect() as c: + nums = [r["episode_number"] for r in c.execute( + "SELECT episode_number FROM episodes WHERE show_id=? ORDER BY episode_number", (sid,)).fetchall()] + assert nums == [1, 2, 3] + + def test_breakdown_reports_episode_art_coverage_for_tmdb(db): db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [ {"season_number": 1, "episodes": [{"episode_number": 1, "still_url": "/e1.jpg"}, diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index f01af3a0..daf4edf4 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -296,8 +296,11 @@ def test_tmdb_show_returns_season_posters(monkeypatch): {"season_number": 2, "poster_path": None}]} monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail))) m = TMDBClient("KEY").match("show", "Show", 2020, known_id=1396)["metadata"] - assert m["seasons"] == [{"season_number": 1, - "poster_url": "https://image.tmdb.org/t/p/original/a.jpg"}] + # The FULL season list now comes back (poster None where TMDB has none) so the + # episode cascade can represent missing seasons too. + assert m["seasons"] == [ + {"season_number": 1, "poster_url": "https://image.tmdb.org/t/p/original/a.jpg"}, + {"season_number": 2, "poster_url": None}] def test_tmdb_client_raises_on_rate_limit(monkeypatch):