diff --git a/database/video_database.py b/database/video_database.py index 4708e22d..6aba170c 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 = 9 +SCHEMA_VERSION = 10 _DEFAULT_DB_PATH = "database/video_library.db" _SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql" @@ -99,6 +99,7 @@ _COLUMN_MIGRATIONS = [ ("shows", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"), ("shows", "airs_time", "TEXT"), # TVDB show air time, e.g. "21:00" (network local) ("video_watchlist", "state", "TEXT NOT NULL DEFAULT 'follow'"), # follow | mute (tombstone) + ("video_wishlist", "still_url", "TEXT"), # episode still thumbnail (captured at add time) ] @@ -1517,16 +1518,17 @@ class VideoDatabase: conn.execute( """INSERT INTO video_wishlist (kind, tmdb_id, title, poster_url, season_number, episode_number, - episode_title, air_date, library_id, server_source) - VALUES ('episode', ?, ?, ?, ?, ?, ?, ?, ?, ?) + episode_title, still_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), 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("air_date"), library_id, server_source)) + e.get("title"), e.get("still_url"), e.get("air_date"), library_id, server_source)) n += 1 conn.commit() return n @@ -1626,14 +1628,14 @@ class VideoDatabase: items = [] for sr in show_rows: eps = conn.execute( - "SELECT season_number, episode_number, episode_title, air_date, status " + "SELECT season_number, episode_number, episode_title, still_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 = {} for e in eps: by_season.setdefault(e["season_number"], []).append({ "episode_number": e["episode_number"], "title": e["episode_title"], - "air_date": e["air_date"], "status": e["status"]}) + "still_url": e["still_url"], "air_date": e["air_date"], "status": e["status"]}) seasons = [{"season_number": sn, "episodes": by_season[sn]} for sn in sorted(by_season)] items.append({"kind": "show", "tmdb_id": sr["tmdb_id"], "title": sr["title"], diff --git a/database/video_schema.sql b/database/video_schema.sql index c47b125e..2ca66a25 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -367,6 +367,7 @@ CREATE TABLE IF NOT EXISTS video_wishlist ( season_number INTEGER, -- episode rows episode_number INTEGER, -- episode rows episode_title TEXT, -- episode rows + still_url TEXT, -- episode still thumbnail (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 library_id INTEGER, -- owned movies.id/shows.id when re-downloading diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 8fd7a8e8..88db6a55 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -945,3 +945,12 @@ def test_wishlist_query_sort(db): db.add_movie_to_wishlist(10, "Banana"); db.add_movie_to_wishlist(11, "Apple") m = [x["title"] for x in db.query_wishlist("movie", sort="title")["items"]] assert m == ["Apple", "Banana"] + + +def test_wishlist_episode_still_roundtrips(db): + db.add_episodes_to_wishlist(1396, "BB", [ + {"season_number": 1, "episode_number": 1, "title": "Pilot", "still_url": "https://img/e1.jpg"}, + {"season_number": 1, "episode_number": 2}]) # no still + eps = db.query_wishlist("show")["items"][0]["seasons"][0]["episodes"] + by = {e["episode_number"]: e for e in eps} + assert by[1]["still_url"] == "https://img/e1.jpg" and by[2]["still_url"] is None diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js index 8957d8ce..24dc2a28 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 }); + title: m.title, air_date: m.air_date, still_url: m.still }); }); if (!eps.length) { if (btn) btn.disabled = false; toast('Select at least one episode', 'info'); return; } @@ -337,7 +337,8 @@ } var missing = 0; eps.forEach(function (e) { - modalState.epMeta[s.season_number + '_' + e.episode_number] = { title: e.title, air_date: e.air_date }; + 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 }; if (epState(e, today) === 'missing') { missing++; modalState.sel.add(s.season_number + '_' + e.episode_number); } }); totalMissing += missing; @@ -399,7 +400,8 @@ if (b) b.innerHTML = eps.map(function (e) { return epRow(sn, e, today); }).join(''); modalState.epMeta = modalState.epMeta || {}; eps.forEach(function (e) { - modalState.epMeta[sn + '_' + e.episode_number] = { title: e.title, air_date: e.air_date }; + modalState.epMeta[sn + '_' + e.episode_number] = { title: e.title, air_date: e.air_date, + 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 fbe10994..417cb977 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2530,6 +2530,27 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vwsh-sort:focus { border-color: rgba(var(--accent-rgb, 29, 185, 84), 0.5); } .vwsh-sort option { background: #16161d; color: #fff; } +/* video-only: square-ish bubbles (the music orbs are circles) */ +.vwsh-nebula .wl-orb, .vwsh-nebula .wl-orb-glow, .vwsh-nebula .wl-orb-img, +.vwsh-nebula .wl-orb-initials, .vwsh-nebula .wl-orb-ring { border-radius: 24%; } + +/* #4 acquisition progress — a thin bar across the bubble bottom (done ÷ wanted) */ +.vwsh-nebula .vwsh-prog { position: absolute; left: 9%; right: 9%; bottom: 6px; height: 4px; z-index: 3; + border-radius: 3px; pointer-events: none; + background: linear-gradient(90deg, rgb(var(--accent-rgb, 29, 185, 84)) calc(var(--vwsh-prog, 0) * 100%), + rgba(255, 255, 255, 0.16) calc(var(--vwsh-prog, 0) * 100%)); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); } + +/* richer episode rows: still thumbnail + roomier expanded tile */ +.vwsh-nebula .wl-album-tile.tile-expanded { width: 320px; } +.vwsh-nebula .wl-tile-track { gap: 8px; padding: 5px 8px; } +.vwsh-nebula .vwsh-ep-thumb { flex-shrink: 0; width: 52px; height: 30px; border-radius: 4px; overflow: hidden; + background: #16161d; border: 1px solid rgba(255, 255, 255, 0.08); } +.vwsh-nebula .vwsh-ep-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.vwsh-nebula .vwsh-ep-thumb--none { background: repeating-linear-gradient(135deg, + rgba(255, 255, 255, 0.04) 0 6px, rgba(255, 255, 255, 0.07) 6px 12px); } +.vwsh-nebula .vwsh-ep-thumb--none img { display: none; } + /* status pills + remove button */ .vwsh-st { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.03em; padding: 3px 8px; border-radius: 6px; flex-shrink: 0; } .vwsh-st--wanted { background: rgba(var(--accent-rgb, 29, 185, 84), 0.16); color: rgb(var(--accent-rgb, 29, 185, 84)); } diff --git a/webui/static/video/video-wishlist.js b/webui/static/video/video-wishlist.js index 791319cf..1b96156d 100644 --- a/webui/static/video/video-wishlist.js +++ b/webui/static/video/video-wishlist.js @@ -86,8 +86,12 @@ var t = e.title || ('Episode ' + e.episode_number); var st = STATUS[e.status] ? e.status : 'wanted'; var date = fmtDate(e.air_date); - // #3: status dot + air date make the episode line actually informative - return '