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:
parent
0598f5fbda
commit
53391372c3
6 changed files with 49 additions and 11 deletions
|
|
@ -159,6 +159,10 @@ class VideoEnrichmentWorker:
|
||||||
data.get("overview"), data.get("poster_url"))
|
data.get("overview"), data.get("poster_url"))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("episode backfill failed: show %s season %s", show_id, snum)
|
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) ──────────────────
|
# ── status (same shape the music enrichment API returns) ──────────────────
|
||||||
def get_stats(self) -> dict:
|
def get_stats(self) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ logger = get_logger("video_database")
|
||||||
|
|
||||||
# Bump when video_schema.sql changes in a way worth recording. Stored in
|
# 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).
|
# 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"
|
_DEFAULT_DB_PATH = "database/video_library.db"
|
||||||
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
|
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
|
||||||
|
|
@ -90,6 +90,7 @@ _COLUMN_MIGRATIONS = [
|
||||||
("episodes", "rating", "REAL"),
|
("episodes", "rating", "REAL"),
|
||||||
("movies", "logo_url", "TEXT"),
|
("movies", "logo_url", "TEXT"),
|
||||||
("shows", "logo_url", "TEXT"),
|
("shows", "logo_url", "TEXT"),
|
||||||
|
("shows", "episodes_synced", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -327,6 +328,16 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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:
|
def show_season_numbers(self, show_id: int) -> list:
|
||||||
conn = self._get_connection()
|
conn = self._get_connection()
|
||||||
try:
|
try:
|
||||||
|
|
@ -958,6 +969,7 @@ class VideoDatabase:
|
||||||
"tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"],
|
"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"]),
|
"has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]),
|
||||||
"logo": show["logo_url"],
|
"logo": show["logo_url"],
|
||||||
|
"episodes_synced": bool(show["episodes_synced"]),
|
||||||
"monitored": bool(show["monitored"]),
|
"monitored": bool(show["monitored"]),
|
||||||
"season_count": len(out_seasons),
|
"season_count": len(out_seasons),
|
||||||
"episode_total": total, "episode_owned": owned_total,
|
"episode_total": total, "episode_owned": owned_total,
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ CREATE TABLE IF NOT EXISTS shows (
|
||||||
poster_url TEXT,
|
poster_url TEXT,
|
||||||
backdrop_url TEXT,
|
backdrop_url TEXT,
|
||||||
logo_url TEXT, -- transparent title logo (clearlogo)
|
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)
|
monitored INTEGER NOT NULL DEFAULT 1, -- "following" (watchlist)
|
||||||
quality_profile_id INTEGER REFERENCES quality_profiles(id) ON DELETE SET NULL,
|
quality_profile_id INTEGER REFERENCES quality_profiles(id) ON DELETE SET NULL,
|
||||||
root_folder_id INTEGER REFERENCES root_folders(id) ON DELETE SET NULL,
|
root_folder_id INTEGER REFERENCES root_folders(id) ON DELETE SET NULL,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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):
|
def test_backfill_creates_fully_missing_season(db):
|
||||||
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [
|
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [
|
||||||
{"season_number": 1, "episodes": [{"episode_number": 1}]}]})
|
{"season_number": 1, "episodes": [{"episode_number": 1}]}]})
|
||||||
|
|
|
||||||
|
|
@ -517,7 +517,10 @@
|
||||||
// it (once per show), then re-render. Sidesteps "already matched, never re-runs".
|
// it (once per show), then re-render. Sidesteps "already matched, never re-runs".
|
||||||
function maybeRefreshArt(id) {
|
function maybeRefreshArt(id) {
|
||||||
if (artAttemptedFor === id || !data || data.id !== id) return;
|
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;
|
if (!needs) return;
|
||||||
artAttemptedFor = id;
|
artAttemptedFor = id;
|
||||||
fetch(DETAIL_URL + 'show/' + id + '/refresh-art',
|
fetch(DETAIL_URL + 'show/' + id + '/refresh-art',
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'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 SIDE_KEY = 'soulsync_side';
|
||||||
var MUSIC_SUBTITLE = 'Music Sync & Manager';
|
var MUSIC_SUBTITLE = 'Music Sync & Manager';
|
||||||
var VIDEO_SUBTITLE = 'Movies, TV & YouTube';
|
var VIDEO_SUBTITLE = 'Movies, TV & YouTube';
|
||||||
|
|
@ -196,8 +201,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
// Capture a deep-linked detail path BEFORE applySide can clear it.
|
// Deep-linked detail path captured at eval time (music may already have
|
||||||
var bootDetail = parseDetailPath(window.location.pathname);
|
// rewritten window.location to /dashboard by now).
|
||||||
|
var bootDetail = parseDetailPath(BOOT_PATH);
|
||||||
|
|
||||||
var toggleButtons = document.querySelectorAll('.side-toggle-btn');
|
var toggleButtons = document.querySelectorAll('.side-toggle-btn');
|
||||||
for (var i = 0; i < toggleButtons.length; i++) {
|
for (var i = 0; i < toggleButtons.length; i++) {
|
||||||
|
|
@ -265,14 +271,19 @@
|
||||||
|
|
||||||
applySide(bootDetail ? 'video' : readSide());
|
applySide(bootDetail ? 'video' : readSide());
|
||||||
|
|
||||||
// Deep link / reload straight to a detail URL → restore it (applySide above
|
// Deep link / reload straight to a detail URL → restore it. Deferred to a
|
||||||
// may have cleared the URL, so re-assert it first).
|
// 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) {
|
if (bootDetail) {
|
||||||
var path = buildDetailPath(bootDetail.source, bootDetail.kind, bootDetail.id);
|
setTimeout(function () {
|
||||||
try {
|
var path = buildDetailPath(bootDetail.source, bootDetail.kind, bootDetail.id);
|
||||||
history.replaceState({ videoDetail: bootDetail }, '', path);
|
try {
|
||||||
} catch (e) { /* ignore */ }
|
history.replaceState({ videoDetail: bootDetail }, '', path);
|
||||||
restoreDetail(bootDetail);
|
} catch (e) { /* ignore */ }
|
||||||
|
restoreDetail(bootDetail);
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue