Wishlist TV: #4 progress bar + episode stills + square video bubbles

#4 acquisition progress: a thin done÷wanted bar across each bubble's bottom
(forward-prep — fills once the download engine lands).

Expanded-view richness: episodes now carry a still thumbnail. New video_wishlist
.still_url column (SCHEMA_VERSION 10 + migration); the get-modal captures the
still per episode (owned -> /poster/episode proxy, tmdb -> direct still_url) and
sends it through add; query_wishlist returns it; the episode row renders a 16:9
thumb (film-frame placeholder when absent) in a roomier expanded tile.

Subtle video identity: the bubbles are now rounded-SQUARES (the music orbs stay
circles). Every rule stays scoped under .vwsh-nebula — verified no bare .wl-*
rules in the video CSS, so the music wishlist is untouched.

Tests: +1 (still roundtrip). Backend 102 passed.
This commit is contained in:
BoulderBadgeDad 2026-06-16 17:27:16 -07:00
parent 599f36fdf3
commit f7bd15d019
6 changed files with 53 additions and 12 deletions

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 = 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"],

View file

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

View file

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

View file

@ -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]');

View file

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

View file

@ -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 '<div class="wl-tile-track">' +
// richer episode row: still thumbnail + status dot + air date
var thumb = e.still_url
? '<span class="vwsh-ep-thumb"><img src="' + esc(e.still_url) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.classList.add(\'vwsh-ep-thumb--none\')"></span>'
: '<span class="vwsh-ep-thumb vwsh-ep-thumb--none"></span>';
return '<div class="wl-tile-track">' + thumb +
'<span class="vwsh-ep-dot vwsh-ep-dot--' + st + '" title="' + STATUS[st][0] + '"></span>' +
'<span class="wl-tile-track-name">E' + e.episode_number + ' · ' + esc(t) + '</span>' +
(date ? '<span class="vwsh-ep-date">' + esc(date) + '</span>' : '') +
@ -111,11 +115,13 @@
// backdrop (--vwsh-poster) both resolve; poster bleeds in only when expanded.
var gstyle = 'animation-delay:' + Math.min(idx * 45, 700) + 'ms;--orb-hue:' + hue +
(sh.poster_url ? ";--vwsh-poster:url('" + esc(sh.poster_url) + "')" : '');
var prog = total ? Math.max(0, Math.min(1, (sh.done || 0) / total)) : 0; // #4 acquisition progress
return '<div class="wl-orb-group" data-vwsh-group style="' + gstyle + '">' +
'<button class="wl-orb-remove" type="button" data-vwsh-rm="show" data-tmdb="' + esc(sh.tmdb_id) + '" title="Remove show">&#10005;</button>' +
'<div class="wl-orb-tooltip">' + esc(sh.title) + '<br><span>' + eps + '</span></div>' +
'<div class="wl-orb ' + orbSize(total) + '" data-vwsh-orb>' +
'<div class="wl-orb ' + orbSize(total) + '" data-vwsh-orb style="--vwsh-prog:' + prog + '">' +
'<div class="wl-orb-glow"></div>' + img + '<div class="wl-orb-ring"></div>' +
'<div class="vwsh-prog"></div>' +
'</div>' +
'<div class="wl-orb-label" data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '" title="' + esc(sh.title) + '">' + esc(sh.title) + '</div>' +
'<div class="wl-orb-meta">' + eps + (sh.done ? ' · ' + sh.done + ' done' : '') + '</div>' +