From 58ceca86b187286e683a37934204b89477b962cf Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 10:19:13 -0700 Subject: [PATCH] Video watchlist: richer show cards (status pill + ep count) + sort dropdown - Backend: effective shows now carry status + owned/total episode counts (joined off the shows table); query_watchlist gains a sort (default | title | added). - Cards: a status pill (Airing / Upcoming / Ended) top-left + '12/20 eps' meta under the title for shows. - Toolbar: a sort select (Following / A-Z / Recently added) next to search. 82 video tests green. --- api/video/watchlist.py | 1 + database/video_database.py | 40 ++++++++++++++++++--------- webui/index.html | 5 ++++ webui/static/video/video-side.css | 16 +++++++++++ webui/static/video/video-watchlist.js | 28 ++++++++++++++++--- 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/api/video/watchlist.py b/api/video/watchlist.py index d9137e44..674d36f1 100644 --- a/api/video/watchlist.py +++ b/api/video/watchlist.py @@ -43,6 +43,7 @@ def register_routes(bp): # Paged + searchable, like the library page. res = db.query_watchlist( kind, search=request.args.get("search", ""), + sort=request.args.get("sort", "default"), page=request.args.get("page", 1), limit=request.args.get("limit", 60), server_source=server) return jsonify({"success": True, "kind": kind, "counts": counts, **res}) diff --git a/database/video_database.py b/database/video_database.py index baf382b8..ae1b1519 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -1269,21 +1269,28 @@ class VideoDatabase: finally: conn.close() + # owned/total episode counts — joined off s.id in both queries below. + _EPS_COLS = ("(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id) AS episode_count, " + "(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id AND e.has_file=1) AS owned_count") + def _effective_shows(self, conn, server_source) -> list[dict]: - """Explicit show follows ∪ actively-airing library shows (not muted).""" + """Explicit show follows ∪ actively-airing library shows (not muted), + each carrying status + owned/total episode counts for the card chrome.""" out, seen = [], set() for r in conn.execute( - "SELECT tmdb_id, title, poster_url, library_id, date_added FROM video_watchlist " - "WHERE kind='show' AND state='follow' ORDER BY date_added DESC, id DESC"): + "SELECT w.tmdb_id, w.title, w.poster_url, w.library_id, w.date_added, s.status, " + + self._EPS_COLS + + " FROM video_watchlist w LEFT JOIN shows s ON s.id = w.library_id " + "WHERE w.kind='show' AND w.state='follow' ORDER BY w.date_added DESC, w.id DESC"): d = dict(r); d["kind"] = "show"; out.append(d); seen.add(r["tmdb_id"]) muted = {r["tmdb_id"] for r in conn.execute( "SELECT tmdb_id FROM video_watchlist WHERE kind='show' AND state='mute'")} - sql = ("SELECT tmdb_id, title, id AS library_id FROM shows " - "WHERE tmdb_id IS NOT NULL AND " + self._ACTIVE_SHOW_SQL) + sql = ("SELECT s.tmdb_id, s.title, s.id AS library_id, s.status, " + self._EPS_COLS + + " FROM shows s WHERE s.tmdb_id IS NOT NULL AND " + self._ACTIVE_SHOW_SQL) args: list = [] if server_source: - sql += " AND server_source = ?"; args.append(server_source) - sql += " ORDER BY COALESCE(sort_title, title) COLLATE NOCASE" + sql += " AND s.server_source = ?"; args.append(server_source) + sql += " ORDER BY COALESCE(s.sort_title, s.title) COLLATE NOCASE" for r in conn.execute(sql, args): tid = r["tmdb_id"] if tid in seen or tid in muted: @@ -1291,7 +1298,9 @@ class VideoDatabase: seen.add(tid) out.append({"kind": "show", "tmdb_id": tid, "title": r["title"], "poster_url": "/api/video/poster/show/%d" % r["library_id"], - "library_id": r["library_id"], "date_added": None, "auto": True}) + "library_id": r["library_id"], "status": r["status"], + "episode_count": r["episode_count"], "owned_count": r["owned_count"], + "date_added": None, "auto": True}) return out def list_watchlist(self, kind: str | None = None, server_source=None) -> list[dict]: @@ -1351,12 +1360,12 @@ 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, + def query_watchlist(self, kind: str, *, search=None, sort="default", 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.""" + """One searched/sorted/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/sorted/sliced rather than via SQL.""" try: page = max(1, int(page or 1)) limit = max(1, min(200, int(limit or 60))) @@ -1366,6 +1375,11 @@ class VideoDatabase: s = (search or "").strip().lower() if s: items = [it for it in items if s in (it.get("title") or "").lower()] + if sort == "title": + items.sort(key=lambda it: (it.get("title") or "").lower()) + elif sort == "added": # explicit follows (have a date) newest-first; airing defaults last + items.sort(key=lambda it: (it.get("date_added") or ""), reverse=True) + # "default": keep the natural effective order (follows first, then airing A–Z) total = len(items) total_pages = max(1, (total + limit - 1) // limit) page = min(page, total_pages) diff --git a/webui/index.html b/webui/index.html index 968241ff..e5834383 100644 --- a/webui/index.html +++ b/webui/index.html @@ -932,6 +932,11 @@ +
'; } function setCounts(counts) { @@ -88,7 +103,7 @@ state.loaded = true; var ld = $('[data-vwlp-loading]'); if (ld) ld.classList.remove('hidden'); var params = new URLSearchParams({ - kind: state.tab, search: state.search, page: state.page, limit: LIMIT }); + kind: state.tab, search: state.search, sort: state.sort, 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) { @@ -137,6 +152,11 @@ }, 250); }); + var sortSel = $('[data-vwlp-sort]'); + if (sortSel) sortSel.addEventListener('change', function () { + state.sort = sortSel.value; state.page = 1; load(); + }); + var prev = $('[data-vwlp-prev]'); if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } }); var next = $('[data-vwlp-next]');