mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
v3 library: song Details drawer + bulk edit — P2 (#703)
* v3 library: song Details drawer + bulk edit — P2 Evolves the per-song editing surface from the legacy modal into a v3 slide-in Details drawer (the filter-drawer idiom, body-appended): catalog fields (title/artist/album/year, written through the existing atomic manifest writer) plus the P1 personal layer - your difficulty (1-5), tags, and notes - with the heart staying the existing favorite system. - Cards badge the personal layer at rest (difficulty pip + tag count, top-right, fading on hover so the action buttons keep that corner); un-annotated cards render byte-identical to before. - Bulk edit from the select-mode batch bar: POST /api/songs/user-meta/batch applies additive tag add/remove and a leave/set/clear difficulty across the selection (mixed-state aware). - The core card action relabels to "Details" and opens the drawer via a feature-detected global, falling back to the legacy modal when the drawer isn't mounted. 16 batch tests new; the P1 user-meta suite stays green. tailwind.min.css rebuilt for the drawer's utility classes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * fix(v3): surface bulk-edit / save-details request failures instead of reporting success (PR #703 review) Check the batch/write responses and show an fbNotify error (keeping selection and drawer) instead of unconditionally closing as success. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Fable 5
byrongamatos
parent
58e7407c38
commit
feaaa5cd81
@@ -998,6 +998,66 @@ class MetadataDB:
|
|||||||
self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,))
|
self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,))
|
||||||
self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,))
|
self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,))
|
||||||
|
|
||||||
|
def batch_user_meta(self, filenames, *, set_difficulty="__keep__",
|
||||||
|
add_tags=None, remove_tags=None) -> int:
|
||||||
|
"""Apply personal-meta edits across MANY songs in one transaction —
|
||||||
|
the bulk-edit primitive behind the batch bar. Additive by design so a
|
||||||
|
bulk action never silently clobbers per-song data the user can't see:
|
||||||
|
|
||||||
|
- `set_difficulty`: an int 1–5 sets it on every song; `None` clears it
|
||||||
|
on every song; the `__keep__` sentinel leaves each song's own value
|
||||||
|
untouched (mixed-state "leave unchanged"). Notes are preserved; a row
|
||||||
|
that ends up difficulty-less AND notes-less is dropped (no empty shell,
|
||||||
|
matching set_song_user_meta).
|
||||||
|
- `add_tags` / `remove_tags`: tag sets ADDED to / REMOVED from each song
|
||||||
|
(never a full-replace — bulk must not wipe a song's other tags). A tag
|
||||||
|
in both add and remove resolves to add (explicit set wins).
|
||||||
|
|
||||||
|
Returns the count of songs touched. Caller normalizes tags is NOT
|
||||||
|
assumed — we normalize here so the endpoint and the DB agree."""
|
||||||
|
add = []
|
||||||
|
seen: set = set()
|
||||||
|
for t in (add_tags or []):
|
||||||
|
nt = _normalize_tag(t)
|
||||||
|
if nt and nt not in seen:
|
||||||
|
seen.add(nt)
|
||||||
|
add.append(nt)
|
||||||
|
rem = {nt for nt in (_normalize_tag(t) for t in (remove_tags or [])) if nt}
|
||||||
|
rem -= set(add) # add wins a conflict
|
||||||
|
fns = list(dict.fromkeys(filenames or [])) # dedupe, keep order
|
||||||
|
if not fns:
|
||||||
|
return 0
|
||||||
|
with self._lock:
|
||||||
|
for fn in fns:
|
||||||
|
if set_difficulty != "__keep__":
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"SELECT notes FROM song_user_meta WHERE filename = ?",
|
||||||
|
(fn,)).fetchone()
|
||||||
|
cur_notes = cur[0] if cur else None
|
||||||
|
if set_difficulty is None and not (cur_notes or "").strip():
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM song_user_meta WHERE filename = ?", (fn,))
|
||||||
|
else:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO song_user_meta (filename, user_difficulty, notes, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, datetime('now')) "
|
||||||
|
"ON CONFLICT(filename) DO UPDATE SET "
|
||||||
|
"user_difficulty = excluded.user_difficulty, "
|
||||||
|
"updated_at = excluded.updated_at",
|
||||||
|
(fn, set_difficulty, cur_notes))
|
||||||
|
if rem:
|
||||||
|
ph = ",".join("?" * len(rem))
|
||||||
|
self.conn.execute(
|
||||||
|
f"DELETE FROM song_tags WHERE filename = ? AND tag IN ({ph})",
|
||||||
|
[fn, *rem])
|
||||||
|
if add:
|
||||||
|
self.conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO song_tags (filename, tag, created_at) "
|
||||||
|
"VALUES (?, ?, datetime('now'))",
|
||||||
|
[(fn, t) for t in add])
|
||||||
|
self.conn.commit()
|
||||||
|
return len(fns)
|
||||||
|
|
||||||
# ── Player profile (fee[dB]ack v0.3.0) ─────────────────────────────────
|
# ── Player profile (fee[dB]ack v0.3.0) ─────────────────────────────────
|
||||||
def get_profile(self) -> dict:
|
def get_profile(self) -> dict:
|
||||||
row = self.conn.execute(
|
row = self.conn.execute(
|
||||||
@@ -5352,6 +5412,53 @@ def put_song_user_meta(filename: str, data: dict):
|
|||||||
return meta_db.get_song_user_meta(key)
|
return meta_db.get_song_user_meta(key)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/songs/user-meta/batch")
|
||||||
|
def batch_song_user_meta(data: dict):
|
||||||
|
"""Bulk personal-meta edit over a selection — one request instead of N×2
|
||||||
|
per-song round-trips (the batch bar's apply-to-all). DB-only; never touches
|
||||||
|
files. Body:
|
||||||
|
{"filenames": [...], # required, non-empty
|
||||||
|
"set_difficulty": 1-5 | null, # optional: set on all / clear on all
|
||||||
|
"add_tags": [...], # optional: add to all (never full-replace)
|
||||||
|
"remove_tags": [...]} # optional: remove from all
|
||||||
|
Omit `set_difficulty` entirely to leave each song's difficulty as-is
|
||||||
|
(mixed-state "leave unchanged"). Returns {"updated": N, "tags": [...]} so the
|
||||||
|
caller can refresh the tag-filter list without a second call."""
|
||||||
|
fns = data.get("filenames")
|
||||||
|
if not isinstance(fns, list) or not fns:
|
||||||
|
return JSONResponse({"error": "filenames must be a non-empty array"}, 400)
|
||||||
|
if not all(isinstance(f, str) and f for f in fns):
|
||||||
|
return JSONResponse({"error": "filenames must be non-empty strings"}, 400)
|
||||||
|
|
||||||
|
kwargs: dict = {}
|
||||||
|
if "set_difficulty" in data:
|
||||||
|
v = data["set_difficulty"]
|
||||||
|
if v is None or v == "":
|
||||||
|
kwargs["set_difficulty"] = None
|
||||||
|
else:
|
||||||
|
if isinstance(v, bool) or (isinstance(v, float) and not v.is_integer()):
|
||||||
|
return JSONResponse({"error": "set_difficulty must be an integer 1–5 or null"}, 400)
|
||||||
|
try:
|
||||||
|
iv = int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return JSONResponse({"error": "set_difficulty must be an integer 1–5 or null"}, 400)
|
||||||
|
if not (1 <= iv <= 5):
|
||||||
|
return JSONResponse({"error": "set_difficulty must be 1–5 or null"}, 400)
|
||||||
|
kwargs["set_difficulty"] = iv
|
||||||
|
|
||||||
|
add_tags = data.get("add_tags")
|
||||||
|
remove_tags = data.get("remove_tags")
|
||||||
|
for name, val in (("add_tags", add_tags), ("remove_tags", remove_tags)):
|
||||||
|
if val is not None and not isinstance(val, list):
|
||||||
|
return JSONResponse({"error": f"{name} must be an array of strings"}, 400)
|
||||||
|
if "set_difficulty" not in data and not add_tags and not remove_tags:
|
||||||
|
return JSONResponse({"error": "Nothing to apply"}, 400)
|
||||||
|
|
||||||
|
keys = [meta_db._canonical_song_filename(f) for f in fns]
|
||||||
|
n = meta_db.batch_user_meta(keys, add_tags=add_tags, remove_tags=remove_tags, **kwargs)
|
||||||
|
return {"updated": n, "tags": meta_db.all_tags()}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/tags")
|
@app.get("/api/tags")
|
||||||
def list_tags():
|
def list_tags():
|
||||||
"""All personal tags in use (over still-present songs), most-used first —
|
"""All personal tags in use (over still-present songs), most-used first —
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -23,11 +23,18 @@
|
|||||||
reg.register({
|
reg.register({
|
||||||
id: 'core.edit-metadata',
|
id: 'core.edit-metadata',
|
||||||
pluginId: 'core',
|
pluginId: 'core',
|
||||||
label: 'Edit metadata',
|
label: 'Details',
|
||||||
placement: 'menu',
|
placement: 'menu',
|
||||||
order: 10,
|
order: 10,
|
||||||
applies: (song) => !!(song && song.filename),
|
applies: (song) => !!(song && song.filename),
|
||||||
run: (song) => {
|
run: (song) => {
|
||||||
|
// Prefer the v3 Details drawer (identity + personal difficulty / tags
|
||||||
|
// / notes); fall back to the legacy edit-metadata modal where the v3
|
||||||
|
// songs screen hasn't defined the opener (e.g. an older shell).
|
||||||
|
if (typeof window.__fbOpenSongDetails === 'function') {
|
||||||
|
window.__fbOpenSongDetails(song);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof window.openEditModal !== 'function') return;
|
if (typeof window.openEditModal !== 'function') return;
|
||||||
window.openEditModal({
|
window.openEditModal({
|
||||||
f: song.filename, t: song.title || '', a: song.artist || '',
|
f: song.filename, t: song.title || '', a: song.artist || '',
|
||||||
|
|||||||
+334
-1
@@ -643,6 +643,21 @@
|
|||||||
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
|
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Personal-layer badges (P2): a difficulty pip + a tag count, painted from the
|
||||||
|
// row payload P1 embeds (song.user_difficulty / song.tags). Sits at top-right
|
||||||
|
// and fades on hover so it yields to the action buttons that share that corner.
|
||||||
|
// Returns '' when the song has neither, so an un-annotated card is byte-for-byte
|
||||||
|
// what it was before P2 (keeps the windowed grid's height math untouched).
|
||||||
|
function personalBadges(song) {
|
||||||
|
const d = song.user_difficulty;
|
||||||
|
const tags = song.tags || [];
|
||||||
|
if (d == null && !tags.length) return '';
|
||||||
|
let out = '<div class="absolute top-2 right-2 flex gap-1 opacity-100 group-hover:opacity-0 transition pointer-events-none">';
|
||||||
|
if (d != null) out += '<span class="bg-black/60 text-white text-[10px] font-bold px-1.5 py-0.5 rounded" title="Your difficulty: ' + esc(DIFF_LABELS[d] || d) + '">◆' + esc(d) + '</span>';
|
||||||
|
if (tags.length) out += '<span class="bg-black/60 text-white text-[10px] font-bold px-1.5 py-0.5 rounded" title="Tags: ' + esc(tags.join(', ')) + '">🏷' + tags.length + '</span>';
|
||||||
|
return out + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
// Clickable arrangement chips — one <button data-arr="<index>"> per
|
// Clickable arrangement chips — one <button data-arr="<index>"> per
|
||||||
// arrangement. wireCards() binds the click to playCard(song, index), which
|
// arrangement. wireCards() binds the click to playCard(song, index), which
|
||||||
// opens THAT arrangement in the highway via playSong(filename, index).
|
// opens THAT arrangement in the highway via playSong(filename, index).
|
||||||
@@ -760,7 +775,7 @@
|
|||||||
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
||||||
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
|
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
|
||||||
'<img src="' + esc(artUrl(song)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
'<img src="' + esc(artUrl(song)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
||||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + overlay +
|
tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + personalBadges(song) + overlay +
|
||||||
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
|
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
|
||||||
inlineBtns +
|
inlineBtns +
|
||||||
'<button data-fav title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
|
'<button data-fav title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
|
||||||
@@ -890,11 +905,13 @@
|
|||||||
bar.innerHTML =
|
bar.innerHTML =
|
||||||
'<span class="text-sm text-fb-text">' + state.selected.size + ' selected</span>' +
|
'<span class="text-sm text-fb-text">' + state.selected.size + ' selected</span>' +
|
||||||
'<button data-batch="playlist" class="text-sm bg-fb-primary hover:bg-fb-primaryHi text-white px-3 py-1 rounded-full">Add to playlist</button>' +
|
'<button data-batch="playlist" class="text-sm bg-fb-primary hover:bg-fb-primaryHi text-white px-3 py-1 rounded-full">Add to playlist</button>' +
|
||||||
|
'<button data-batch="details" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 py-1 rounded-full">Edit details</button>' +
|
||||||
'<button data-batch="saved" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 py-1 rounded-full">Save for Later</button>' +
|
'<button data-batch="saved" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 py-1 rounded-full">Save for Later</button>' +
|
||||||
'<button data-batch="clear" class="text-sm text-fb-textDim hover:text-fb-text px-2">Clear</button>';
|
'<button data-batch="clear" class="text-sm text-fb-textDim hover:text-fb-text px-2">Clear</button>';
|
||||||
bar.querySelector('[data-batch="clear"]').addEventListener('click', () => { state.selected.clear(); reload(); renderBatchBar(); });
|
bar.querySelector('[data-batch="clear"]').addEventListener('click', () => { state.selected.clear(); reload(); renderBatchBar(); });
|
||||||
bar.querySelector('[data-batch="saved"]').addEventListener('click', batchSave);
|
bar.querySelector('[data-batch="saved"]').addEventListener('click', batchSave);
|
||||||
bar.querySelector('[data-batch="playlist"]').addEventListener('click', batchAddToPlaylist);
|
bar.querySelector('[data-batch="playlist"]').addEventListener('click', batchAddToPlaylist);
|
||||||
|
bar.querySelector('[data-batch="details"]').addEventListener('click', openBulkEdit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function batchSave() {
|
async function batchSave() {
|
||||||
@@ -1044,6 +1061,94 @@
|
|||||||
reload(); renderBatchBar();
|
reload(); renderBatchBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk personal-meta editor (P2) — apply-to-all over the current selection via
|
||||||
|
// the batch endpoint (one request). Additive + mixed-state safe: difficulty
|
||||||
|
// defaults to "Leave" (each song keeps its own value); tags ADD/REMOVE rather
|
||||||
|
// than replace, so a bulk action never silently wipes per-song data. Notes are
|
||||||
|
// inherently per-song, so they're deliberately not bulk-editable.
|
||||||
|
function openBulkEdit() {
|
||||||
|
const fns = [...state.selected];
|
||||||
|
if (!fns.length) return;
|
||||||
|
// Tags present across the selection (from the embedded row payload) → one-tap
|
||||||
|
// removable chips, no extra fetch.
|
||||||
|
const present = new Set();
|
||||||
|
fns.forEach((fn) => ((state.songsById[fn] && state.songsById[fn].tags) || []).forEach((t) => present.add(t)));
|
||||||
|
const bulk = { diff: 'keep', add: [], remove: new Set() };
|
||||||
|
const norm = (raw) => String(raw || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 60);
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(); } };
|
||||||
|
function done() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(); });
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const diffBtn = (val, label) =>
|
||||||
|
'<button data-bd="' + val + '" class="px-2 h-8 rounded-md text-sm border ' +
|
||||||
|
(String(bulk.diff) === String(val) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700 hover:text-fb-text') + '">' + label + '</button>';
|
||||||
|
const addChips = bulk.add.map((t) => '<span class="inline-flex items-center gap-1 bg-fb-primary/20 text-fb-text border border-fb-primary/40 text-xs px-2 py-0.5 rounded-full">' + esc(t) +
|
||||||
|
'<button data-badd-rm="' + esc(t) + '" aria-label="Remove ' + esc(t) + '" class="text-fb-textDim hover:text-fb-accent leading-none">×</button></span>').join('');
|
||||||
|
const rmChips = [...present].sort((a, b) => a.localeCompare(b)).map((t) =>
|
||||||
|
'<button data-brm="' + esc(t) + '" class="text-xs px-2 py-0.5 rounded-full border ' +
|
||||||
|
(bulk.remove.has(t) ? 'bg-fb-low/30 text-fb-low border-fb-low/40 line-through' : 'bg-gray-800/50 text-fb-textDim border-gray-700 hover:text-fb-text') + '">' + esc(t) + '</button>').join('');
|
||||||
|
const nothing = bulk.diff === 'keep' && !bulk.add.length && !bulk.remove.size;
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="bg-fb-sidebar border border-fb-border/60 rounded-2xl w-full max-w-sm shadow-2xl max-h-[85vh] overflow-y-auto v3-scroll">' +
|
||||||
|
'<div class="p-5 space-y-4">' +
|
||||||
|
'<div class="flex items-center justify-between"><h3 class="text-base font-semibold text-fb-text">Edit ' + fns.length + ' songs</h3>' +
|
||||||
|
'<button data-bulk-x aria-label="Close" class="text-fb-textDim hover:text-fb-text text-xl leading-none">✕</button></div>' +
|
||||||
|
|
||||||
|
'<div><div class="text-xs text-fb-textDim mb-1">Difficulty (for you)</div>' +
|
||||||
|
'<div class="flex flex-wrap gap-1 items-center">' + diffBtn('keep', 'Leave') + diffBtn(1, '1') + diffBtn(2, '2') + diffBtn(3, '3') + diffBtn(4, '4') + diffBtn(5, '5') + diffBtn('clear', 'Clear') + '</div>' +
|
||||||
|
'<div class="text-[11px] text-fb-textDim mt-1">"Leave" keeps each song's own value; a number or Clear applies to all ' + fns.length + '.</div></div>' +
|
||||||
|
|
||||||
|
'<div><div class="text-xs text-fb-textDim mb-1">Add tags to all</div>' +
|
||||||
|
'<div class="flex flex-wrap gap-1 mb-2">' + (addChips || '<span class="text-xs text-fb-textDim">None</span>') + '</div>' +
|
||||||
|
'<div class="flex gap-1"><input type="text" data-badd-input placeholder="Add a tag…" class="flex-1 bg-fb-card border border-fb-border/60 rounded-lg px-3 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary/60">' +
|
||||||
|
'<button data-badd-btn class="text-sm px-3 rounded-lg bg-fb-card/60 border border-fb-border/50 text-fb-text hover:bg-fb-card">Add</button></div></div>' +
|
||||||
|
|
||||||
|
(present.size ? '<div><div class="text-xs text-fb-textDim mb-1">Remove tags (present in the selection)</div><div class="flex flex-wrap gap-1">' + rmChips + '</div></div>' : '') +
|
||||||
|
|
||||||
|
'<div class="flex gap-2 pt-1"><button data-bulk-apply ' + (nothing ? 'disabled' : '') + ' class="flex-1 px-4 py-2 rounded-xl text-sm font-semibold ' +
|
||||||
|
(nothing ? 'bg-fb-card/50 text-fb-textDim cursor-not-allowed' : 'bg-fb-primary hover:bg-fb-primaryHi text-white') + '">Apply</button>' +
|
||||||
|
'<button data-bulk-x class="px-4 py-2 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-xl text-sm text-fb-text">Cancel</button></div>' +
|
||||||
|
'</div></div>';
|
||||||
|
|
||||||
|
overlay.querySelectorAll('[data-bulk-x]').forEach((b) => b.addEventListener('click', done));
|
||||||
|
overlay.querySelectorAll('[data-bd]').forEach((b) => b.addEventListener('click', () => { bulk.diff = b.getAttribute('data-bd'); render(); }));
|
||||||
|
overlay.querySelectorAll('[data-badd-rm]').forEach((b) => b.addEventListener('click', () => { const t = b.getAttribute('data-badd-rm'); const i = bulk.add.indexOf(t); if (i >= 0) bulk.add.splice(i, 1); render(); }));
|
||||||
|
overlay.querySelectorAll('[data-brm]').forEach((b) => b.addEventListener('click', () => { const t = b.getAttribute('data-brm'); if (bulk.remove.has(t)) bulk.remove.delete(t); else bulk.remove.add(t); render(); }));
|
||||||
|
const addInput = overlay.querySelector('[data-badd-input]');
|
||||||
|
const addTag = () => { const t = norm(addInput && addInput.value); if (t && !bulk.add.includes(t)) { bulk.add.push(t); render(); overlay.querySelector('[data-badd-input]')?.focus(); } };
|
||||||
|
overlay.querySelector('[data-badd-btn]')?.addEventListener('click', addTag);
|
||||||
|
addInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } });
|
||||||
|
overlay.querySelector('[data-bulk-apply]')?.addEventListener('click', applyBulk);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyBulk() {
|
||||||
|
const body = { filenames: fns };
|
||||||
|
if (bulk.diff === 'clear') body.set_difficulty = null;
|
||||||
|
else if (bulk.diff !== 'keep') body.set_difficulty = Number(bulk.diff);
|
||||||
|
if (bulk.add.length) body.add_tags = bulk.add;
|
||||||
|
if (bulk.remove.size) body.remove_tags = [...bulk.remove];
|
||||||
|
if (!('set_difficulty' in body) && !body.add_tags && !body.remove_tags) { done(); return; }
|
||||||
|
// jsend returns null on HTTP error / network failure — don't tear down the
|
||||||
|
// selection or reload as if it worked. Surface the failure and let the user retry.
|
||||||
|
const res = await jsend('POST', '/api/songs/user-meta/batch', body);
|
||||||
|
if (!res) {
|
||||||
|
if (window.fbNotify) { try { window.fbNotify.show({ title: 'Bulk edit failed', message: 'Could not save your changes. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
finishBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
overlay.querySelector('[data-badd-input]')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
async function jsend(method, url, body) {
|
async function jsend(method, url, body) {
|
||||||
try { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return r.ok ? r.json() : null; } catch (e) { return null; }
|
try { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return r.ok ? r.json() : null; } catch (e) { return null; }
|
||||||
}
|
}
|
||||||
@@ -1654,6 +1759,234 @@
|
|||||||
function closeDrawer() { document.getElementById('v3-songs-drawer')?.classList.add('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.add('hidden'); updateFilterBadge(); }
|
function closeDrawer() { document.getElementById('v3-songs-drawer')?.classList.add('translate-x-full'); document.getElementById('v3-songs-overlay')?.classList.add('hidden'); updateFilterBadge(); }
|
||||||
function updateFilterBadge() { const b = document.getElementById('v3-songs-filter-count'); if (b) { const n = activeFilterCount(); b.textContent = n; b.classList.toggle('hidden', n === 0); } }
|
function updateFilterBadge() { const b = document.getElementById('v3-songs-filter-count'); if (b) { const n = activeFilterCount(); b.textContent = n; b.classList.toggle('hidden', n === 0); } }
|
||||||
|
|
||||||
|
// ── Song Details drawer (P2) ───────────────────────────────────────────--
|
||||||
|
// Evolves the legacy edit-metadata modal into a v3 slide-in drawer that
|
||||||
|
// unifies catalog identity (writes back into the feedpak FILE), the personal
|
||||||
|
// practice layer (difficulty / tags / notes — local, never shared), the like
|
||||||
|
// heart, and remove-from-library. Reuses the filter-drawer slide idiom
|
||||||
|
// (fixed-right panel + overlay + translate-x-full) but is built on demand and
|
||||||
|
// body-appended, so it opens from any card context. The Charts drawer (P5d)
|
||||||
|
// reuses this pattern.
|
||||||
|
const DIFF_LABELS = ['', 'Very easy', 'Easy', 'Medium', 'Hard', 'Very hard'];
|
||||||
|
let _detailsEls = null; // {overlay, drawer, opener, onKey} while open
|
||||||
|
|
||||||
|
function closeDetails() {
|
||||||
|
if (!_detailsEls) return;
|
||||||
|
const { overlay, drawer, opener, onKey } = _detailsEls;
|
||||||
|
_detailsEls = null;
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
drawer.classList.add('translate-x-full');
|
||||||
|
overlay.classList.add('opacity-0');
|
||||||
|
setTimeout(() => { drawer.remove(); overlay.remove(); }, 200);
|
||||||
|
if (opener && document.body.contains(opener)) { try { opener.focus({ preventScroll: true }); } catch (_) { /* */ } }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetails(song) {
|
||||||
|
if (!song || !song.filename) return;
|
||||||
|
closeDetails();
|
||||||
|
const fn = song.filename;
|
||||||
|
const opener = document.activeElement;
|
||||||
|
// Notes aren't in the grid row payload; difficulty/tags are, but re-read
|
||||||
|
// for authority (another tab / a bulk edit may have changed them).
|
||||||
|
let meta = { user_difficulty: (song.user_difficulty != null ? song.user_difficulty : null), notes: '', tags: song.tags || [] };
|
||||||
|
try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ }
|
||||||
|
let vocab = [];
|
||||||
|
try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ }
|
||||||
|
if (_detailsEls) closeDetails(); // a concurrent open resolved first
|
||||||
|
|
||||||
|
const st = {
|
||||||
|
t: song.title || '', a: song.artist || '', al: song.album || '', y: song.year != null ? String(song.year) : '',
|
||||||
|
diff: (meta.user_difficulty != null ? meta.user_difficulty : null),
|
||||||
|
notes: meta.notes || '', tags: (meta.tags || []).slice(),
|
||||||
|
fav: !!song.favorite, artDataUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'fixed inset-0 bg-black/50 z-[60] opacity-0 transition-opacity duration-200';
|
||||||
|
const drawer = document.createElement('aside');
|
||||||
|
drawer.id = 'v3-song-details-drawer';
|
||||||
|
drawer.className = 'fixed top-0 right-0 h-full w-full sm:w-[26rem] bg-fb-sidebar border-l border-fb-border/50 z-[61] transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll';
|
||||||
|
drawer.setAttribute('role', 'dialog');
|
||||||
|
drawer.setAttribute('aria-modal', 'true');
|
||||||
|
drawer.setAttribute('aria-label', 'Song details');
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.body.appendChild(drawer);
|
||||||
|
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeDetails(); } };
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
overlay.addEventListener('click', closeDetails);
|
||||||
|
_detailsEls = { overlay, drawer, opener, onKey };
|
||||||
|
|
||||||
|
const render = () => { drawer.innerHTML = detailsHtml(song, st, vocab); wireDetails(drawer, song, st, vocab, render); };
|
||||||
|
render();
|
||||||
|
requestAnimationFrame(() => { drawer.classList.remove('translate-x-full'); overlay.classList.remove('opacity-0'); });
|
||||||
|
if (window._trapFocusInModal) { try { window._trapFocusInModal(drawer); } catch (_) { /* */ } }
|
||||||
|
const first = drawer.querySelector('#det-title');
|
||||||
|
if (first) { try { first.focus({ preventScroll: true }); const n = first.value.length; first.setSelectionRange(n, n); } catch (_) { /* */ } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailsHtml(song, st, vocab) {
|
||||||
|
const art = st.artDataUrl || artUrl(song);
|
||||||
|
const diffBtns = [1, 2, 3, 4, 5].map((n) =>
|
||||||
|
'<button data-diff="' + n + '" aria-pressed="' + (st.diff === n ? 'true' : 'false') + '" class="w-8 h-8 rounded-md text-sm font-bold border ' +
|
||||||
|
(st.diff === n ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700 hover:text-fb-text') + '">' + n + '</button>').join('');
|
||||||
|
const applied = new Set(st.tags);
|
||||||
|
const tagChips = st.tags.length
|
||||||
|
? st.tags.map((t) => '<span class="inline-flex items-center gap-1 bg-fb-primary/20 text-fb-text border border-fb-primary/40 text-xs px-2 py-0.5 rounded-full">' + esc(t) +
|
||||||
|
'<button data-tag-rm="' + esc(t) + '" aria-label="Remove tag ' + esc(t) + '" class="text-fb-textDim hover:text-fb-accent leading-none">×</button></span>').join('')
|
||||||
|
: '<span class="text-xs text-fb-textDim">No tags yet</span>';
|
||||||
|
const suggest = (vocab || []).filter((v) => !applied.has(v.tag)).slice(0, 8).map((v) =>
|
||||||
|
'<button data-tag-add="' + esc(v.tag) + '" class="text-[11px] px-2 py-0.5 rounded-full bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(v.tag) + '</button>').join('');
|
||||||
|
const field = (id, label, val) =>
|
||||||
|
'<div><label for="' + id + '" class="text-xs text-fb-textDim mb-1 block">' + label + '</label>' +
|
||||||
|
'<input type="text" id="' + id + '" value="' + esc(val) + '" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>';
|
||||||
|
return '<div class="p-5 space-y-5">' +
|
||||||
|
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Song details</h3>' +
|
||||||
|
'<button data-det-close aria-label="Close" class="text-fb-textDim hover:text-fb-text text-xl leading-none">✕</button></div>' +
|
||||||
|
|
||||||
|
'<div class="flex items-center gap-3">' +
|
||||||
|
'<div class="relative group cursor-pointer shrink-0" data-det-art title="Change album art">' +
|
||||||
|
'<img src="' + esc(art) + '" alt="" class="w-16 h-16 rounded-lg object-cover bg-fb-card" id="det-art-preview" onerror="this.style.visibility=\'hidden\'">' +
|
||||||
|
'<div class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition text-[10px] text-white">Change</div>' +
|
||||||
|
'<input type="file" accept="image/*" id="det-art-file" class="hidden"></div>' +
|
||||||
|
'<div class="min-w-0"><div class="text-sm text-fb-text truncate" title="' + esc(song.title || song.filename) + '">' + esc(song.title || song.filename) + '</div>' +
|
||||||
|
'<div class="mt-1"><button data-det-fav class="text-xs ' + (st.fav ? 'text-fb-accent' : 'text-fb-textDim hover:text-fb-text') + '">' + (st.fav ? '♥ Liked' : '♡ Like') + '</button>' +
|
||||||
|
'<span class="text-[10px] text-fb-textDim ml-1">a like, not a rating</span></div></div></div>' +
|
||||||
|
|
||||||
|
// Identity — writes back into the feedpak FILE
|
||||||
|
'<div class="space-y-3"><div class="flex items-center gap-2"><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Identity</div>' +
|
||||||
|
'<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song's feedpak. Editing them writes back to the file.">From pack</span></div>' +
|
||||||
|
field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + field('det-album', 'Album', st.al) +
|
||||||
|
'<div><label for="det-year" class="text-xs text-fb-textDim mb-1 block">Year</label><input type="text" inputmode="numeric" id="det-year" value="' + esc(st.y) + '" placeholder="e.g. 2024" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div></div>' +
|
||||||
|
|
||||||
|
// Personal practice layer — local, never shared
|
||||||
|
'<div class="space-y-3 pt-1"><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Your practice <span class="normal-case font-normal text-fb-textDim/70">· stays on this device</span></div>' +
|
||||||
|
'<div><div class="flex items-center justify-between mb-1"><label class="text-xs text-fb-textDim">Difficulty (for you)</label>' +
|
||||||
|
'<button data-diff-clear class="text-[11px] text-fb-textDim hover:text-fb-text ' + (st.diff == null ? 'invisible' : '') + '">Clear</button></div>' +
|
||||||
|
'<div class="flex gap-1 items-center">' + diffBtns + '<span class="text-xs text-fb-textDim ml-2">' + esc(st.diff ? DIFF_LABELS[st.diff] : 'Not set') + '</span></div></div>' +
|
||||||
|
'<div><label for="det-tag-input" class="text-xs text-fb-textDim mb-1 block">Tags</label>' +
|
||||||
|
'<div class="flex flex-wrap gap-1 mb-2" data-det-tags>' + tagChips + '</div>' +
|
||||||
|
'<div class="flex gap-1"><input type="text" id="det-tag-input" placeholder="Add a tag…" class="flex-1 bg-fb-card border border-fb-border/60 rounded-lg px-3 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary/60">' +
|
||||||
|
'<button data-tag-addbtn class="text-sm px-3 rounded-lg bg-fb-card/60 border border-fb-border/50 text-fb-text hover:bg-fb-card">Add</button></div>' +
|
||||||
|
(suggest ? '<div class="flex flex-wrap gap-1 mt-2">' + suggest + '</div>' : '') + '</div>' +
|
||||||
|
'<div><label for="det-notes" class="text-xs text-fb-textDim mb-1 block">Notes</label>' +
|
||||||
|
'<textarea id="det-notes" rows="3" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60 resize-none" placeholder="Private practice notes…">' + esc(st.notes) + '</textarea></div></div>' +
|
||||||
|
|
||||||
|
'<div class="flex gap-3 pt-1"><button data-det-save class="flex-1 bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-xl text-sm font-semibold">Save</button>' +
|
||||||
|
'<button data-det-close class="px-4 py-2 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-xl text-sm text-fb-text">Cancel</button></div>' +
|
||||||
|
'<div class="pt-3 border-t border-fb-border/50"><button data-det-remove class="w-full px-4 py-2 bg-fb-low/15 hover:bg-fb-low/30 border border-fb-low/40 rounded-xl text-sm text-fb-low">Remove from library</button></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireDetails(drawer, song, st, vocab, render) {
|
||||||
|
const $ = (s) => drawer.querySelector(s);
|
||||||
|
drawer.querySelectorAll('[data-det-close]').forEach((b) => b.addEventListener('click', closeDetails));
|
||||||
|
// Catalog inputs update state without a re-render (keep caret / focus).
|
||||||
|
const bindInput = (id, key) => { const el = $('#' + id); if (el) el.addEventListener('input', () => { st[key] = el.value; }); };
|
||||||
|
bindInput('det-title', 't'); bindInput('det-artist', 'a'); bindInput('det-album', 'al'); bindInput('det-year', 'y');
|
||||||
|
const notes = $('#det-notes'); if (notes) notes.addEventListener('input', () => { st.notes = notes.value; });
|
||||||
|
|
||||||
|
drawer.querySelectorAll('[data-diff]').forEach((b) => b.addEventListener('click', () => {
|
||||||
|
const n = Number(b.getAttribute('data-diff'));
|
||||||
|
st.diff = (st.diff === n ? null : n); // click the active one to unset
|
||||||
|
render();
|
||||||
|
}));
|
||||||
|
$('[data-diff-clear]')?.addEventListener('click', () => { st.diff = null; render(); });
|
||||||
|
|
||||||
|
drawer.querySelectorAll('[data-tag-rm]').forEach((b) => b.addEventListener('click', () => {
|
||||||
|
const t = b.getAttribute('data-tag-rm'); const i = st.tags.indexOf(t); if (i >= 0) st.tags.splice(i, 1); render();
|
||||||
|
}));
|
||||||
|
drawer.querySelectorAll('[data-tag-add]').forEach((b) => b.addEventListener('click', () => { if (addDetailTag(st, b.getAttribute('data-tag-add'))) render(); }));
|
||||||
|
const addFromInput = () => { const el = $('#det-tag-input'); if (!el) return; if (addDetailTag(st, el.value)) { render(); drawer.querySelector('#det-tag-input')?.focus(); } };
|
||||||
|
$('[data-tag-addbtn]')?.addEventListener('click', addFromInput);
|
||||||
|
$('#det-tag-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addFromInput(); } });
|
||||||
|
|
||||||
|
const artWrap = $('[data-det-art]'); const artFile = $('#det-art-file');
|
||||||
|
if (artWrap && artFile) {
|
||||||
|
artWrap.addEventListener('click', () => artFile.click());
|
||||||
|
artFile.addEventListener('change', () => {
|
||||||
|
const f = artFile.files && artFile.files[0]; if (!f) return;
|
||||||
|
const rd = new FileReader();
|
||||||
|
rd.onload = (e) => { st.artDataUrl = e.target.result; const img = $('#det-art-preview'); if (img) { img.src = st.artDataUrl; img.style.visibility = 'visible'; } };
|
||||||
|
rd.readAsDataURL(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('[data-det-fav]')?.addEventListener('click', async (e) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: song.filename }) });
|
||||||
|
const d = await r.json(); st.fav = !!d.favorite;
|
||||||
|
const btn = e.currentTarget; btn.textContent = st.fav ? '♥ Liked' : '♡ Like';
|
||||||
|
btn.classList.toggle('text-fb-accent', st.fav); btn.classList.toggle('text-fb-textDim', !st.fav);
|
||||||
|
_patchCardFav(song.filename, st.fav);
|
||||||
|
} catch (_) { /* */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st));
|
||||||
|
$('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize + append a tag to the drawer's working set (mirrors the server's
|
||||||
|
// _normalize_tag). Returns whether it was actually added (new + non-blank).
|
||||||
|
function addDetailTag(st, raw) {
|
||||||
|
const t = String(raw || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 60);
|
||||||
|
if (!t || st.tags.includes(t)) return false;
|
||||||
|
st.tags.push(t); st.tags.sort((a, b) => a.localeCompare(b)); return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a rendered card's heart in sync when the drawer toggles the like (the
|
||||||
|
// heart is instant, not part of Save — matches the on-card heart).
|
||||||
|
function _patchCardFav(fn, fav) {
|
||||||
|
const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
|
||||||
|
document.querySelectorAll('[data-fn="' + sel + '"] [data-fav]').forEach((btn) => {
|
||||||
|
btn.textContent = fav ? '♥' : '♡';
|
||||||
|
btn.setAttribute('aria-pressed', fav ? 'true' : 'false');
|
||||||
|
btn.classList.toggle('text-fb-accent', fav); btn.classList.toggle('text-white', !fav);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDetails(song, st) {
|
||||||
|
const fn = song.filename;
|
||||||
|
const catChanged = st.t.trim() !== (song.title || '') || st.a.trim() !== (song.artist || '')
|
||||||
|
|| st.al.trim() !== (song.album || '') || String(st.y).trim() !== (song.year != null ? String(song.year) : '');
|
||||||
|
const ops = [];
|
||||||
|
if (catChanged) ops.push(fetch('/api/song/' + enc(fn) + '/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: st.t.trim(), artist: st.a.trim(), album: st.al.trim(), year: String(st.y).trim() }) }));
|
||||||
|
ops.push(fetch('/api/song/' + enc(fn) + '/user-meta', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_difficulty: st.diff, notes: st.notes, tags: st.tags }) }));
|
||||||
|
if (st.artDataUrl) ops.push(fetch('/api/song/' + enc(fn) + '/art/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: st.artDataUrl }) }));
|
||||||
|
// Fire the writes concurrently but know each outcome — a failed write must not
|
||||||
|
// look like a save. allSettled keeps partial results; treat a rejection OR a
|
||||||
|
// non-ok HTTP response as failure and keep the drawer open so the user can retry.
|
||||||
|
const results = await Promise.allSettled(ops);
|
||||||
|
const failed = results.some((r) => r.status === 'rejected' || (r.value && r.value.ok === false));
|
||||||
|
if (failed) {
|
||||||
|
if (window.fbNotify) { try { window.fbNotify.show({ title: 'Save failed', message: 'Could not save your changes. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeDetails();
|
||||||
|
try { reload(); } catch (_) { /* not on the songs grid */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFromLibrary(song) {
|
||||||
|
const title = song.title || song.filename;
|
||||||
|
let ok;
|
||||||
|
if (window._confirmDialog) {
|
||||||
|
ok = await window._confirmDialog({
|
||||||
|
title: 'Remove from library?',
|
||||||
|
body: '<p class="text-sm text-gray-300">Remove <span class="font-semibold text-white">' + esc(title) + '</span> from your library?</p>' +
|
||||||
|
'<p class="text-xs text-red-400/90 mt-2">This permanently deletes the file from disk. This cannot be undone.</p>',
|
||||||
|
confirmText: 'Remove', cancelText: 'Cancel', danger: true,
|
||||||
|
});
|
||||||
|
} else { ok = window.confirm('Remove "' + title + '" from your library? This deletes the file from disk.'); }
|
||||||
|
if (!ok) return;
|
||||||
|
try { const r = await fetch('/api/song/' + enc(song.filename), { method: 'DELETE' }); if (!r.ok) return; } catch (_) { return; }
|
||||||
|
closeDetails();
|
||||||
|
try { reload(); } catch (_) { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose the opener so the core `edit-metadata` card action opens this drawer
|
||||||
|
// (it falls back to the legacy modal where this isn't defined).
|
||||||
|
window.__fbOpenSongDetails = openDetails;
|
||||||
|
|
||||||
// The host loads the Folder Library plugin's screen.js at startup (defining
|
// The host loads the Folder Library plugin's screen.js at startup (defining
|
||||||
// window.folderLibrary). If it isn't present yet, inject it once; the
|
// window.folderLibrary). If it isn't present yet, inject it once; the
|
||||||
// plugin's IIFEs are idempotent so a redundant evaluation is a no-op. The
|
// plugin's IIFEs are idempotent so a redundant evaluation is a no-op. The
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Tests for the bulk personal-meta endpoint (P2):
|
||||||
|
POST /api/songs/user-meta/batch — the apply-to-all behind the Songs batch bar.
|
||||||
|
DB-only, never touches files. Additive by design: bulk tag edits ADD/REMOVE
|
||||||
|
(never full-replace) and an omitted `set_difficulty` leaves each song's own
|
||||||
|
value alone (mixed-state "leave unchanged")."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def server(tmp_path, monkeypatch, isolate_logging):
|
||||||
|
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
srv = importlib.import_module("server")
|
||||||
|
try:
|
||||||
|
yield srv
|
||||||
|
finally:
|
||||||
|
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
sys.modules.pop("server", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(server):
|
||||||
|
return TestClient(server.app)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(server, *filenames):
|
||||||
|
for fn in filenames:
|
||||||
|
server.meta_db.put(fn, 0, 0, {"title": fn.split(".")[0], "artist": "A"})
|
||||||
|
|
||||||
|
|
||||||
|
def _um(client, fn):
|
||||||
|
return client.get(f"/api/song/{fn}/user-meta").json()
|
||||||
|
|
||||||
|
|
||||||
|
BATCH = "/api/songs/user-meta/batch"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Difficulty across a selection ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_batch_set_difficulty_on_all(client, server):
|
||||||
|
_seed(server, "a.archive", "b.archive", "c.archive")
|
||||||
|
r = client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
|
||||||
|
"set_difficulty": 4})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["updated"] == 2
|
||||||
|
assert _um(client, "a.archive")["user_difficulty"] == 4
|
||||||
|
assert _um(client, "b.archive")["user_difficulty"] == 4
|
||||||
|
# untouched song stays unset
|
||||||
|
assert _um(client, "c.archive")["user_difficulty"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_clear_difficulty_preserves_notes(client, server):
|
||||||
|
_seed(server, "a.archive", "b.archive")
|
||||||
|
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 3, "notes": "keep me"})
|
||||||
|
client.put("/api/song/b.archive/user-meta", json={"user_difficulty": 2})
|
||||||
|
r = client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
|
||||||
|
"set_difficulty": None})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# a still has its notes; b (no notes) is fully cleared
|
||||||
|
a = _um(client, "a.archive")
|
||||||
|
assert a["user_difficulty"] is None and a["notes"] == "keep me"
|
||||||
|
assert _um(client, "b.archive") == {"user_difficulty": None, "notes": "", "tags": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_omitting_set_difficulty_leaves_each_value(client, server):
|
||||||
|
"""Mixed-state: no set_difficulty key → every song keeps its own difficulty
|
||||||
|
while tags still apply."""
|
||||||
|
_seed(server, "a.archive", "b.archive")
|
||||||
|
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 1})
|
||||||
|
client.put("/api/song/b.archive/user-meta", json={"user_difficulty": 5})
|
||||||
|
client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
|
||||||
|
"add_tags": ["gig"]})
|
||||||
|
assert _um(client, "a.archive")["user_difficulty"] == 1
|
||||||
|
assert _um(client, "b.archive")["user_difficulty"] == 5
|
||||||
|
assert _um(client, "a.archive")["tags"] == ["gig"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tags: additive, never full-replace ───────────────────────────────────────
|
||||||
|
|
||||||
|
def test_batch_add_tags_does_not_clobber_existing(client, server):
|
||||||
|
_seed(server, "a.archive", "b.archive")
|
||||||
|
client.put("/api/song/a.archive/user-meta", json={"tags": ["existing"]})
|
||||||
|
client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
|
||||||
|
"add_tags": ["Warm-ups", "warm-ups"]}) # dupe-folds
|
||||||
|
assert _um(client, "a.archive")["tags"] == ["existing", "warm-ups"]
|
||||||
|
assert _um(client, "b.archive")["tags"] == ["warm-ups"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_remove_tags(client, server):
|
||||||
|
_seed(server, "a.archive", "b.archive")
|
||||||
|
client.put("/api/song/a.archive/user-meta", json={"tags": ["gig", "riffs"]})
|
||||||
|
client.put("/api/song/b.archive/user-meta", json={"tags": ["gig"]})
|
||||||
|
client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
|
||||||
|
"remove_tags": ["gig"]})
|
||||||
|
assert _um(client, "a.archive")["tags"] == ["riffs"]
|
||||||
|
assert _um(client, "b.archive")["tags"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_wins_over_remove_conflict(client, server):
|
||||||
|
_seed(server, "a.archive")
|
||||||
|
client.post(BATCH, json={"filenames": ["a.archive"],
|
||||||
|
"add_tags": ["keep"], "remove_tags": ["keep"]})
|
||||||
|
assert _um(client, "a.archive")["tags"] == ["keep"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_returned_tag_vocabulary_refreshes(client, server):
|
||||||
|
_seed(server, "a.archive", "b.archive")
|
||||||
|
body = client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
|
||||||
|
"add_tags": ["warmups"]}).json()
|
||||||
|
assert {"tag": "warmups", "count": 2} in body["tags"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_empty_filenames_is_400(client, server):
|
||||||
|
assert client.post(BATCH, json={"filenames": [], "set_difficulty": 3}).status_code == 400
|
||||||
|
assert client.post(BATCH, json={"set_difficulty": 3}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_to_apply_is_400(client, server):
|
||||||
|
_seed(server, "a.archive")
|
||||||
|
assert client.post(BATCH, json={"filenames": ["a.archive"]}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [0, 6, 2.5, "x", True])
|
||||||
|
def test_bad_set_difficulty_is_400(client, server, bad):
|
||||||
|
_seed(server, "a.archive")
|
||||||
|
assert client.post(BATCH, json={"filenames": ["a.archive"],
|
||||||
|
"set_difficulty": bad}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_tags_must_be_arrays(client, server):
|
||||||
|
_seed(server, "a.archive")
|
||||||
|
assert client.post(BATCH, json={"filenames": ["a.archive"],
|
||||||
|
"add_tags": "gig"}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_survives_rescan(client, server):
|
||||||
|
_seed(server, "a.archive")
|
||||||
|
client.post(BATCH, json={"filenames": ["a.archive"],
|
||||||
|
"set_difficulty": 3, "add_tags": ["t"]})
|
||||||
|
server.meta_db.put("a.archive", 1, 1, {"title": "a", "artist": "A"})
|
||||||
|
m = _um(client, "a.archive")
|
||||||
|
assert m["user_difficulty"] == 3 and m["tags"] == ["t"]
|
||||||
Reference in New Issue
Block a user