Wishlist: episode synopsis + clickable cards + FIFO/newest sort
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.
This commit is contained in:
parent
4d62df5c33
commit
e8a0e1256b
8 changed files with 80 additions and 27 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1073,6 +1073,7 @@
|
|||
</div>
|
||||
<select class="vwsh-sort" data-vwsh-sort aria-label="Sort">
|
||||
<option value="added">Recently added</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
<option value="wanted">Most wanted</option>
|
||||
<option value="title">A–Z</option>
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -114,8 +114,11 @@
|
|||
'</div>';
|
||||
}
|
||||
|
||||
// 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 @@
|
|||
? '<span class="vwsh-epc-thumb"><img src="' + esc(e.still_url) + '" alt="" loading="lazy" ' +
|
||||
'onerror="this.parentNode.classList.add(\'vwsh-epc-thumb--none\')"></span>'
|
||||
: '<span class="vwsh-epc-thumb vwsh-epc-thumb--none"></span>';
|
||||
return '<div class="vwsh-epc">' + thumb +
|
||||
return '<div class="vwsh-epc" data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '">' + thumb +
|
||||
'<div class="vwsh-epc-body">' +
|
||||
'<div class="vwsh-epc-title" title="' + esc(t) + '">' + esc(t) + '</div>' +
|
||||
'<div class="vwsh-epc-meta"><span class="vwsh-ep-dot vwsh-ep-dot--' + st + '"></span>' +
|
||||
'S' + se.season_number + '·E' + e.episode_number + (date ? ' · ' + esc(date) : '') + '</div>' +
|
||||
(e.overview ? '<div class="vwsh-epc-ov">' + esc(e.overview) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<button class="vwsh-epc-rm" type="button" data-vwsh-rm="episode" ' +
|
||||
'data-tmdb="' + esc(tmdb) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '" title="Remove">✕</button>' +
|
||||
'data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '" title="Remove">✕</button>' +
|
||||
'</div>';
|
||||
}
|
||||
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 = '<div class="vwsh-ep-grid">' +
|
||||
(se.episodes || []).map(function (e) { return epCard(tmdb, se, e); }).join('') + '</div>';
|
||||
(se.episodes || []).map(function (e) { return epCard(sh, se, e); }).join('') + '</div>';
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue