video wishlist: add a 'Clear all' button (movies / TV / YouTube)

The wishlist page had no way to empty a tab — only per-item remove. Added a red-tinted
'Clear all' button in the toolbar that empties the ACTIVE tab in one click (after a
confirm), shown only when that tab has items.

- db.clear_wishlist(kind) maps the tab to its rows (movie→'movie', show→'episode',
  youtube→'video') and deletes them; returns the count.
- POST /api/video/wishlist/clear {kind: movie|show|youtube}.
- Toolbar button + clearAll() (confirm → clear → reload) + updateClearBtn() visibility.

Tests: per-tab clear leaves the others intact, unknown-kind/empty no-ops, the endpoint,
and the frontend wiring. node --check clean.
This commit is contained in:
BoulderBadgeDad 2026-06-20 16:42:57 -07:00
parent 21e1944784
commit 9bb6c4ebd0
6 changed files with 174 additions and 0 deletions

View file

@ -108,6 +108,23 @@ def register_routes(bp):
logger.exception("Failed to remove from video wishlist")
return jsonify({"success": False, "error": "Failed"}), 500
@bp.route("/wishlist/clear", methods=["POST"])
def video_wishlist_clear():
"""Empty an entire wishlist tab. Body: {kind} where kind ∈ movie|show|youtube."""
from . import get_video_db
body = request.get_json(silent=True) or {}
kind = body.get("kind")
if kind not in ("movie", "show", "youtube"):
return jsonify({"success": False, "error": "kind must be movie|show|youtube"}), 400
try:
db = get_video_db()
removed = db.clear_wishlist(kind)
return jsonify({"success": True, "removed": removed,
"counts": db.wishlist_counts(), "youtube_counts": db.youtube_wishlist_counts()})
except Exception:
logger.exception("Failed to clear video wishlist")
return jsonify({"success": False, "error": "Failed"}), 500
@bp.route("/wishlist/backfill-art", methods=["POST"])
def video_wishlist_backfill_art():
"""Fill episode stills + season posters for rows that predate art-capture.

View file

@ -2127,6 +2127,20 @@ class VideoDatabase:
finally:
conn.close()
def clear_wishlist(self, kind: str) -> int:
"""Empty an entire wishlist tab in one go. ``kind`` is the user-facing tab:
'movie' | 'show' (TV) | 'youtube'. Returns the number of rows removed."""
dbkind = {"movie": "movie", "show": "episode", "youtube": "video"}.get(kind)
if not dbkind:
return 0
conn = self._get_connection()
try:
cur = conn.execute("DELETE FROM video_wishlist WHERE kind=?", (dbkind,))
conn.commit()
return cur.rowcount
finally:
conn.close()
def wishlist_counts(self) -> dict:
"""{'movie': n, 'show': n, 'episode': n, 'total': movies+episodes}."""
conn = self._get_connection()

View file

@ -0,0 +1,94 @@
"""Wishlist 'Clear all' — empties one tab (movies / TV / YouTube) in a single call.
The three tabs map to different rows in video_wishlist: movies=kind 'movie',
TV=kind 'episode', YouTube=kind 'video'. clear_wishlist(kind) removes exactly the
one tab's rows and leaves the others alone.
"""
from __future__ import annotations
import pytest
from database.video_database import VideoDatabase
@pytest.fixture()
def db(tmp_path):
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
def _seed(db):
db.add_movie_to_wishlist(1, "M1")
db.add_movie_to_wishlist(2, "M2")
db.add_episodes_to_wishlist(9, "Show", [
{"season_number": 1, "episode_number": 1},
{"season_number": 1, "episode_number": 2}])
db.add_videos_to_wishlist({"youtube_id": "ch1", "title": "Ch"},
[{"youtube_id": "v1", "title": "V1"},
{"youtube_id": "v2", "title": "V2"}])
def test_clear_each_tab_removes_only_its_own_rows(db):
_seed(db)
assert db.wishlist_counts()["movie"] == 2
assert db.wishlist_counts()["episode"] == 2
assert db.youtube_wishlist_counts()["video"] == 2
# clear movies → TV + YouTube untouched
assert db.clear_wishlist("movie") == 2
assert db.wishlist_counts()["movie"] == 0
assert db.wishlist_counts()["episode"] == 2
assert db.youtube_wishlist_counts()["video"] == 2
# clear TV → only episodes gone
assert db.clear_wishlist("show") == 2
assert db.wishlist_counts()["episode"] == 0
assert db.youtube_wishlist_counts()["video"] == 2
# clear YouTube
assert db.clear_wishlist("youtube") == 2
assert db.youtube_wishlist_counts()["video"] == 0
def test_clear_rejects_unknown_kind(db):
_seed(db)
assert db.clear_wishlist("nope") == 0
assert db.wishlist_counts()["movie"] == 2 # nothing removed
def test_clear_empty_tab_is_a_noop(db):
assert db.clear_wishlist("movie") == 0
def test_clear_endpoint(tmp_path):
from flask import Flask
import api.video as videoapi
db = VideoDatabase(database_path=str(tmp_path / "video_library.db"))
_seed(db)
videoapi._video_db = db
app = Flask(__name__)
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
client = app.test_client()
try:
r = client.post("/api/video/wishlist/clear", json={"kind": "movie"}).get_json()
assert r["success"] and r["removed"] == 2 and r["counts"]["movie"] == 0
assert client.post("/api/video/wishlist/clear", json={"kind": "bad"}).status_code == 400
finally:
videoapi._video_db = None
# ── frontend wiring ─────────────────────────────────────────────────────────
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
_JS = (_ROOT / "webui" / "static" / "video" / "video-wishlist.js").read_text(encoding="utf-8")
_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
def test_clear_button_wired_for_all_tabs():
assert "data-vwsh-clear" in _INDEX
assert "function clearAll(" in _JS
assert "/api/video/wishlist/clear" in _JS
assert "kind: kind" in _JS # clears the active tab
# shown only when the active tab has items
assert "function updateClearBtn(" in _JS

View file

@ -1261,6 +1261,7 @@
<option value="wanted">Most wanted</option>
<option value="title">A&ndash;Z</option>
</select>
<button class="vwsh-clear" type="button" data-vwsh-clear hidden>Clear all</button>
</div>
<div class="vwsh-body">
<div class="vwsh-loading hidden" data-vwsh-loading>

View file

@ -2570,6 +2570,13 @@ body[data-side="video"] #soulsync-toggle { display: none; }
cursor: pointer; outline: none; transition: border-color 0.15s ease; }
.vwsh-sort:focus { border-color: rgba(var(--accent-rgb, 29, 185, 84), 0.5); }
.vwsh-sort option { background: #16161d; color: #fff; }
/* Clear-all (empties the current tab) — red-tinted, sits at the end of the toolbar */
.vwsh-clear { margin-left: auto; padding: 11px 16px; border-radius: 12px; cursor: pointer;
font-size: 13px; font-weight: 700; color: #fca5a5;
background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.32);
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
.vwsh-clear:hover { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.55); color: #fecaca; }
.vwsh-clear[hidden] { display: none; }
/* video-only: square-ish bubbles (the music orbs are circles) */
.vwsh-nebula .wl-orb, .vwsh-nebula .wl-orb-glow, .vwsh-nebula .wl-orb-img,

View file

@ -306,12 +306,22 @@
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.episode));
updateSub();
updateClearBtn();
}
function setYtCounts(counts) {
state.ytChannel = (counts && counts.channel) || 0;
state.ytVideo = (counts && counts.video) || 0;
var cy = $('[data-vwsh-count-youtube]'); if (cy) cy.textContent = state.ytVideo;
updateSub();
updateClearBtn();
}
// Show "Clear all" only when the active tab actually has items.
function updateClearBtn() {
var btn = $('[data-vwsh-clear]'); if (!btn) return;
var has = state.tab === 'movie' ? state.counts.movie > 0
: state.tab === 'show' ? state.counts.show > 0
: (state.ytVideo > 0 || state.ytChannel > 0);
btn.hidden = !has;
}
// Keep the YouTube tab badge fresh without switching to the tab.
function refreshYtCount() {
@ -431,9 +441,38 @@
var tabs = document.querySelectorAll('[data-vwsh-tab]');
for (var i = 0; i < tabs.length; i++)
tabs[i].classList.toggle('vwsh-tab--on', tabs[i].getAttribute('data-vwsh-tab') === tab);
updateClearBtn();
load();
}
// Empty the whole current tab (movies / TV / YouTube), after a confirm.
function clearAll() {
var kind = state.tab;
var label = kind === 'movie' ? 'movies' : kind === 'show' ? 'TV episodes' : 'YouTube videos';
var go = function () {
fetch('/api/video/wishlist/clear', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: kind }),
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) {
if (res && res.success) {
if (typeof showToast === 'function')
showToast('Cleared ' + (res.removed || 0) + ' ' + label + ' from your wishlist', 'success');
load();
} else if (typeof showToast === 'function') {
showToast('Could not clear wishlist', 'error');
}
}).catch(function () { if (typeof showToast === 'function') showToast('Could not clear wishlist', 'error'); });
};
if (typeof showConfirmDialog === 'function') {
showConfirmDialog({
title: 'Clear wishlist',
message: 'Remove ALL ' + label + ' from your wishlist? This cant be undone.',
confirmText: 'Clear all', cancelText: 'Cancel', destructive: true,
}).then(function (ok) { if (ok) go(); });
} else { go(); }
}
// ── remove (TMDB scopes via /wishlist/remove; YouTube scopes via youtube) ──
function doRemove(btn) {
var scope = btn.getAttribute('data-vwsh-rm');
@ -539,6 +578,8 @@
});
var sortSel = $('[data-vwsh-sort]');
if (sortSel) sortSel.addEventListener('change', function () { state.sort = sortSel.value; state.page = 1; load(); });
var clearBtn = $('[data-vwsh-clear]');
if (clearBtn) clearBtn.addEventListener('click', clearAll);
var prev = $('[data-vwsh-prev]');
if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } });
var next = $('[data-vwsh-next]');