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:
parent
1b23b7da78
commit
58ceca86b1
5 changed files with 73 additions and 17 deletions
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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…" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<select class="vwlp-sort" data-vwlp-sort aria-label="Sort">
|
||||
<option value="default">Following</option>
|
||||
<option value="title">A–Z</option>
|
||||
<option value="added">Recently added</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="vwlp-body">
|
||||
<div class="vwlp-loading hidden" data-vwlp-loading>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
|
|
|
|||
Loading…
Reference in a new issue