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")
|
logger.exception("Failed to remove from video wishlist")
|
||||||
return jsonify({"success": False, "error": "Failed"}), 500
|
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"])
|
@bp.route("/wishlist/backfill-art", methods=["POST"])
|
||||||
def video_wishlist_backfill_art():
|
def video_wishlist_backfill_art():
|
||||||
"""Fill episode stills + season posters for rows that predate art-capture.
|
"""Fill episode stills + season posters for rows that predate art-capture.
|
||||||
|
|
|
||||||
|
|
@ -2127,6 +2127,20 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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:
|
def wishlist_counts(self) -> dict:
|
||||||
"""{'movie': n, 'show': n, 'episode': n, 'total': movies+episodes}."""
|
"""{'movie': n, 'show': n, 'episode': n, 'total': movies+episodes}."""
|
||||||
conn = self._get_connection()
|
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="wanted">Most wanted</option>
|
||||||
<option value="title">A–Z</option>
|
<option value="title">A–Z</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button class="vwsh-clear" type="button" data-vwsh-clear hidden>Clear all</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="vwsh-body">
|
<div class="vwsh-body">
|
||||||
<div class="vwsh-loading hidden" data-vwsh-loading>
|
<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; }
|
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:focus { border-color: rgba(var(--accent-rgb, 29, 185, 84), 0.5); }
|
||||||
.vwsh-sort option { background: #16161d; color: #fff; }
|
.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) */
|
/* video-only: square-ish bubbles (the music orbs are circles) */
|
||||||
.vwsh-nebula .wl-orb, .vwsh-nebula .wl-orb-glow, .vwsh-nebula .wl-orb-img,
|
.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;
|
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));
|
updateBadges(counts && counts.total != null ? counts.total : (state.counts.movie + state.counts.episode));
|
||||||
updateSub();
|
updateSub();
|
||||||
|
updateClearBtn();
|
||||||
}
|
}
|
||||||
function setYtCounts(counts) {
|
function setYtCounts(counts) {
|
||||||
state.ytChannel = (counts && counts.channel) || 0;
|
state.ytChannel = (counts && counts.channel) || 0;
|
||||||
state.ytVideo = (counts && counts.video) || 0;
|
state.ytVideo = (counts && counts.video) || 0;
|
||||||
var cy = $('[data-vwsh-count-youtube]'); if (cy) cy.textContent = state.ytVideo;
|
var cy = $('[data-vwsh-count-youtube]'); if (cy) cy.textContent = state.ytVideo;
|
||||||
updateSub();
|
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.
|
// Keep the YouTube tab badge fresh without switching to the tab.
|
||||||
function refreshYtCount() {
|
function refreshYtCount() {
|
||||||
|
|
@ -431,9 +441,38 @@
|
||||||
var tabs = document.querySelectorAll('[data-vwsh-tab]');
|
var tabs = document.querySelectorAll('[data-vwsh-tab]');
|
||||||
for (var i = 0; i < tabs.length; i++)
|
for (var i = 0; i < tabs.length; i++)
|
||||||
tabs[i].classList.toggle('vwsh-tab--on', tabs[i].getAttribute('data-vwsh-tab') === tab);
|
tabs[i].classList.toggle('vwsh-tab--on', tabs[i].getAttribute('data-vwsh-tab') === tab);
|
||||||
|
updateClearBtn();
|
||||||
load();
|
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) ──
|
// ── remove (TMDB scopes via /wishlist/remove; YouTube scopes via youtube) ──
|
||||||
function doRemove(btn) {
|
function doRemove(btn) {
|
||||||
var scope = btn.getAttribute('data-vwsh-rm');
|
var scope = btn.getAttribute('data-vwsh-rm');
|
||||||
|
|
@ -539,6 +578,8 @@
|
||||||
});
|
});
|
||||||
var sortSel = $('[data-vwsh-sort]');
|
var sortSel = $('[data-vwsh-sort]');
|
||||||
if (sortSel) sortSel.addEventListener('change', function () { state.sort = sortSel.value; state.page = 1; load(); });
|
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]');
|
var prev = $('[data-vwsh-prev]');
|
||||||
if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } });
|
if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } });
|
||||||
var next = $('[data-vwsh-next]');
|
var next = $('[data-vwsh-next]');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue