video: fix detail reload (music router clobber) + reliably show missing episodes

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.
This commit is contained in:
BoulderBadgeDad 2026-06-14 22:26:37 -07:00
parent 0598f5fbda
commit 53391372c3
6 changed files with 49 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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',

View file

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