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 @@
+
-
-
+
👁
Nothing on your watchlist yet
Hover any show or person card and hit the eye to follow them — new content lands here.
+
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 '' +
'' +
'' +
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();