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:
parent
b344303f75
commit
4287385af6
7 changed files with 194 additions and 76 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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…" 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…</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">👁</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>‹ Prev</button>
|
||||
<span class="vwlp-page-info" data-vwlp-pageinfo></span>
|
||||
<button class="vwlp-page-btn" type="button" data-vwlp-next>Next ›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue