From c565fec59da5a30092b868d72349a40b966d71d4 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 22:02:58 -0700 Subject: [PATCH] video: show the FULL episode list (owned + missing), Sonarr-style Previously the episodes table held only what the server has (all 'Owned'), so the detail page never showed what you're missing. Now the metadata provider defines the full series structure and the server marks ownership: - TMDB returns the full season list (poster optional) + full episode fields (title/air date/runtime/still/rating) per season. - backfill_episodes UPSERTs: owned episodes keep has_file=1; episodes the server lacks are inserted as MISSING (has_file=0); fully-missing seasons get created. The cascade now iterates every TMDB season, not just the ones on the server. - The scan prune only removes SERVER-originated rows (server_id set) that vanished, so enrichment-added missing episodes/seasons are never pruned on re-scan. Season coverage (X / Y) is now meaningful, and the episode list shows Owned + Missing together. Seam tests: missing-episode insert, fully-missing season, prune preserves missing. --- core/video/enrichment/clients.py | 20 ++++++---- core/video/enrichment/engine.py | 3 +- core/video/enrichment/worker.py | 25 +++++++----- database/video_database.py | 65 +++++++++++++++++++++----------- tests/test_video_database.py | 44 ++++++++++++++++++++- tests/test_video_enrichment.py | 7 +++- 6 files changed, 119 insertions(+), 45 deletions(-) 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):