diff --git a/api/video/wishlist.py b/api/video/wishlist.py index 487d6278..87f56f95 100644 --- a/api/video/wishlist.py +++ b/api/video/wishlist.py @@ -39,7 +39,7 @@ def register_routes(bp): kind = request.args.get("kind") if kind in _KINDS: res = db.query_wishlist( - kind, search=request.args.get("search", ""), + kind, search=request.args.get("search", ""), sort=request.args.get("sort", "added"), page=request.args.get("page", 1), limit=request.args.get("limit", 60)) return jsonify({"success": True, "kind": kind, "counts": counts, **res}) return jsonify({"success": True, "counts": counts}) diff --git a/database/video_database.py b/database/video_database.py index f2111f9a..4708e22d 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -1578,9 +1578,10 @@ class VideoDatabase: finally: conn.close() - def query_wishlist(self, kind: str, *, search=None, page=1, limit=60) -> dict: + def query_wishlist(self, kind: str, *, search=None, sort="added", page=1, limit=60) -> dict: """One paged slice of the wishlist. kind='movie' → movie cards; kind='show' - → shows grouped show→season→episode with wanted/done roll-ups. {items, + → shows grouped show→season→episode with wanted/done roll-ups. ``sort`` ∈ + added | title | wanted (wanted = most episodes, shows only). {items, pagination} like the other paged queries.""" try: page = max(1, int(page or 1)) @@ -1595,10 +1596,12 @@ class VideoDatabase: if s: where.append("title LIKE ? COLLATE NOCASE"); args.append("%" + s + "%") wsql = " WHERE " + " AND ".join(where) + order = {"title": "title COLLATE NOCASE", + "added": "date_added DESC, id DESC"}.get(sort, "date_added DESC, id DESC") total = conn.execute("SELECT COUNT(*) c FROM video_wishlist" + wsql, args).fetchone()["c"] rows = conn.execute( "SELECT tmdb_id, title, poster_url, year, status, library_id, date_added " - "FROM video_wishlist" + wsql + " ORDER BY date_added DESC, id DESC LIMIT ? OFFSET ?", + "FROM video_wishlist" + wsql + " ORDER BY " + order + " LIMIT ? OFFSET ?", args + [limit, (page - 1) * limit]).fetchall() items = [{"kind": "movie", "tmdb_id": r["tmdb_id"], "title": r["title"], "poster_url": r["poster_url"], "year": r["year"], "status": r["status"], @@ -1610,13 +1613,15 @@ class VideoDatabase: wsql = " WHERE " + " AND ".join(where) total = conn.execute( "SELECT COUNT(DISTINCT tmdb_id) c FROM video_wishlist" + wsql, args).fetchone()["c"] + order = {"title": "title COLLATE NOCASE", "wanted": "wanted DESC, last_added DESC", + "added": "last_added DESC"}.get(sort, "last_added DESC") show_rows = conn.execute( "SELECT tmdb_id, MAX(title) AS title, MAX(poster_url) AS poster_url, " "MAX(library_id) AS library_id, COUNT(*) AS wanted, " "SUM(CASE WHEN status='downloaded' THEN 1 ELSE 0 END) AS done, " "MAX(date_added) AS last_added " "FROM video_wishlist" + wsql + - " GROUP BY tmdb_id ORDER BY last_added DESC LIMIT ? OFFSET ?", + " GROUP BY tmdb_id ORDER BY " + order + " LIMIT ? OFFSET ?", args + [limit, (page - 1) * limit]).fetchall() items = [] for sr in show_rows: diff --git a/tests/test_video_database.py b/tests/test_video_database.py index b63c26de..8fd7a8e8 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -930,3 +930,18 @@ def test_wishlist_keys_for_shows(db): keys = db.wishlist_keys_for_shows([1396, 1399, 9999]) assert keys[1396] == {"1_1", "2_3"} and keys[1399] == {"1_1"} and 9999 not in keys assert db.wishlist_keys_for_shows([]) == {} + + +def test_wishlist_query_sort(db): + db.add_episodes_to_wishlist(1, "Alpha", [{"season_number": 1, "episode_number": 1}]) # 1 ep + db.add_episodes_to_wishlist(2, "Zeta", [{"season_number": 1, "episode_number": i} for i in range(5)]) # 5 eps + # most-wanted first + w = [s["title"] for s in db.query_wishlist("show", sort="wanted")["items"]] + assert w[0] == "Zeta" + # A–Z + az = [s["title"] for s in db.query_wishlist("show", sort="title")["items"]] + assert az == ["Alpha", "Zeta"] + # movies A–Z + db.add_movie_to_wishlist(10, "Banana"); db.add_movie_to_wishlist(11, "Apple") + m = [x["title"] for x in db.query_wishlist("movie", sort="title")["items"]] + assert m == ["Apple", "Banana"] diff --git a/webui/index.html b/webui/index.html index 4e7fee96..56e5c3f5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1054,7 +1054,7 @@

Wishlist

-

Movies & episodes you want to grab

+

Movies & episodes you want to grab

+
'; }).join(''); var eps = total + ' episode' + (total === 1 ? '' : 's'); - return '
' + + // --orb-hue on the GROUP so the music orb styles + my cinematic-expand + // backdrop (--vwsh-poster) both resolve; poster bleeds in only when expanded. + var gstyle = 'animation-delay:' + Math.min(idx * 45, 700) + 'ms;--orb-hue:' + hue + + (sh.poster_url ? ";--vwsh-poster:url('" + esc(sh.poster_url) + "')" : ''); + return '
' + '' + '
' + esc(sh.title) + '
' + eps + '
' + - '
' + + '
' + '
' + img + '
' + '
' + '
' + esc(sh.title) + '
' + @@ -111,6 +127,7 @@ var grid = $('[data-vwsh-grid]'); if (!grid) return; var shows = state.tab === 'show'; grid.classList.toggle('wl-nebula-field', shows); + grid.classList.toggle('vwsh-nebula', shows); // video-only scope so music wl-* is untouched grid.classList.toggle('vwsh-grid--movies', !shows); grid.innerHTML = shows ? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('') @@ -119,10 +136,19 @@ // ── counts / badges / pager ─────────────────────────────────────────────── function setCounts(counts) { - state.counts = { movie: (counts && counts.movie) || 0, show: (counts && counts.show) || 0 }; + state.counts = { movie: (counts && counts.movie) || 0, show: (counts && counts.show) || 0, + episode: (counts && counts.episode) || 0 }; var cm = $('[data-vwsh-count-movie]'); if (cm) cm.textContent = state.counts.movie; var cs = $('[data-vwsh-count-show]'); if (cs) cs.textContent = state.counts.show; - updateBadges(counts && counts.total != null ? counts.total : (state.counts.movie + state.counts.show)); + updateBadges(counts && counts.total != null ? counts.total : (state.counts.movie + state.counts.episode)); + updateSub(); + } + function updateSub() { + var el = $('[data-vwsh-sub]'); if (!el) return; + var c = state.counts; + el.textContent = state.tab === 'show' + ? c.show + ' show' + (c.show === 1 ? '' : 's') + ' · ' + c.episode + ' episode' + (c.episode === 1 ? '' : 's') + : c.movie + ' movie' + (c.movie === 1 ? '' : 's'); } function updateBadges(total) { var n = total || 0; @@ -160,7 +186,7 @@ function load() { state.loaded = true; var ld = $('[data-vwsh-loading]'); if (ld) ld.classList.remove('hidden'); - var params = new URLSearchParams({ kind: state.tab, search: state.search, page: state.page, limit: LIMIT }); + var params = new URLSearchParams({ kind: state.tab, search: state.search, sort: state.sort, page: state.page, limit: LIMIT }); fetch('/api/video/wishlist?' + params.toString(), { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { @@ -234,6 +260,8 @@ if (searchTimer) clearTimeout(searchTimer); searchTimer = setTimeout(function () { state.search = search.value.trim(); state.page = 1; load(); }, 250); }); + var sortSel = $('[data-vwsh-sort]'); + if (sortSel) sortSel.addEventListener('change', function () { state.sort = sortSel.value; state.page = 1; load(); }); var prev = $('[data-vwsh-prev]'); if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } }); var next = $('[data-vwsh-next]');