From 4287385af653e7c222a3a2678899d63c990aebd0 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 09:40:41 -0700 Subject: [PATCH] Video watchlist page: server-paged + search bar (like the library) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/video/watchlist.py | 14 ++- database/video_database.py | 23 ++++ tests/test_video_api.py | 12 ++ tests/test_video_database.py | 16 +++ webui/index.html | 14 ++- webui/static/video/video-side.css | 23 ++++ webui/static/video/video-watchlist.js | 168 +++++++++++++++----------- 7 files changed, 194 insertions(+), 76 deletions(-) diff --git a/api/video/watchlist.py b/api/video/watchlist.py index 8090cfbb..d9137e44 100644 --- a/api/video/watchlist.py +++ b/api/video/watchlist.py @@ -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 diff --git a/database/video_database.py b/database/video_database.py index fda31ebc..83bf1dc1 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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.""" diff --git a/tests/test_video_api.py b/tests/test_video_api.py index ef5573bc..0cf87bf4 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -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" diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 6f65c3c5..d99349ac 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -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 diff --git a/webui/index.html b/webui/index.html index b3f64ad8..aaf16768 100644 --- a/webui/index.html +++ b/webui/index.html @@ -927,18 +927,28 @@ +
+ +
-
- +
+
diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 62e27d6a..295ac665 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; } } diff --git a/webui/static/video/video-watchlist.js b/webui/static/video/video-watchlist.js index e0377792..5d3653e4 100644 --- a/webui/static/video/video-watchlist.js +++ b/webui/static/video/video-watchlist.js @@ -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 '' + '
' + art + '
' + btn + '
' + '
' + esc(it.title) + '
'; } - 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 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();