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.
This commit is contained in:
BoulderBadgeDad 2026-06-14 22:02:58 -07:00
parent 6e526d7745
commit c565fec59d
6 changed files with 119 additions and 45 deletions

View file

@ -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:

View file

@ -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}

View file

@ -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)

View file

@ -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=?",

View file

@ -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"},

View file

@ -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):