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()
server = _server()
kind = request.args.get("kind")
counts = db.watchlist_counts(server_source=server)
if kind in _KINDS:
items = db.list_watchlist(kind, server_source=server)
return jsonify({"success": True, "kind": kind, "items": items})
# Paged + searchable, like the library page.
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)
shows = [r for r in rows if r.get("kind") == "show"]
people = [r for r in rows if r.get("kind") == "person"]
return jsonify({"success": True, "shows": shows, "people": people,
"counts": {"show": len(shows), "person": len(people),
"total": len(rows)}})
return jsonify({"success": True, "shows": shows, "people": people, "counts": counts})
except Exception:
logger.exception("Failed to list video watchlist")
return jsonify({"success": False, "error": "Failed to load watchlist"}), 500

View file

@ -1351,6 +1351,29 @@ class VideoDatabase:
people = self.list_watchlist("person")
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:
"""Full movie detail: the movie + owned/file info. Drives the (isolated)
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/remove", json={"kind": "person"}).status_code == 400
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("person", [287]) == {287: True}
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 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-loading hidden" data-vwlp-loading>
<div class="loading-spinner"></div>
<div class="loading-text">Loading watchlist&hellip;</div>
</div>
<div class="vwlp-grid" data-vwlp-grid="show"></div>
<div class="vwlp-grid hidden" data-vwlp-grid="person"></div>
<div class="vwlp-grid" data-vwlp-grid></div>
<div class="vwlp-state hidden" data-vwlp-empty>
<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-sub">Hover any show or person card and hit the eye to follow them — new content lands here.</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>
</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
(scoped to VIDEO cards so the shared music .library-artist-image is untouched). */
.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).
*
* The shows + people you follow, split by a Shows / People tab switcher. Reads
* /api/video/watchlist; cards reuse the shared VideoWatchlist eye-button (here it
* reads as "watched" and un-follows on click). v1 is membership only the
* monitoring/discovery engine that turns follows into downloads comes later.
* The shows + people you follow, split by a Shows / People tab switcher.
* Server-paged + searchable like the library (only a page of cards/posters
* renders at once). Reads /api/video/watchlist?kind=&search=&page=&limit=.
* Cards reuse the shared VideoWatchlist eye-button (reads as "watched" here;
* un-follows on click, with a confirm).
*/
(function () {
'use strict';
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 esc(s) {
@ -33,83 +37,119 @@
var btn = wlBtn({ kind: kind, tmdbId: it.tmdb_id, title: it.title,
poster: it.poster_url, libraryId: it.library_id });
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) + '">' +
'<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) + '">' +
esc(it.title) + '</span></div></a>';
}
function updateEmpty() {
var n = state.data[state.tab].length;
['show', 'person'].forEach(function (k) {
var g = $('[data-vwlp-grid="' + k + '"]');
if (g) g.classList.toggle('hidden', k !== state.tab || state.data[k].length === 0);
});
function setCounts(counts) {
state.counts = { show: (counts && counts.show) || 0, person: (counts && counts.person) || 0 };
var cs = $('[data-vwlp-count-show]'); if (cs) cs.textContent = state.counts.show;
var cp = $('[data-vwlp-count-person]'); if (cp) cp.textContent = state.counts.person;
}
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]');
if (empty) empty.classList.toggle('hidden', n > 0);
if (empty) empty.classList.toggle('hidden', total > 0);
var et = $('[data-vwlp-empty-title]');
if (et && n === 0) et.textContent = 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++)
tabs[i].classList.toggle('vwlp-tab--on', tabs[i].getAttribute('data-vwlp-tab') === tab);
updateEmpty();
}
function render() {
// Seed the shared cache so every button paints "watched" with no flash
// (everything on this page is, by definition, followed).
if (window.VideoWatchlist) {
state.data.show.forEach(function (it) { VideoWatchlist._watched.show[it.tmdb_id] = true; });
state.data.person.forEach(function (it) { VideoWatchlist._watched.person[it.tmdb_id] = true; });
if (et && total === 0) {
et.textContent = state.search
? 'No matches'
: (state.tab === 'show' ? 'No shows on your watchlist yet' : 'No people on your watchlist yet');
}
}
function render(items) {
// Everything on this page is watched — seed the shared cache so the eyes
// paint "watched" with no flash.
if (window.VideoWatchlist) {
items.forEach(function (it) { VideoWatchlist._watched[state.tab][it.tmdb_id] = true; });
}
var grid = $('[data-vwlp-grid]');
if (grid) {
grid.innerHTML = items.map(function (it) { return cardHTML(it, state.tab); }).join('');
if (window.VideoWatchlist) VideoWatchlist.hydrate(grid);
}
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() {
state.loaded = true;
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 (d) {
if (ld) ld.classList.add('hidden');
state.data = (d && d.success)
? { show: d.shows || [], person: d.people || [] }
: { show: [], person: [] };
render();
if (!d || !d.success) { render([]); updatePagination(null); updateEmpty(0); return; }
setCounts(d.counts);
var p = d.pagination || { page: 1, total_pages: 1, total_count: (d.items || []).length };
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 onChanged(e) {
var det = (e && e.detail) || {};
if (det.watched) return; // additions are picked up on next page load
var kind = det.kind, id = String(det.id);
if (!state.data[kind]) return;
state.data[kind] = state.data[kind].filter(function (it) { return String(it.tmdb_id) !== id; });
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;
updateEmpty();
function setTab(tab) {
if (tab !== 'show' && tab !== 'person') return;
state.tab = tab; state.page = 1;
var tabs = document.querySelectorAll('[data-vwlp-tab]');
for (var i = 0; i < tabs.length; i++)
tabs[i].classList.toggle('vwlp-tab--on', tabs[i].getAttribute('data-vwlp-tab') === tab);
load();
}
// A removal anywhere → if we're showing the watchlist, reload the page so the
// 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
// FULL page reload, re-downloading the whole app — ~15s freeze). The eye
// button's own capture-phase handler already stops its clicks from reaching
// here. Mirrors video-library.js.
// FULL page reload). The eye button's capture-phase handler already stops its
// own clicks from reaching here. Mirrors video-library.js.
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]');
if (!card) return;
e.preventDefault();
@ -122,17 +162,7 @@
}));
}
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 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 onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); } }
function init() {
wire();