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.
This commit is contained in:
BoulderBadgeDad 2026-06-16 10:19:13 -07:00
parent 1b23b7da78
commit 58ceca86b1
5 changed files with 73 additions and 17 deletions

View file

@ -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})

View file

@ -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 AZ)
total = len(items)
total_pages = max(1, (total + limit - 1) // limit)
page = min(page, total_pages)

View file

@ -932,6 +932,11 @@
<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>
<select class="vwlp-sort" data-vwlp-sort aria-label="Sort">
<option value="default">Following</option>
<option value="title">A&ndash;Z</option>
<option value="added">Recently added</option>
</select>
</div>
<div class="vwlp-body">
<div class="vwlp-loading hidden" data-vwlp-loading>

View file

@ -1952,3 +1952,19 @@ body[data-side="video"] #soulsync-toggle { display: none; }
box-shadow: 0 8px 24px rgba(var(--accent-rgb, 88 101 242), 0.4); }
.vgm-btn--primary:hover { filter: brightness(1.08); transform: translateY(-1px); }
@media (max-width: 560px) { .vgm-actions { flex-direction: column-reverse; } .vgm-btn { width: 100%; } }
/* Watchlist card richness — status pill + episode-count meta + sort select */
.vwlp-pill { position: absolute; top: 8px; left: 8px; z-index: 3;
font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.03em;
padding: 3px 8px; border-radius: 7px; backdrop-filter: blur(6px);
background: rgba(0, 0, 0, 0.55); color: rgba(255, 255, 255, 0.9); border: 1px solid rgba(255, 255, 255, 0.12); }
.vwlp-pill--airing { background: rgba(108, 211, 145, 0.22); color: #8fe7af; border-color: rgba(108, 211, 145, 0.4); }
.vwlp-pill--soon { background: rgba(var(--accent-rgb, 88 101 242), 0.24); color: #c4cbff; border-color: rgba(var(--accent-rgb, 88 101 242), 0.45); }
.vwlp-pill--ended { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.6); }
.vwlp-card-meta { display: block; font-size: 11.5px; font-weight: 600; color: rgba(255, 255, 255, 0.45); margin-top: 3px; }
.vwlp-sort { padding: 11px 14px; border-radius: 12px; background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; font-size: 13px; font-weight: 600;
cursor: pointer; outline: none; transition: border-color 0.15s ease; }
.vwlp-sort:focus { border-color: rgba(var(--accent-rgb, 88 101 242), 0.6); }
.vwlp-sort option { background: #16161d; color: #fff; }

View file

@ -12,7 +12,7 @@
var PAGE_ID = 'video-watchlist';
var LIMIT = 60;
var state = { loaded: false, tab: 'show', search: '', page: 1,
var state = { loaded: false, tab: 'show', search: '', sort: 'default', page: 1,
counts: { show: 0, person: 0 } };
var searchTimer = null;
@ -23,6 +23,18 @@
}
function wlBtn(opts) { return (window.VideoWatchlist) ? VideoWatchlist.btn(opts) : ''; }
function statusPill(status) {
var s = String(status == null ? '' : status).trim().toLowerCase();
if (!s) return '';
if (['ended', 'canceled', 'cancelled', 'completed'].indexOf(s) >= 0)
return '<span class="vwlp-pill vwlp-pill--ended">Ended</span>';
if (s.indexOf('return') >= 0 || s === 'continuing')
return '<span class="vwlp-pill vwlp-pill--airing">Airing</span>';
if (s === 'upcoming' || s.indexOf('production') >= 0 || s.indexOf('planned') >= 0 || s === 'pilot')
return '<span class="vwlp-pill vwlp-pill--soon">Upcoming</span>';
return '<span class="vwlp-pill">' + esc(status) + '</span>';
}
function cardHTML(it, kind) {
// SPA open target: library shows open by library id ('library' source);
// people + un-owned shows open by tmdb id ('tmdb').
@ -36,11 +48,14 @@
: '<div class="vwlp-card-ph">' + ph + '</div>';
var btn = wlBtn({ kind: kind, tmdbId: it.tmdb_id, title: it.title,
poster: it.poster_url, libraryId: it.library_id });
var pill = kind === 'show' ? statusPill(it.status) : '';
var meta = (kind === 'show' && it.episode_count)
? '<span class="vwlp-card-meta">' + (it.owned_count || 0) + '/' + it.episode_count + ' eps</span>' : '';
return '<a class="vwlp-card' + (kind === 'person' ? ' vwlp-card--person' : '') + '" href="' + href + '" ' +
'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-art">' + art + '<div class="vwlp-card-scrim"></div>' + pill + btn + '</div>' +
'<div class="vwlp-card-info"><span class="vwlp-card-title" title="' + esc(it.title) + '">' +
esc(it.title) + '</span></div></a>';
esc(it.title) + '</span>' + meta + '</div></a>';
}
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]');