Video watchlist page: server-paged + search bar (like the library)

The page rendered every follow + airing-default show at once (DOM + all posters)
— slow once the watchlist grows. Now it pages like the library:

- /api/video/watchlist?kind=&search=&page=&limit= returns {items, pagination,
  counts}; query_watchlist() filters by title + slices (effective list is
  bounded, so compute-then-slice, not heavier UNION SQL).
- Page reworked to a single grid: Shows/People tabs each load their own page;
  debounced search box; Prev/Next pager; tab badges show totals from counts.
- Only a page of cards (and lazy posters) render at a time.

4 tests added (DB paginate/search + endpoint). 82 video tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-16 09:40:41 -07:00
parent b344303f75
commit 4287385af6
7 changed files with 194 additions and 76 deletions

View file

@ -38,15 +38,19 @@ def register_routes(bp):
db = get_video_db() db = get_video_db()
server = _server() server = _server()
kind = request.args.get("kind") kind = request.args.get("kind")
counts = db.watchlist_counts(server_source=server)
if kind in _KINDS: if kind in _KINDS:
items = db.list_watchlist(kind, server_source=server) # Paged + searchable, like the library page.
return jsonify({"success": True, "kind": kind, "items": items}) res = db.query_watchlist(
kind, search=request.args.get("search", ""),
page=request.args.get("page", 1), limit=request.args.get("limit", 60),
server_source=server)
return jsonify({"success": True, "kind": kind, "counts": counts, **res})
# No kind → grouped (counts + first-glance lists).
rows = db.list_watchlist(server_source=server) rows = db.list_watchlist(server_source=server)
shows = [r for r in rows if r.get("kind") == "show"] shows = [r for r in rows if r.get("kind") == "show"]
people = [r for r in rows if r.get("kind") == "person"] people = [r for r in rows if r.get("kind") == "person"]
return jsonify({"success": True, "shows": shows, "people": people, return jsonify({"success": True, "shows": shows, "people": people, "counts": counts})
"counts": {"show": len(shows), "person": len(people),
"total": len(rows)}})
except Exception: except Exception:
logger.exception("Failed to list video watchlist") logger.exception("Failed to list video watchlist")
return jsonify({"success": False, "error": "Failed to load watchlist"}), 500 return jsonify({"success": False, "error": "Failed to load watchlist"}), 500

View file

@ -1351,6 +1351,29 @@ class VideoDatabase:
people = self.list_watchlist("person") people = self.list_watchlist("person")
return {"show": len(shows), "person": len(people), "total": len(shows) + len(people)} return {"show": len(shows), "person": len(people), "total": len(shows) + len(people)}
def query_watchlist(self, kind: str, *, search=None, page=1, limit=60,
server_source=None) -> dict:
"""One searched/paged slice of the effective watchlist for a kind — mirrors
query_library's {items, pagination} shape so the page can paginate like
the library. The effective list is bounded (follows + airing library
shows), so it's computed then filtered/sliced rather than via heavier SQL."""
try:
page = max(1, int(page or 1))
limit = max(1, min(200, int(limit or 60)))
except (TypeError, ValueError):
page, limit = 1, 60
items = self.list_watchlist(kind, server_source=server_source) if kind in ("show", "person") else []
s = (search or "").strip().lower()
if s:
items = [it for it in items if s in (it.get("title") or "").lower()]
total = len(items)
total_pages = max(1, (total + limit - 1) // limit)
page = min(page, total_pages)
start = (page - 1) * limit
return {"items": items[start:start + limit], "pagination": {
"page": page, "total_pages": total_pages, "total_count": total,
"has_prev": page > 1, "has_next": page < total_pages}}
def movie_detail(self, movie_id: int) -> dict | None: def movie_detail(self, movie_id: int) -> dict | None:
"""Full movie detail: the movie + owned/file info. Drives the (isolated) """Full movie detail: the movie + owned/file info. Drives the (isolated)
video movie-detail page.""" video movie-detail page."""

View file

@ -351,3 +351,15 @@ def test_watchlist_add_validates_input(tmp_path):
assert client.post("/api/video/watchlist/add", json={"kind": "show", "tmdb_id": 1}).status_code == 400 # no title assert client.post("/api/video/watchlist/add", json={"kind": "show", "tmdb_id": 1}).status_code == 400 # no title
assert client.post("/api/video/watchlist/remove", json={"kind": "person"}).status_code == 400 assert client.post("/api/video/watchlist/remove", json={"kind": "person"}).status_code == 400
assert client.post("/api/video/watchlist/check", json={"tmdb_ids": [1]}).status_code == 400 # no kind assert client.post("/api/video/watchlist/check", json={"tmdb_ids": [1]}).status_code == 400 # no kind
def test_watchlist_endpoint_paginates_and_searches(tmp_path):
client, _ = _make_client(tmp_path)
for i in range(1, 6):
client.post("/api/video/watchlist/add", json={"kind": "person", "tmdb_id": 300 + i, "title": "P%d" % i})
d = client.get("/api/video/watchlist?kind=person&page=1&limit=2").get_json()
assert d["success"] and len(d["items"]) == 2
assert d["pagination"]["total_count"] == 5 and d["pagination"]["total_pages"] == 3
assert d["counts"]["person"] == 5
s = client.get("/api/video/watchlist?kind=person&search=P3").get_json()
assert len(s["items"]) == 1 and s["items"][0]["title"] == "P3"

View file

@ -827,3 +827,19 @@ def test_watchlist_state_and_counts(db):
assert db.watchlist_state("show", []) == {} assert db.watchlist_state("show", []) == {}
assert db.watchlist_state("person", [287]) == {287: True} assert db.watchlist_state("person", [287]) == {287: True}
assert db.watchlist_counts() == {"show": 2, "person": 1, "total": 3} assert db.watchlist_counts() == {"show": 2, "person": 1, "total": 3}
def test_query_watchlist_paginates_and_searches(db):
for i in range(1, 8):
db.add_to_watchlist("person", 100 + i, "Person %d" % i)
db.add_to_watchlist("person", 200, "Brad Pitt")
res = db.query_watchlist("person", page=1, limit=3)
assert len(res["items"]) == 3
assert res["pagination"]["total_count"] == 8 and res["pagination"]["total_pages"] == 3
assert res["pagination"]["has_next"] is True and res["pagination"]["has_prev"] is False
# search (case-insensitive title contains)
res2 = db.query_watchlist("person", search="brad", limit=60)
assert len(res2["items"]) == 1 and res2["items"][0]["title"] == "Brad Pitt"
assert res2["pagination"]["total_count"] == 1
# page beyond range clamps to the last page
assert db.query_watchlist("person", page=99, limit=3)["pagination"]["page"] == 3

View file

@ -927,18 +927,28 @@
</div> </div>
</div> </div>
</div> </div>
<div class="vwlp-toolbar">
<div class="vwlp-search">
<svg class="vwlp-search-ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" class="vwlp-search-input" data-vwlp-search placeholder="Search watchlist&hellip;" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="vwlp-body"> <div class="vwlp-body">
<div class="vwlp-loading hidden" data-vwlp-loading> <div class="vwlp-loading hidden" data-vwlp-loading>
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<div class="loading-text">Loading watchlist&hellip;</div> <div class="loading-text">Loading watchlist&hellip;</div>
</div> </div>
<div class="vwlp-grid" data-vwlp-grid="show"></div> <div class="vwlp-grid" data-vwlp-grid></div>
<div class="vwlp-grid hidden" data-vwlp-grid="person"></div>
<div class="vwlp-state hidden" data-vwlp-empty> <div class="vwlp-state hidden" data-vwlp-empty>
<div class="vwlp-state-ic">&#128065;</div> <div class="vwlp-state-ic">&#128065;</div>
<div class="vwlp-state-title" data-vwlp-empty-title>Nothing on your watchlist yet</div> <div class="vwlp-state-title" data-vwlp-empty-title>Nothing on your watchlist yet</div>
<div class="vwlp-state-sub">Hover any show or person card and hit the eye to follow them — new content lands here.</div> <div class="vwlp-state-sub">Hover any show or person card and hit the eye to follow them — new content lands here.</div>
</div> </div>
<div class="vwlp-pagination hidden" data-vwlp-pagination>
<button class="vwlp-page-btn" type="button" data-vwlp-prev>&lsaquo; Prev</button>
<span class="vwlp-page-info" data-vwlp-pageinfo></span>
<button class="vwlp-page-btn" type="button" data-vwlp-next>Next &rsaquo;</button>
</div>
</div> </div>
</div> </div>
</section> </section>

View file

@ -1865,3 +1865,26 @@ body[data-side="video"] #soulsync-toggle { display: none; }
/* Ensure the show poster box is a positioning context for its .vwl-btn /* Ensure the show poster box is a positioning context for its .vwl-btn
(scoped to VIDEO cards so the shared music .library-artist-image is untouched). */ (scoped to VIDEO cards so the shared music .library-artist-image is untouched). */
.video-card--clickable .library-artist-image { position: relative; } .video-card--clickable .library-artist-image { position: relative; }
/* ── Watchlist page — search toolbar + pagination ─────────────────────────── */
.vwlp-toolbar { padding: 18px 40px 0; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.vwlp-search { position: relative; flex: 1; max-width: 420px; min-width: 220px; }
.vwlp-search-ic { position: absolute; left: 13px; top: 50%; transform: translateY(-50%);
color: rgba(255, 255, 255, 0.4); pointer-events: none; }
.vwlp-search-input { width: 100%; padding: 11px 14px 11px 38px; border-radius: 12px;
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff; font-size: 14px; font-weight: 500; outline: none; transition: border-color 0.15s ease, background 0.15s ease; }
.vwlp-search-input::placeholder { color: rgba(255, 255, 255, 0.35); }
.vwlp-search-input:focus { border-color: rgba(var(--accent-rgb, 88 101 242), 0.6);
background: rgba(255, 255, 255, 0.07); }
.vwlp-pagination { display: flex; align-items: center; justify-content: center; gap: 16px; padding: 30px 0 4px; }
.vwlp-pagination.hidden { display: none; }
.vwlp-page-btn { padding: 9px 16px; border-radius: 10px; font-size: 13px; font-weight: 700;
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85); cursor: pointer; transition: all 0.15s ease; }
.vwlp-page-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.12); color: #fff; }
.vwlp-page-btn:disabled { opacity: 0.35; cursor: default; }
.vwlp-page-info { font-size: 13px; font-weight: 700; color: rgba(255, 255, 255, 0.6); min-width: 110px; text-align: center; }
@media (max-width: 900px) { .vwlp-toolbar { padding-left: 18px; padding-right: 18px; } }

View file

@ -1,16 +1,20 @@
/* /*
* SoulSync Video Watchlist page (isolated). * SoulSync Video Watchlist page (isolated).
* *
* The shows + people you follow, split by a Shows / People tab switcher. Reads * The shows + people you follow, split by a Shows / People tab switcher.
* /api/video/watchlist; cards reuse the shared VideoWatchlist eye-button (here it * Server-paged + searchable like the library (only a page of cards/posters
* reads as "watched" and un-follows on click). v1 is membership only the * renders at once). Reads /api/video/watchlist?kind=&search=&page=&limit=.
* monitoring/discovery engine that turns follows into downloads comes later. * Cards reuse the shared VideoWatchlist eye-button (reads as "watched" here;
* un-follows on click, with a confirm).
*/ */
(function () { (function () {
'use strict'; 'use strict';
var PAGE_ID = 'video-watchlist'; var PAGE_ID = 'video-watchlist';
var state = { loaded: false, tab: 'show', data: { show: [], person: [] } }; var LIMIT = 60;
var state = { loaded: false, tab: 'show', search: '', page: 1,
counts: { show: 0, person: 0 } };
var searchTimer = null;
function $(s, r) { return (r || document).querySelector(s); } function $(s, r) { return (r || document).querySelector(s); }
function esc(s) { function esc(s) {
@ -33,83 +37,119 @@
var btn = wlBtn({ kind: kind, tmdbId: it.tmdb_id, title: it.title, var btn = wlBtn({ kind: kind, tmdbId: it.tmdb_id, title: it.title,
poster: it.poster_url, libraryId: it.library_id }); poster: it.poster_url, libraryId: it.library_id });
return '<a class="vwlp-card' + (kind === 'person' ? ' vwlp-card--person' : '') + '" href="' + href + '" ' + return '<a class="vwlp-card' + (kind === 'person' ? ' vwlp-card--person' : '') + '" href="' + href + '" ' +
'data-vwlp-card="' + kind + '" data-vwlp-id="' + esc(it.tmdb_id) + '" ' +
'data-vwlp-open="' + kind + '" data-vwlp-source="' + source + '" data-vwlp-openid="' + esc(openId) + '">' + 'data-vwlp-open="' + kind + '" data-vwlp-source="' + source + '" data-vwlp-openid="' + esc(openId) + '">' +
'<div class="vwlp-card-art">' + art + '<div class="vwlp-card-scrim"></div>' + btn + '</div>' + '<div class="vwlp-card-art">' + art + '<div class="vwlp-card-scrim"></div>' + btn + '</div>' +
'<div class="vwlp-card-info"><span class="vwlp-card-title" title="' + esc(it.title) + '">' + '<div class="vwlp-card-info"><span class="vwlp-card-title" title="' + esc(it.title) + '">' +
esc(it.title) + '</span></div></a>'; esc(it.title) + '</span></div></a>';
} }
function updateEmpty() { function setCounts(counts) {
var n = state.data[state.tab].length; state.counts = { show: (counts && counts.show) || 0, person: (counts && counts.person) || 0 };
['show', 'person'].forEach(function (k) { var cs = $('[data-vwlp-count-show]'); if (cs) cs.textContent = state.counts.show;
var g = $('[data-vwlp-grid="' + k + '"]'); var cp = $('[data-vwlp-count-person]'); if (cp) cp.textContent = state.counts.person;
if (g) g.classList.toggle('hidden', k !== state.tab || state.data[k].length === 0); }
});
function updatePagination(p) {
var box = $('[data-vwlp-pagination]'), prev = $('[data-vwlp-prev]'),
next = $('[data-vwlp-next]'), info = $('[data-vwlp-pageinfo]');
if (!box) return;
if (!p || p.total_pages <= 1) { box.classList.add('hidden'); return; }
if (prev) prev.disabled = !p.has_prev;
if (next) next.disabled = !p.has_next;
if (info) info.textContent = 'Page ' + p.page + ' of ' + p.total_pages;
box.classList.remove('hidden');
}
function updateEmpty(total) {
var empty = $('[data-vwlp-empty]'); var empty = $('[data-vwlp-empty]');
if (empty) empty.classList.toggle('hidden', n > 0); if (empty) empty.classList.toggle('hidden', total > 0);
var et = $('[data-vwlp-empty-title]'); var et = $('[data-vwlp-empty-title]');
if (et && n === 0) et.textContent = state.tab === 'show' if (et && total === 0) {
? 'No shows on your watchlist yet' : 'No people on your watchlist yet'; et.textContent = state.search
} ? 'No matches'
: (state.tab === 'show' ? 'No shows on your watchlist yet' : 'No people on your watchlist yet');
function setTab(tab) { }
state.tab = tab; }
var tabs = document.querySelectorAll('[data-vwlp-tab]');
for (var i = 0; i < tabs.length; i++) function render(items) {
tabs[i].classList.toggle('vwlp-tab--on', tabs[i].getAttribute('data-vwlp-tab') === tab); // Everything on this page is watched — seed the shared cache so the eyes
updateEmpty(); // paint "watched" with no flash.
} if (window.VideoWatchlist) {
items.forEach(function (it) { VideoWatchlist._watched[state.tab][it.tmdb_id] = true; });
function render() { }
// Seed the shared cache so every button paints "watched" with no flash var grid = $('[data-vwlp-grid]');
// (everything on this page is, by definition, followed). if (grid) {
if (window.VideoWatchlist) { grid.innerHTML = items.map(function (it) { return cardHTML(it, state.tab); }).join('');
state.data.show.forEach(function (it) { VideoWatchlist._watched.show[it.tmdb_id] = true; }); if (window.VideoWatchlist) VideoWatchlist.hydrate(grid);
state.data.person.forEach(function (it) { VideoWatchlist._watched.person[it.tmdb_id] = true; });
} }
var sg = $('[data-vwlp-grid="show"]'), pg = $('[data-vwlp-grid="person"]');
if (sg) sg.innerHTML = state.data.show.map(function (it) { return cardHTML(it, 'show'); }).join('');
if (pg) pg.innerHTML = state.data.person.map(function (it) { return cardHTML(it, 'person'); }).join('');
var cs = $('[data-vwlp-count-show]'); if (cs) cs.textContent = state.data.show.length;
var cp = $('[data-vwlp-count-person]'); if (cp) cp.textContent = state.data.person.length;
setTab(state.tab);
} }
function load() { function load() {
state.loaded = true; state.loaded = true;
var ld = $('[data-vwlp-loading]'); if (ld) ld.classList.remove('hidden'); var ld = $('[data-vwlp-loading]'); if (ld) ld.classList.remove('hidden');
fetch('/api/video/watchlist', { headers: { Accept: 'application/json' } }) var params = new URLSearchParams({
kind: state.tab, search: state.search, page: state.page, limit: LIMIT });
fetch('/api/video/watchlist?' + params.toString(), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; }) .then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { .then(function (d) {
if (ld) ld.classList.add('hidden'); if (ld) ld.classList.add('hidden');
state.data = (d && d.success) if (!d || !d.success) { render([]); updatePagination(null); updateEmpty(0); return; }
? { show: d.shows || [], person: d.people || [] } setCounts(d.counts);
: { show: [], person: [] }; var p = d.pagination || { page: 1, total_pages: 1, total_count: (d.items || []).length };
render(); state.page = p.page;
render(d.items || []);
updatePagination(p);
updateEmpty(p.total_count);
}) })
.catch(function () { if (ld) ld.classList.add('hidden'); state.data = { show: [], person: [] }; render(); }); .catch(function () { if (ld) ld.classList.add('hidden'); render([]); updatePagination(null); updateEmpty(0); });
} }
// When an item is un-followed (here or anywhere), drop its card + fix counts. function setTab(tab) {
function onChanged(e) { if (tab !== 'show' && tab !== 'person') return;
var det = (e && e.detail) || {}; state.tab = tab; state.page = 1;
if (det.watched) return; // additions are picked up on next page load var tabs = document.querySelectorAll('[data-vwlp-tab]');
var kind = det.kind, id = String(det.id); for (var i = 0; i < tabs.length; i++)
if (!state.data[kind]) return; tabs[i].classList.toggle('vwlp-tab--on', tabs[i].getAttribute('data-vwlp-tab') === tab);
state.data[kind] = state.data[kind].filter(function (it) { return String(it.tmdb_id) !== id; }); load();
var card = document.querySelector('.vwlp-card[data-vwlp-card="' + kind + '"][data-vwlp-id="' + id + '"]'); }
if (card && card.parentNode) card.parentNode.removeChild(card);
var c = $('[data-vwlp-count-' + kind + ']'); if (c) c.textContent = state.data[kind].length; // A removal anywhere → if we're showing the watchlist, reload the page so the
updateEmpty(); // un-followed card drops and counts/pagination stay correct.
function onChanged() {
var grid = $('[data-vwlp-grid]');
if (state.loaded && grid && grid.offsetParent !== null) load();
}
function wire() {
var tabs = document.querySelectorAll('[data-vwlp-tab]');
for (var i = 0; i < tabs.length; i++) (function (b) {
b.addEventListener('click', function () { setTab(b.getAttribute('data-vwlp-tab')); });
})(tabs[i]);
var grid = $('[data-vwlp-grid]');
if (grid) grid.addEventListener('click', onGridClick);
var search = $('[data-vwlp-search]');
if (search) search.addEventListener('input', function () {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
state.search = search.value.trim(); state.page = 1; load();
}, 250);
});
var prev = $('[data-vwlp-prev]');
if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } });
var next = $('[data-vwlp-next]');
if (next) next.addEventListener('click', function () { state.page++; load(); });
document.addEventListener('soulsync:video-watchlist-changed', onChanged);
} }
// Intercept card clicks → in-app SPA navigation (a bare <a href> would do a // Intercept card clicks → in-app SPA navigation (a bare <a href> would do a
// FULL page reload, re-downloading the whole app — ~15s freeze). The eye // FULL page reload). The eye button's capture-phase handler already stops its
// button's own capture-phase handler already stops its clicks from reaching // own clicks from reaching here. Mirrors video-library.js.
// here. Mirrors video-library.js.
function onGridClick(e) { function onGridClick(e) {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; // let new-tab work if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var card = e.target.closest('[data-vwlp-open]'); var card = e.target.closest('[data-vwlp-open]');
if (!card) return; if (!card) return;
e.preventDefault(); e.preventDefault();
@ -122,17 +162,7 @@
})); }));
} }
function wire() { function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); } }
var tabs = document.querySelectorAll('[data-vwlp-tab]');
for (var i = 0; i < tabs.length; i++) (function (b) {
b.addEventListener('click', function () { setTab(b.getAttribute('data-vwlp-tab')); });
})(tabs[i]);
var grids = document.querySelectorAll('[data-vwlp-grid]');
for (var j = 0; j < grids.length; j++) grids[j].addEventListener('click', onGridClick);
document.addEventListener('soulsync:video-watchlist-changed', onChanged);
}
function onShown(e) { if (e && e.detail === PAGE_ID) load(); } // reload each visit → stays fresh
function init() { function init() {
wire(); wire();