From 53391372c3830b789b3c6456659792443cadcd04 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 22:26:37 -0700 Subject: [PATCH] video: fix detail reload (music router clobber) + reliably show missing episodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reload bug: music's router boots first, rewrites an unknown /video-detail/... URL to /dashboard, and my init read the already-changed URL (no restore) AND dispatched open-detail before video-detail.js was listening (empty page + stray back button). Fix: capture the path at SCRIPT-EVAL time (before music boots) and DEFER the restore to a macrotask so every DOMContentLoaded handler is registered and music's initial routing has run — then re-assert the real URL. Reload/deep-link now restore the exact item. Missing-episodes bug: the full-episode-list cascade only ran via the lazy refresh, which was gated on ART being missing — so a show that already had posters/logo never pulled its episode list (stayed owned-only). Added shows.episodes_synced (schema v5): the worker sets it after a full cascade; show_detail returns it; the lazy refresh now triggers when NOT synced, so owned + missing episodes populate. --- core/video/enrichment/worker.py | 4 ++++ database/video_database.py | 14 +++++++++++++- database/video_schema.sql | 1 + tests/test_video_database.py | 7 +++++++ webui/static/video/video-detail.js | 5 ++++- webui/static/video/video-side.js | 29 ++++++++++++++++++++--------- 6 files changed, 49 insertions(+), 11 deletions(-) diff --git a/core/video/enrichment/worker.py b/core/video/enrichment/worker.py index f8c3bdc6..a0c7502e 100644 --- a/core/video/enrichment/worker.py +++ b/core/video/enrichment/worker.py @@ -159,6 +159,10 @@ class VideoEnrichmentWorker: data.get("overview"), data.get("poster_url")) except Exception: logger.exception("episode backfill failed: show %s season %s", show_id, snum) + try: + self.db.mark_episodes_synced(show_id) + except Exception: + logger.exception("episode backfill: could not mark synced for show %s", show_id) # ── status (same shape the music enrichment API returns) ────────────────── def get_stats(self) -> dict: diff --git a/database/video_database.py b/database/video_database.py index f677b573..90654607 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -29,7 +29,7 @@ logger = get_logger("video_database") # Bump when video_schema.sql changes in a way worth recording. Stored in # PRAGMA user_version as a backstop indicator (nothing gates on it yet). -SCHEMA_VERSION = 4 +SCHEMA_VERSION = 5 _DEFAULT_DB_PATH = "database/video_library.db" _SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql" @@ -90,6 +90,7 @@ _COLUMN_MIGRATIONS = [ ("episodes", "rating", "REAL"), ("movies", "logo_url", "TEXT"), ("shows", "logo_url", "TEXT"), + ("shows", "episodes_synced", "INTEGER NOT NULL DEFAULT 0"), ] @@ -327,6 +328,16 @@ class VideoDatabase: finally: conn.close() + def mark_episodes_synced(self, show_id: int) -> None: + """Flag that the show's FULL episode list has been pulled from metadata + (so the lazy on-view refresh doesn't re-cascade every visit).""" + conn = self._get_connection() + try: + conn.execute("UPDATE shows SET episodes_synced=1 WHERE id=?", (show_id,)) + conn.commit() + finally: + conn.close() + def show_season_numbers(self, show_id: int) -> list: conn = self._get_connection() try: @@ -958,6 +969,7 @@ class VideoDatabase: "tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"], "has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]), "logo": show["logo_url"], + "episodes_synced": bool(show["episodes_synced"]), "monitored": bool(show["monitored"]), "season_count": len(out_seasons), "episode_total": total, "episode_owned": owned_total, diff --git a/database/video_schema.sql b/database/video_schema.sql index d49ebe9f..ca6c3770 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -125,6 +125,7 @@ CREATE TABLE IF NOT EXISTS shows ( poster_url TEXT, backdrop_url TEXT, logo_url TEXT, -- transparent title logo (clearlogo) + episodes_synced INTEGER NOT NULL DEFAULT 0, -- full episode list pulled from metadata? monitored INTEGER NOT NULL DEFAULT 1, -- "following" (watchlist) quality_profile_id INTEGER REFERENCES quality_profiles(id) ON DELETE SET NULL, root_folder_id INTEGER REFERENCES root_folders(id) ON DELETE SET NULL, diff --git a/tests/test_video_database.py b/tests/test_video_database.py index b8f2f0e6..3d417bbd 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -626,6 +626,13 @@ def test_backfill_inserts_missing_episodes_as_unowned(db): assert by[2]["title"] == "Two" and by[2]["air_date"] == "2020-01-08" +def test_episodes_synced_flag_drives_lazy_refresh(db): + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) + assert db.show_detail(sid)["episodes_synced"] is False # → lazy refresh will run + db.mark_episodes_synced(sid) + assert db.show_detail(sid)["episodes_synced"] is True # → won't re-cascade + + 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}]}]}) diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index c1b4308b..23b1875b 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -517,7 +517,10 @@ // it (once per show), then re-render. Sidesteps "already matched, never re-runs". function maybeRefreshArt(id) { if (artAttemptedFor === id || !data || data.id !== id) return; - var needs = !data.logo || (data.seasons || []).some(function (s) { return !s.has_poster; }); + // Trigger if the full episode list hasn't been pulled yet (so missing + // episodes show up), or any art is still missing. + var needs = !data.episodes_synced || !data.logo + || (data.seasons || []).some(function (s) { return !s.has_poster; }); if (!needs) return; artAttemptedFor = id; fetch(DETAIL_URL + 'show/' + id + '/refresh-art', diff --git a/webui/static/video/video-side.js b/webui/static/video/video-side.js index f951d02e..1ad6a6bf 100644 --- a/webui/static/video/video-side.js +++ b/webui/static/video/video-side.js @@ -16,6 +16,11 @@ (function () { 'use strict'; + // Captured at SCRIPT-EVAL time — before music's router boots (on + // DOMContentLoaded) and may rewrite an unknown /video-detail/... URL to + // /dashboard. This is the real path the user reloaded/deep-linked. + var BOOT_PATH = window.location.pathname; + var SIDE_KEY = 'soulsync_side'; var MUSIC_SUBTITLE = 'Music Sync & Manager'; var VIDEO_SUBTITLE = 'Movies, TV & YouTube'; @@ -196,8 +201,9 @@ } function init() { - // Capture a deep-linked detail path BEFORE applySide can clear it. - var bootDetail = parseDetailPath(window.location.pathname); + // Deep-linked detail path captured at eval time (music may already have + // rewritten window.location to /dashboard by now). + var bootDetail = parseDetailPath(BOOT_PATH); var toggleButtons = document.querySelectorAll('.side-toggle-btn'); for (var i = 0; i < toggleButtons.length; i++) { @@ -265,14 +271,19 @@ applySide(bootDetail ? 'video' : readSide()); - // Deep link / reload straight to a detail URL → restore it (applySide above - // may have cleared the URL, so re-assert it first). + // Deep link / reload straight to a detail URL → restore it. Deferred to a + // macrotask so EVERY script's DOMContentLoaded handler has registered + // first (video-detail.js loads after us and must be listening for the + // open-detail event), and so it lands AFTER music's initial routing — then + // we re-assert the real URL it clobbered. if (bootDetail) { - var path = buildDetailPath(bootDetail.source, bootDetail.kind, bootDetail.id); - try { - history.replaceState({ videoDetail: bootDetail }, '', path); - } catch (e) { /* ignore */ } - restoreDetail(bootDetail); + setTimeout(function () { + var path = buildDetailPath(bootDetail.source, bootDetail.kind, bootDetail.id); + try { + history.replaceState({ videoDetail: bootDetail }, '', path); + } catch (e) { /* ignore */ } + restoreDetail(bootDetail); + }, 0); } }