From e8a0e1256b0a4e66b16fef165ce28275d1cbc9c4 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 18:21:26 -0700 Subject: [PATCH] Wishlist: episode synopsis + clickable cards + FIFO/newest sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More data in the roomier episode cards (asked for): episodes now carry a synopsis (new video_wishlist.episode_overview, SCHEMA_VERSION 12 + migration; captured at add-time, and the art-backfill fills it for old rows from the same tmdb_season call). The card shows a 2-line synopsis under the meta line and is now clickable -> opens the show detail (episodes have no page of their own). Organize the wishlist two ways: added 'Oldest first' (FIFO) alongside 'Recently added' (newest) — query_wishlist gains the 'oldest' sort for both movies + shows. Tests: FIFO/newest ordering (with pinned add-times) + overview roundtrip/backfill. 105 passed. Music wishlist untouched. --- api/video/wishlist.py | 11 +++++--- database/video_database.py | 38 ++++++++++++++++++++------- database/video_schema.sql | 1 + tests/test_video_database.py | 21 +++++++++++++++ webui/index.html | 1 + webui/static/video/video-get-modal.js | 6 ++--- webui/static/video/video-side.css | 13 +++++---- webui/static/video/video-wishlist.js | 16 ++++++----- 8 files changed, 80 insertions(+), 27 deletions(-) diff --git a/api/video/wishlist.py b/api/video/wishlist.py index 5ea2bde8..d2351bea 100644 --- a/api/video/wishlist.py +++ b/api/video/wishlist.py @@ -126,10 +126,13 @@ def register_routes(bp): if se.get("poster_url"): updated += db.set_wishlist_season_poster(grp["tmdb_id"], grp["season_number"], se["poster_url"]) for ep in (se.get("episodes") or []): - if ep.get("still_url") and ep.get("episode_number") is not None: - if db.set_wishlist_still(grp["tmdb_id"], grp["season_number"], - ep["episode_number"], ep["still_url"]): - updated += 1 + en = ep.get("episode_number") + if en is None: + continue + if ep.get("still_url") and db.set_wishlist_still(grp["tmdb_id"], grp["season_number"], en, ep["still_url"]): + updated += 1 + if ep.get("overview"): + db.set_wishlist_episode_overview(grp["tmdb_id"], grp["season_number"], en, ep["overview"]) return jsonify({"success": True, "updated": updated}) except Exception: logger.exception("wishlist art backfill failed") diff --git a/database/video_database.py b/database/video_database.py index c2e4402f..8576b295 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 = 11 +SCHEMA_VERSION = 12 _DEFAULT_DB_PATH = "database/video_library.db" _SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql" @@ -101,6 +101,7 @@ _COLUMN_MIGRATIONS = [ ("video_watchlist", "state", "TEXT NOT NULL DEFAULT 'follow'"), # follow | mute (tombstone) ("video_wishlist", "still_url", "TEXT"), # episode still thumbnail (captured at add time) ("video_wishlist", "season_poster_url", "TEXT"), # the episode's season poster + ("video_wishlist", "episode_overview", "TEXT"), # episode synopsis ] @@ -1519,18 +1520,20 @@ class VideoDatabase: conn.execute( """INSERT INTO video_wishlist (kind, tmdb_id, title, poster_url, season_number, episode_number, - episode_title, still_url, season_poster_url, air_date, library_id, server_source) - VALUES ('episode', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + episode_title, still_url, episode_overview, season_poster_url, + air_date, library_id, server_source) + VALUES ('episode', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(tmdb_id, season_number, episode_number) WHERE kind='episode' DO UPDATE SET title=excluded.title, poster_url=COALESCE(excluded.poster_url, video_wishlist.poster_url), episode_title=COALESCE(excluded.episode_title, video_wishlist.episode_title), still_url=COALESCE(excluded.still_url, video_wishlist.still_url), + episode_overview=COALESCE(excluded.episode_overview, video_wishlist.episode_overview), season_poster_url=COALESCE(excluded.season_poster_url, video_wishlist.season_poster_url), air_date=COALESCE(excluded.air_date, video_wishlist.air_date), library_id=COALESCE(excluded.library_id, video_wishlist.library_id)""", (int(show_tmdb_id), show_title, poster_url, int(sn), int(en), - e.get("title"), e.get("still_url"), e.get("season_poster_url"), + e.get("title"), e.get("still_url"), e.get("overview"), e.get("season_poster_url"), e.get("air_date"), library_id, server_source)) n += 1 conn.commit() @@ -1601,7 +1604,7 @@ class VideoDatabase: if s: where.append("title LIKE ? COLLATE NOCASE"); args.append("%" + s + "%") wsql = " WHERE " + " AND ".join(where) - order = {"title": "title COLLATE NOCASE", + order = {"title": "title COLLATE NOCASE", "oldest": "date_added ASC, id ASC", "added": "date_added DESC, id DESC"}.get(sort, "date_added DESC, id DESC") total = conn.execute("SELECT COUNT(*) c FROM video_wishlist" + wsql, args).fetchone()["c"] rows = conn.execute( @@ -1619,7 +1622,7 @@ class VideoDatabase: total = conn.execute( "SELECT COUNT(DISTINCT tmdb_id) c FROM video_wishlist" + wsql, args).fetchone()["c"] order = {"title": "title COLLATE NOCASE", "wanted": "wanted DESC, last_added DESC", - "added": "last_added DESC"}.get(sort, "last_added DESC") + "oldest": "last_added ASC", "added": "last_added DESC"}.get(sort, "last_added DESC") show_rows = conn.execute( "SELECT tmdb_id, MAX(title) AS title, MAX(poster_url) AS poster_url, " "MAX(library_id) AS library_id, COUNT(*) AS wanted, " @@ -1632,7 +1635,7 @@ class VideoDatabase: for sr in show_rows: eps = conn.execute( "SELECT season_number, episode_number, episode_title, still_url, " - "season_poster_url, air_date, status " + "episode_overview, season_poster_url, air_date, status " "FROM video_wishlist WHERE kind='episode' AND tmdb_id=? " "ORDER BY season_number, episode_number", (sr["tmdb_id"],)).fetchall() by_season: dict = {} @@ -1640,7 +1643,8 @@ class VideoDatabase: for e in eps: by_season.setdefault(e["season_number"], []).append({ "episode_number": e["episode_number"], "title": e["episode_title"], - "still_url": e["still_url"], "air_date": e["air_date"], "status": e["status"]}) + "still_url": e["still_url"], "overview": e["episode_overview"], + "air_date": e["air_date"], "status": e["status"]}) if e["season_poster_url"] and e["season_number"] not in season_poster: season_poster[e["season_number"]] = e["season_poster_url"] seasons = [{"season_number": sn, "poster_url": season_poster.get(sn), @@ -1686,7 +1690,8 @@ class VideoDatabase: "SELECT DISTINCT tmdb_id, season_number FROM video_wishlist " "WHERE kind='episode' AND tmdb_id IS NOT NULL AND season_number IS NOT NULL " "AND ((still_url IS NULL OR still_url='') OR " - " (season_poster_url IS NULL OR season_poster_url=''))").fetchall() + " (season_poster_url IS NULL OR season_poster_url='') OR " + " (episode_overview IS NULL OR episode_overview=''))").fetchall() return [{"tmdb_id": r["tmdb_id"], "season_number": r["season_number"]} for r in rows] finally: conn.close() @@ -1706,6 +1711,21 @@ class VideoDatabase: finally: conn.close() + def set_wishlist_episode_overview(self, show_tmdb_id, season_number, episode_number, overview) -> bool: + """Fill a single episode's synopsis (only if it doesn't already have one).""" + if not overview: + return False + conn = self._get_connection() + try: + cur = conn.execute( + "UPDATE video_wishlist SET episode_overview=? WHERE kind='episode' AND tmdb_id=? " + "AND season_number=? AND episode_number=? AND (episode_overview IS NULL OR episode_overview='')", + (overview, int(show_tmdb_id), int(season_number), int(episode_number))) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + def set_wishlist_season_poster(self, show_tmdb_id, season_number, poster_url) -> int: """Fill the season poster on every episode row of a season that lacks one.""" if not poster_url: diff --git a/database/video_schema.sql b/database/video_schema.sql index 4deaeb6b..402d1433 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -368,6 +368,7 @@ CREATE TABLE IF NOT EXISTS video_wishlist ( episode_number INTEGER, -- episode rows episode_title TEXT, -- episode rows still_url TEXT, -- episode still thumbnail (episode rows) + episode_overview TEXT, -- episode synopsis (episode rows) season_poster_url TEXT, -- the episode's SEASON poster (episode rows) air_date TEXT, -- episode rows (already aired by the time it's here) status TEXT NOT NULL DEFAULT 'wanted', -- wanted|searching|downloading|downloaded|failed diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 8c25b0f3..ffd9feaf 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -935,9 +935,18 @@ def test_wishlist_keys_for_shows(db): def test_wishlist_query_sort(db): db.add_episodes_to_wishlist(1, "Alpha", [{"season_number": 1, "episode_number": 1}]) # 1 ep db.add_episodes_to_wishlist(2, "Zeta", [{"season_number": 1, "episode_number": i} for i in range(5)]) # 5 eps + # CURRENT_TIMESTAMP collides within a second — pin distinct add-times so the + # FIFO vs newest ordering is deterministic. + with db.connect() as c: + c.execute("UPDATE video_wishlist SET date_added='2024-01-01 00:00:00' WHERE tmdb_id=1") + c.execute("UPDATE video_wishlist SET date_added='2024-01-02 00:00:00' WHERE tmdb_id=2") + c.commit() # most-wanted first w = [s["title"] for s in db.query_wishlist("show", sort="wanted")["items"]] assert w[0] == "Zeta" + # FIFO (oldest first) vs recently-added (newest first) + assert [s["title"] for s in db.query_wishlist("show", sort="oldest")["items"]] == ["Alpha", "Zeta"] + assert [s["title"] for s in db.query_wishlist("show", sort="added")["items"]] == ["Zeta", "Alpha"] # A–Z az = [s["title"] for s in db.query_wishlist("show", sort="title")["items"]] assert az == ["Alpha", "Zeta"] @@ -968,3 +977,15 @@ def test_wishlist_art_backfill_targets_and_set(db): assert db.set_wishlist_season_poster(1396, 2, "/s2.jpg") == 1 # fills the season's episodes by_season = {s["season_number"]: s for s in db.query_wishlist("show")["items"][0]["seasons"]} assert by_season[1]["poster_url"] == "/s1.jpg" and by_season[2]["poster_url"] == "/s2.jpg" + + +def test_wishlist_episode_overview_roundtrips_and_backfills(db): + db.add_episodes_to_wishlist(1396, "BB", [ + {"season_number": 1, "episode_number": 1, "overview": "The one where it begins."}, + {"season_number": 1, "episode_number": 2}]) + eps = {e["episode_number"]: e for e in db.query_wishlist("show")["items"][0]["seasons"][0]["episodes"]} + assert eps[1]["overview"] == "The one where it begins." and eps[2]["overview"] is None + # episode missing an overview shows up as a backfill target + assert (1396, 1) in {(t["tmdb_id"], t["season_number"]) for t in db.wishlist_art_backfill_targets()} + assert db.set_wishlist_episode_overview(1396, 1, 2, "Filled in.") is True + assert db.set_wishlist_episode_overview(1396, 1, 1, "nope") is False # won't clobber diff --git a/webui/index.html b/webui/index.html index 56e5c3f5..d34431f1 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1073,6 +1073,7 @@ diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js index a1b4c92c..4fe7285f 100644 --- a/webui/static/video/video-get-modal.js +++ b/webui/static/video/video-get-modal.js @@ -95,7 +95,7 @@ modalState.sel.forEach(function (key) { var p = key.split('_'), m = (modalState.epMeta || {})[key] || {}; eps.push({ season_number: parseInt(p[0], 10), episode_number: parseInt(p[1], 10), - title: m.title, air_date: m.air_date, still_url: m.still, + title: m.title, air_date: m.air_date, still_url: m.still, overview: m.overview, season_poster_url: (modalState.seasonPoster || {})[p[0]] }); }); if (!eps.length) { if (btn) btn.disabled = false; toast('Select at least one episode', 'info'); return; } @@ -343,7 +343,7 @@ var missing = 0; eps.forEach(function (e) { modalState.epMeta[s.season_number + '_' + e.episode_number] = { title: e.title, air_date: e.air_date, - still: e.has_still ? ('/api/video/poster/episode/' + e.id) : null }; + overview: e.overview, still: e.has_still ? ('/api/video/poster/episode/' + e.id) : null }; if (epState(e, today) === 'missing') { missing++; modalState.sel.add(s.season_number + '_' + e.episode_number); } }); totalMissing += missing; @@ -406,7 +406,7 @@ modalState.epMeta = modalState.epMeta || {}; eps.forEach(function (e) { modalState.epMeta[sn + '_' + e.episode_number] = { title: e.title, air_date: e.air_date, - still: e.still_url || null }; + overview: e.overview, still: e.still_url || null }; if (epState(e, today) === 'missing') modalState.sel.add(sn + '_' + e.episode_number); }); var all = seasonEl.querySelector('[data-vgm-season-all]'); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index e743f79a..a6872565 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2556,13 +2556,13 @@ body[data-side="video"] #soulsync-toggle { display: none; } /* #1/#2 — the picked season drops its episodes here, full-width, as 2-line cards */ .vwsh-nebula .vwsh-ep-area:empty { display: none; } .vwsh-nebula .vwsh-ep-area { margin-top: 14px; } -.vwsh-nebula .vwsh-ep-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; - max-height: 360px; overflow-y: auto; padding: 2px; scrollbar-width: thin; } -.vwsh-nebula .vwsh-epc { position: relative; display: flex; align-items: center; gap: 11px; padding: 8px; +.vwsh-nebula .vwsh-ep-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px; + max-height: 420px; overflow-y: auto; padding: 2px; scrollbar-width: thin; } +.vwsh-nebula .vwsh-epc { position: relative; display: flex; align-items: flex-start; gap: 11px; padding: 9px; cursor: pointer; border-radius: 10px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); transition: background 0.15s ease, border-color 0.15s ease; } -.vwsh-nebula .vwsh-epc:hover { background: rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.12); } -.vwsh-nebula .vwsh-epc-thumb { flex-shrink: 0; width: 84px; height: 47px; border-radius: 6px; overflow: hidden; +.vwsh-nebula .vwsh-epc:hover { background: rgba(255, 255, 255, 0.07); border-color: hsla(var(--orb-hue, 230), 60%, 55%, 0.35); } +.vwsh-nebula .vwsh-epc-thumb { flex-shrink: 0; width: 92px; height: 52px; border-radius: 6px; overflow: hidden; background: #16161d; border: 1px solid rgba(255, 255, 255, 0.08); } .vwsh-nebula .vwsh-epc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } .vwsh-nebula .vwsh-epc-thumb--none { background: repeating-linear-gradient(135deg, @@ -2571,7 +2571,10 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vwsh-nebula .vwsh-epc-body { flex: 1; min-width: 0; } .vwsh-nebula .vwsh-epc-title { font-size: 12.5px; font-weight: 700; color: #fff; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.vwsh-nebula .vwsh-epc:hover .vwsh-epc-title { color: hsl(var(--orb-hue, 230) 80% 80%); } .vwsh-nebula .vwsh-epc-meta { margin-top: 3px; font-size: 11px; font-weight: 600; color: rgba(255, 255, 255, 0.42); } +.vwsh-nebula .vwsh-epc-ov { margin-top: 5px; font-size: 11px; line-height: 1.45; color: rgba(255, 255, 255, 0.5); + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .vwsh-nebula .vwsh-epc-rm { flex-shrink: 0; width: 22px; height: 22px; border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.35); color: rgba(255, 255, 255, 0.45); font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: all 0.15s ease; } diff --git a/webui/static/video/video-wishlist.js b/webui/static/video/video-wishlist.js index 3e9abf46..b10b94df 100644 --- a/webui/static/video/video-wishlist.js +++ b/webui/static/video/video-wishlist.js @@ -114,8 +114,11 @@ ''; } - // A single episode as a 2-line card (still + title that can wrap + meta line). - function epCard(tmdb, se, e) { + // A single episode as a rich card: still + title + meta + synopsis. The card + // links to the show detail (episodes have no page of their own). + function epCard(sh, se, e) { + var src = sh.library_id != null ? 'library' : 'tmdb'; + var openId = sh.library_id != null ? sh.library_id : sh.tmdb_id; var t = e.title || ('Episode ' + e.episode_number); var st = STATUS[e.status] ? e.status : 'wanted'; var date = fmtDate(e.air_date); @@ -123,14 +126,15 @@ ? '' : ''; - return '
' + thumb + + return '
' + thumb + '
' + '
' + esc(t) + '
' + '
' + 'S' + se.season_number + '·E' + e.episode_number + (date ? ' · ' + esc(date) : '') + '
' + + (e.overview ? '
' + esc(e.overview) + '
' : '') + '
' + '' + + 'data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '" title="Remove">✕' + '
'; } function renderEpisodeArea(group, tmdb, seasonNum) { @@ -139,7 +143,7 @@ if (sh) (sh.seasons || []).forEach(function (x) { if (x.season_number === seasonNum) se = x; }); if (!se) { area.innerHTML = ''; return; } area.innerHTML = '
' + - (se.episodes || []).map(function (e) { return epCard(tmdb, se, e); }).join('') + '
'; + (se.episodes || []).map(function (e) { return epCard(sh, se, e); }).join('') + '
'; } function render(items) { @@ -231,7 +235,7 @@ if (state.tab !== 'show' || artBackfilled) return; var missing = (items || []).some(function (sh) { return (sh.seasons || []).some(function (se) { - return !se.poster_url || (se.episodes || []).some(function (e) { return !e.still_url; }); + return !se.poster_url || (se.episodes || []).some(function (e) { return !e.still_url || !e.overview; }); }); }); if (!missing) return;