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:
parent
21e1944784
commit
9bb6c4ebd0
6 changed files with 174 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
94
tests/test_video_wishlist_clear.py
Normal file
94
tests/test_video_wishlist_clear.py
Normal 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
|
||||
|
|
@ -1261,6 +1261,7 @@
|
|||
<option value="wanted">Most wanted</option>
|
||||
<option value="title">A–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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 can’t 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]');
|
||||
|
|
|
|||
Loading…
Reference in a new issue