From 061079f0f65dd2d38ccc39bfe7a3841977fe1cc4 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 00:13:08 -0700 Subject: [PATCH] video scan: don't crash on episodes with no episode number Plex specials/unmatched episodes can have a null index -> getattr(...,0) still returned None -> 'NOT NULL constraint failed: episodes.episode_number'. - Plex adapter skips episodes with no index (logged), passes a real number. - upsert_show_tree defensively skips any episode missing season/episode number, so no source can crash a scan. Test added. --- core/video/sources.py | 10 +++++++--- database/video_database.py | 4 +++- tests/test_video_database.py | 11 +++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/core/video/sources.py b/core/video/sources.py index 848c0fbd..1d51f1de 100644 --- a/core/video/sources.py +++ b/core/video/sources.py @@ -122,13 +122,13 @@ class PlexVideoSource: "file": self._part_file(m), } - def _episode(self, ep, snum) -> dict: + def _episode(self, ep, snum, enum) -> dict: dur = getattr(ep, "duration", None) aired = getattr(ep, "originallyAvailableAt", None) return { "server_id": str(ep.ratingKey), "season_number": snum, - "episode_number": getattr(ep, "index", 0), + "episode_number": enum, "title": ep.title, "overview": getattr(ep, "summary", None), "air_date": aired.date().isoformat() if aired else None, @@ -142,8 +142,12 @@ class PlexVideoSource: seasons_map = {} try: for ep in sh.episodes(): + enum = getattr(ep, "index", None) + if enum is None: + # No episode number (unmatched/special) — can't key it; skip. + continue snum = ep.parentIndex if getattr(ep, "parentIndex", None) is not None else 0 - seasons_map.setdefault(snum, []).append(self._episode(ep, snum)) + seasons_map.setdefault(snum, []).append(self._episode(ep, snum, enum)) except Exception: logger.exception("Plex: failed reading episodes for %s", getattr(sh, "title", "?")) seasons = [{"server_id": None, "season_number": n, "title": None, diff --git a/database/video_database.py b/database/video_database.py index a3a0bef6..7396a8b3 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -252,7 +252,9 @@ class VideoDatabase: ).fetchone()["id"] for ep in season.get("episodes", []): - enum = ep["episode_number"] + enum = ep.get("episode_number") + if enum is None or snum is None: + continue # can't key an episode without season+episode numbers seen_eps.add((snum, enum)) conn.execute( "INSERT INTO episodes (show_id, season_id, server_source, server_id, " diff --git a/tests/test_video_database.py b/tests/test_video_database.py index ed110a17..187834f3 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -216,6 +216,17 @@ def test_upsert_show_tree_builds_seasons_episodes_and_prunes(db): assert eps == [1] +def test_upsert_show_tree_skips_episodes_without_number(db): + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [ + {"season_number": 1, "episodes": [ + {"episode_number": 1, "title": "E1"}, + {"episode_number": None, "title": "Unmatched special"}]}]}) + with db.connect() as c: + eps = [r["episode_number"] for r in c.execute( + "SELECT episode_number FROM episodes WHERE show_id=?", (sid,)).fetchall()] + assert eps == [1] + + def test_prune_missing_removes_unseen_top_level(db): db.upsert_movie("plex", {"server_id": "a", "title": "A"}) db.upsert_movie("plex", {"server_id": "b", "title": "B"})