feat(library): 3-tab Fix-metadata popup — Details / Cover art / Match (slice 4)

Turns the thin single-song fix-match modal into the Plex-style metadata
editor reached from a card's "Fix metadata…" menu:

- Details tab: type + lock the displayed title/artist/album/year. Values
  ride the reversible override store (GET/PUT /api/song/{fn}/overrides); each
  field sits on its pack value (Yours/Pack provenance + revert-to-pack), a lock
  pins it against auto-match, and Save repaints the grid via library:changed
  (slice-3 overlay). This is the real tool for the blank-artist city-pop pile
  MusicBrainz can't surface — you just type the right title.
- Cover art tab: hands off to the shared image picker (its own modal); the
  pick refreshes the thumbnail everywhere.
- Match tab: the existing MusicBrainz search + candidate/pick flow, refactored
  into shared body/footer helpers (the queue-review flow is untouched).

Backend: GET /overrides now also returns the pack baseline so the Details tab
can pre-fill + show provenance. tailwind.min.css regenerated (build-tailwind.sh)
for the popup's new utility classes.

Identify-by-audio (AcoustID) is deferred: it lives in unmerged PR #759, off
main — the Match tab gains the button once #759 lands and this branch rebases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-07-05 21:06:59 +02:00
committed by byrongamatos
co-authored by Claude Opus 4.8
parent c61aa008ba
commit 9ace7a3401
5 changed files with 274 additions and 52 deletions
+18 -3
View File
@@ -1266,6 +1266,17 @@ class MetadataDB:
out.setdefault(fn, {})[field] = {"value": value, "locked": bool(locked)} out.setdefault(fn, {})[field] = {"value": value, "locked": bool(locked)}
return out return out
def pack_fields(self, filename: str) -> dict:
"""The stored (pack) values for the overridable catalog fields — the
Fix-metadata popup shows these behind each override as the 'revert to
pack' reference + the Yours/Pack provenance. Empty strings for a missing
song so the popup always has a value to render."""
keys = ("title", "artist", "album", "year", "genre")
row = self.conn.execute(
"SELECT title, artist, album, year, genre FROM songs WHERE filename = ?",
(filename,)).fetchone()
return {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
def set_song_tags(self, filename: str, tags) -> list: def set_song_tags(self, filename: str, tags) -> list:
"""Replace ALL of a song's tags with the given set (each normalized; """Replace ALL of a song's tags with the given set (each normalized;
blanks + case-dupes dropped). Full-replace so the whole personal-meta blanks + case-dupes dropped). Full-replace so the whole personal-meta
@@ -9203,9 +9214,13 @@ _OVERRIDE_FIELDS = frozenset({"title", "artist", "album", "year", "genre"})
@app.get("/api/song/{filename:path}/overrides") @app.get("/api/song/{filename:path}/overrides")
def get_song_overrides(filename: str): def get_song_overrides(filename: str):
"""Per-field metadata overrides + locks for one song (Fix-metadata popup): """Per-field metadata overrides + locks for one song (Fix-metadata popup):
{"overrides": {field: {"value": str|null, "locked": bool}}}.""" {"overrides": {field: {"value": str|null, "locked": bool}},
return {"overrides": meta_db.get_song_overrides( "pack": {field: str}}. `pack` is the stored value each override sits on top
meta_db._canonical_song_filename(filename))} of the popup's Details tab renders it as the revert-to-pack reference and
the Yours/Pack provenance."""
key = meta_db._canonical_song_filename(filename)
return {"overrides": meta_db.get_song_overrides(key),
"pack": meta_db.pack_fields(key)}
@app.put("/api/song/{filename:path}/overrides") @app.put("/api/song/{filename:path}/overrides")
+1 -1
View File
File diff suppressed because one or more lines are too long
+241 -47
View File
@@ -131,7 +131,8 @@
let _queue = []; let _queue = [];
let _idx = 0; let _idx = 0;
let _lastFocus = null; let _lastFocus = null;
let _single = false; // Fix-match mode: one song, no queue navigation let _single = false; // Fix-metadata mode: one song, no queue navigation
let _tab = 'details'; // active tab in single mode: details | cover | match
function ensureModal() { function ensureModal() {
let m = document.getElementById('v3-match-modal'); let m = document.getElementById('v3-match-modal');
@@ -180,14 +181,17 @@
loadQueue(); loadQueue();
} }
// Fix-match (R2): the same modal for ONE song — the escape hatch for a // Fix metadata (R2 → popup slice 4): the tabbed per-song editor for ONE
// wrong (or missing) match, reachable from the card's ⋮ / right-click // song, reachable from the card's ⋮ / right-click menu. Three tabs —
// menu. No stored candidates are required: the search panel opens // Details (type + lock the displayed fields), Cover art (launch the picker),
// pre-filled, and a pick pins the match exactly like the review flow. // Match (pin a MusicBrainz identity). Opens on Details: for the obscure /
// blank-artist packs this exists to fix, typing the right title is the tool,
// and Match is the escape hatch when text search can surface a record.
function fixMatch(song) { function fixMatch(song) {
if (!song || !song.filename) return; if (!song || !song.filename) return;
_lastFocus = document.activeElement; _lastFocus = document.activeElement;
_single = true; _single = true;
_tab = 'details';
_queue = [{ _queue = [{
filename: song.filename, title: song.title || song.filename, filename: song.filename, title: song.title || song.filename,
artist: song.artist || '', album: song.album || '', artist: song.artist || '', album: song.album || '',
@@ -198,10 +202,7 @@
const m = ensureModal(); const m = ensureModal();
m.classList.remove('hidden'); m.classList.remove('hidden');
document.getElementById('v3-match-overlay')?.classList.remove('hidden'); document.getElementById('v3-match-overlay')?.classList.remove('hidden');
renderCurrent(); renderCurrent(); // _single ⇒ renderTabbed()
// Straight to the point: the search panel is why this mode exists.
document.getElementById('v3-match-panel')
?.querySelector('[data-mr-search-toggle]')?.click();
} }
function closeModal() { function closeModal() {
@@ -214,7 +215,7 @@
} }
function nav(step) { function nav(step) {
if (!_queue.length) return; if (_single || !_queue.length) return; // single mode has no queue to page
_idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1); _idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1);
renderCurrent(); renderCurrent();
} }
@@ -305,17 +306,16 @@
'</button>'; '</button>';
} }
function renderCurrent() { // The middle content shared by the queue-review render and the single-song
const panel = document.getElementById('v3-match-panel'); // popup's Match tab: the chart being matched, its candidate list, and the
if (!panel) return; // "search instead" panel. Header + footer differ per surface. When there
if (!_queue.length) { renderDone(); return; } // are no stored candidates (a manual fix), the search panel opens pre-filled
_idx = Math.min(_idx, _queue.length - 1); // — searching IS the point in that case.
const song = _queue[_idx]; function reviewBodyHtml(song) {
if (song._sel == null) song._sel = 0;
const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · '); const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · ');
const noCands = !(song.candidates || []).length;
panel.innerHTML = headerHtml() + const prefill = noCands ? [song.artist, song.title].filter(Boolean).join(' ') : '';
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll">' + return '<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
// The chart being matched // The chart being matched
'<div class="flex items-start gap-3">' + '<div class="flex items-start gap-3">' +
'<img data-mr-art src="' + esc(artUrl(song)) + '" alt="" loading="lazy" class="w-16 h-16 rounded-lg object-cover bg-fb-card shrink-0">' + '<img data-mr-art src="' + esc(artUrl(song)) + '" alt="" loading="lazy" class="w-16 h-16 rounded-lg object-cover bg-fb-card shrink-0">' +
@@ -325,25 +325,27 @@
'<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' + '<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
missingChips(song) + missingChips(song) +
'</div></div>' + '</div></div>' +
// Candidates (Fix-match mode arrives with none — the search panel (noCands
// is its whole point, so the empty header is suppressed). ? ''
((song.candidates || []).length : '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
? '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' + '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' +
song.candidates.map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') + song.candidates.map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
'</div>' '</div>') +
: '') + // Search panel — hidden when candidates exist (a "Search instead…"
// Search-instead panel // toggle reveals it); open + pre-filled when there are none.
'<div data-mr-search-panel class="hidden space-y-2">' + '<div data-mr-search-panel class="' + (noCands ? '' : 'hidden') + ' space-y-2">' +
'<div class="flex gap-2">' + '<div class="flex gap-2">' +
'<input data-mr-search-input type="text" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist Title">' + '<input data-mr-search-input type="text" value="' + esc(prefill) + '" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist Title">' +
'<button data-mr-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button></div>' + '<button data-mr-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button></div>' +
'<div data-mr-search-results class="space-y-1"></div></div>' + '<div data-mr-search-results class="space-y-1"></div></div>' +
'</div>' + '</div>';
// Footer actions. Fix-match mode drops Skip (no queue) and the }
// accept button when there is nothing to accept — search-result
// rows carry their own pick action. // Footer actions. Single mode drops Skip / Not-a-match (no queue); the
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' + // accept button only shows when there is a stored candidate to accept —
// search-result rows carry their own pick action.
function footerHtml(song) {
return '<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
'<div class="flex items-center gap-3">' + '<div class="flex items-center gap-3">' +
(_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') + (_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') +
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' + '<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
@@ -352,57 +354,249 @@
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') + (_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') +
((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') + ((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') +
'</div></div>'; '</div></div>';
wireCurrent(panel, song);
} }
function wireCurrent(panel, song) { function renderCurrent() {
const panel = document.getElementById('v3-match-panel');
if (!panel) return;
if (_single) { renderTabbed(); return; } // popup: the tabbed shell
if (!_queue.length) { renderDone(); return; }
_idx = Math.min(_idx, _queue.length - 1);
const song = _queue[_idx];
if (song._sel == null) song._sel = 0;
panel.innerHTML = headerHtml() + reviewBodyHtml(song) + footerHtml(song);
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal); panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1)); panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1));
panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1)); panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1));
panel.querySelector('[data-mr-skip]')?.addEventListener('click', () => nav(1)); panel.querySelector('[data-mr-skip]')?.addEventListener('click', () => nav(1));
wireReviewBody(panel, song);
}
// Candidate / search / accept-reject wiring shared by the queue render and
// the popup's Match tab. Scoped to `root` so the tabbed shell can wire just
// its tab body — its close + tab chrome live in the header (wired once by
// renderTabbed), so wiring here must NOT touch close/prev/next/skip.
function wireReviewBody(root, song) {
// Art failure → flag + re-render once so the "cover art" chip shows. // Art failure → flag + re-render once so the "cover art" chip shows.
const img = panel.querySelector('[data-mr-art]'); const img = root.querySelector('[data-mr-art]');
if (img) img.onerror = () => { if (img) img.onerror = () => {
img.style.visibility = 'hidden'; img.style.visibility = 'hidden';
if (!song._artMissing) { song._artMissing = true; renderCurrent(); } if (!song._artMissing) { song._artMissing = true; renderCurrent(); }
}; };
panel.querySelectorAll('[data-mr-cand]').forEach((btn) => { root.querySelectorAll('[data-mr-cand]').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
song._sel = Number(btn.getAttribute('data-mr-cand')); song._sel = Number(btn.getAttribute('data-mr-cand'));
renderCurrent(); renderCurrent();
}); });
}); });
panel.querySelector('[data-mr-accept]')?.addEventListener('click', async () => { root.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
const cand = (song.candidates || [])[song._sel || 0]; const cand = (song.candidates || [])[song._sel || 0];
if (!cand) return; if (!cand) return;
await post('/api/enrichment/review/' + enc(song.filename) + '/accept', await post('/api/enrichment/review/' + enc(song.filename) + '/accept',
{ recording_id: cand.recording_id }); { recording_id: cand.recording_id });
settle(song); settle(song);
}); });
panel.querySelector('[data-mr-reject]')?.addEventListener('click', async () => { root.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
await post('/api/enrichment/review/' + enc(song.filename) + '/reject'); await post('/api/enrichment/review/' + enc(song.filename) + '/reject');
settle(song); settle(song);
}); });
const sp = panel.querySelector('[data-mr-search-panel]'); const sp = root.querySelector('[data-mr-search-panel]');
const input = panel.querySelector('[data-mr-search-input]'); const input = root.querySelector('[data-mr-search-input]');
panel.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => { root.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
sp?.classList.toggle('hidden'); sp?.classList.toggle('hidden');
if (sp && !sp.classList.contains('hidden') && input && !input.value) { if (sp && !sp.classList.contains('hidden') && input && !input.value) {
input.value = [song.artist, song.title].filter(Boolean).join(' '); input.value = [song.artist, song.title].filter(Boolean).join(' ');
input.focus(); input.focus();
} }
}); });
const go = () => runSearch(panel, song); const go = () => runSearch(root, song);
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go); root.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
panel.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(panel, song)); panel.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(panel, song));
} }
// ── Tabbed single-song popup (slice 4) ───────────────────────────────────
// Header + tab bar, then the active tab's body. The queue-review render
// above is untouched; this is only reached in _single mode.
function tabHeaderHtml() {
const tab = (id, label) =>
'<button data-mr-tab="' + id + '" role="tab" aria-selected="' + (_tab === id ? 'true' : 'false') + '" ' +
'class="px-3 py-2 text-sm -mb-px border-b-2 ' + (_tab === id
? 'border-fb-primary text-fb-text'
: 'border-transparent text-fb-textDim hover:text-fb-text') + '">' + label + '</button>';
return '<div class="flex items-center justify-between gap-3 px-5 pt-4 shrink-0">' +
'<h3 class="text-lg font-semibold text-fb-text">Fix metadata</h3>' +
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
'<div role="tablist" class="flex gap-1 px-4 border-b border-fb-border/40 shrink-0">' +
tab('details', 'Details') + tab('cover', 'Cover art') + tab('match', 'Match') + '</div>';
}
function renderTabbed() {
const panel = document.getElementById('v3-match-panel');
if (!panel) return;
const song = _queue[0];
if (!song) { closeModal(); return; }
panel.innerHTML = tabHeaderHtml() +
'<div data-mr-tabbody role="tabpanel" class="flex flex-col min-h-0 flex-1 overflow-hidden"></div>';
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
panel.querySelectorAll('[data-mr-tab]').forEach((b) => b.addEventListener('click', () => {
const t = b.getAttribute('data-mr-tab');
if (t !== _tab) { _tab = t; renderTabbed(); }
}));
const body = panel.querySelector('[data-mr-tabbody]');
if (_tab === 'details') { renderDetailsTab(body, song); }
else if (_tab === 'cover') { renderCoverTab(body, song); }
else {
body.innerHTML = reviewBodyHtml(song) + footerHtml(song);
wireReviewBody(body, song);
if (!(song.candidates || []).length) body.querySelector('[data-mr-search-input]')?.focus();
}
}
// Details tab: type + lock the DISPLAYED fields. Values ride the reversible
// override store (GET/PUT /api/song/{fn}/overrides) — never the pack file.
// Each field sits on its pack value: editing above the pack makes it an
// override ("Yours"); a lock pins it so an auto-match can't recanonicalize
// it; revert (↺) drops back to the pack value.
const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year']];
async function renderDetailsTab(body, song) {
body.innerHTML = '<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
let data = { overrides: {}, pack: {} };
try {
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides');
if (r.ok) data = await r.json();
} catch (_) { /* offline — fall back to the empty baseline */ }
if (!_single || _tab !== 'details') return; // tab/modal changed while fetching
const pack = data.pack || {};
const ov = data.overrides || {};
const st = {};
for (const [f] of DETAIL_FIELDS) {
const o = ov[f] || {};
st[f] = {
pack: pack[f] || '',
value: (o.value != null ? o.value : (pack[f] || '')),
locked: !!o.locked,
};
}
song._detailsState = st;
paintDetails(body, song);
}
function paintDetails(body, song) {
const st = song._detailsState;
const row = ([f, label]) => {
const s = st[f];
const isYours = !!(String(s.value).trim() && String(s.value).trim() !== String(s.pack).trim());
return '<div class="space-y-1">' +
'<div class="flex items-center justify-between">' +
'<label class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">' + esc(label) + '</label>' +
(isYours
? '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary">Yours</span>'
: '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-card text-fb-textDim">Pack</span>') +
'</div>' +
'<div class="flex items-center gap-2">' +
'<input data-df-input="' + f + '" type="text" value="' + esc(s.value) + '" ' +
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary" ' +
'placeholder="' + esc(s.pack || label) + '">' +
'<button data-df-lock="' + f + '" type="button" aria-pressed="' + (s.locked ? 'true' : 'false') + '" ' +
'title="' + (s.locked ? 'Locked — auto-match wont change this field' : 'Lock this field against auto-match') + '" ' +
'class="px-2 py-1.5 rounded-md border ' + (s.locked ? 'border-fb-primary text-fb-primary bg-fb-primary/10' : 'border-fb-border/50 text-fb-textDim hover:text-fb-text') + '">' +
(s.locked ? '🔒' : '🔓') + '</button>' +
'<button data-df-revert="' + f + '" type="button" title="Revert to the pack value" ' +
'class="px-2 py-1.5 rounded-md border border-fb-border/50 text-fb-textDim hover:text-fb-text">↺</button>' +
'</div></div>';
};
body.innerHTML =
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
'<div class="flex items-start gap-3">' +
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-14 h-14 rounded-lg object-cover bg-fb-card shrink-0">' +
'<p class="text-xs text-fb-textDim pt-1">Your edits show in the library right away and are never written to the song files. Use <span class="text-fb-text">Match</span> to pull info from MusicBrainz, or lock a field to keep it.</p>' +
'</div>' +
DETAIL_FIELDS.map(row).join('') +
'<p data-df-status class="text-xs h-4"></p>' +
'</div>' +
'<div class="flex items-center justify-end gap-2 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
'<button data-df-save class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Save</button>' +
'</div>';
body.querySelectorAll('[data-df-input]').forEach((inp) => {
inp.addEventListener('input', () => { st[inp.getAttribute('data-df-input')].value = inp.value; });
});
body.querySelectorAll('[data-df-lock]').forEach((b) => {
b.addEventListener('click', () => { const f = b.getAttribute('data-df-lock'); st[f].locked = !st[f].locked; paintDetails(body, song); });
});
body.querySelectorAll('[data-df-revert]').forEach((b) => {
b.addEventListener('click', () => { const f = b.getAttribute('data-df-revert'); st[f].value = st[f].pack || ''; st[f].locked = false; paintDetails(body, song); });
});
body.querySelector('[data-df-save]')?.addEventListener('click', () => saveDetails(body, song));
}
async function saveDetails(body, song) {
const st = song._detailsState;
const overrides = {};
for (const [f] of DETAIL_FIELDS) {
const v = String(st[f].value || '').trim();
const p = String(st[f].pack || '').trim();
// Only store a value that differs from the pack; equal / blank clears
// the override (the server drops a value-less, unlocked row).
overrides[f] = { value: (v && v !== p) ? v : null, locked: !!st[f].locked };
}
const status = body.querySelector('[data-df-status]');
const saveBtn = body.querySelector('[data-df-save]');
if (saveBtn) saveBtn.disabled = true;
let ok = false;
try {
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overrides }),
});
ok = r.ok;
} catch (_) { ok = false; }
if (saveBtn) saveBtn.disabled = false;
if (!ok) {
if (status) { status.className = 'text-xs h-4 text-fb-accent'; status.textContent = 'Could not save — try again.'; }
return;
}
// Reflect the new effective values on the in-memory song (keeps the Match
// tab header consistent) and repaint the library so the card shows them —
// the grid reloads on library:changed (slice 3 overlay does the rest).
for (const [f] of DETAIL_FIELDS) {
const v = String(st[f].value || '').trim(); const p = String(st[f].pack || '').trim();
song[f] = (v && v !== p) ? v : (st[f].pack || '');
}
try { window.feedBack?.emit('library:changed', { reason: 'override' }); } catch (_) { }
if (status) { status.className = 'text-xs h-4 text-fb-good'; status.textContent = 'Saved.'; }
}
// Cover-art tab: the current art + a button that hands off to the shared
// cover picker (image-picker.js, its own z-[200] modal). A pick there
// refreshes every <img> for this song's art — including this thumbnail — so
// there's nothing to wire back.
function renderCoverTab(body, song) {
body.innerHTML =
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0 flex flex-col items-center text-center">' +
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-40 h-40 rounded-xl object-cover bg-fb-card">' +
'<p class="text-sm text-fb-textDim max-w-sm">Choose from the Cover Art Archive, paste an image link, or upload your own. Your song files are never changed.</p>' +
'<button data-cover-open class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Choose cover art…</button>' +
'</div>';
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
}
});
}
// Silent-on-success: the chart just leaves the queue and the next one // Silent-on-success: the chart just leaves the queue and the next one
// renders; the last one renders the done state. No toasts, no sounds. // renders; the last one renders the done state. No toasts, no sounds.
function settle(song) { function settle(song) {
if (_single) { closeModal(); return; } // Fix-match: done means done if (_single) {
// Popup Match tab: a pinned identity can change the art/canon — nudge
// the grid to repaint (silent otherwise, like the queue flow).
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
closeModal();
return;
}
const i = _queue.indexOf(song); const i = _queue.indexOf(song);
if (i >= 0) _queue.splice(i, 1); if (i >= 0) _queue.splice(i, 1);
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1); if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
+1 -1
View File
@@ -949,7 +949,7 @@
// address the local DB / filesystem). Both openers (⋮ and // address the local DB / filesystem). Both openers (⋮ and
// right-click) share this list, so parity is structural. // right-click) share this list, so parity is structural.
...(state.provider === 'local' && song.filename ? [ ...(state.provider === 'local' && song.filename ? [
{ id: '__fixmatch', label: 'Fix match…' }, { id: '__fixmatch', label: 'Fix metadata…' },
{ id: '__cover', label: 'Change cover…' }, { id: '__cover', label: 'Change cover…' },
{ id: '__refreshmeta', label: 'Refresh metadata' }, { id: '__refreshmeta', label: 'Refresh metadata' },
{ id: '__getinfo', label: 'Get info…' }, { id: '__getinfo', label: 'Get info…' },
+13
View File
@@ -117,6 +117,19 @@ def test_api_put_get_and_clear(client, server):
assert "artist" not in client.get("/api/song/a.archive/overrides").json()["overrides"] assert "artist" not in client.get("/api/song/a.archive/overrides").json()["overrides"]
def test_api_get_returns_pack_values(client, server):
_put(server, "a.archive", title="Pack Title", artist="Pack Artist",
album="Pack Album", year="1988")
server.meta_db.set_song_override("a.archive", "title", value="Fixed Title")
body = client.get("/api/song/a.archive/overrides").json()
# the override rides "overrides"; the pack baseline rides "pack" (all 5 fields)
assert body["overrides"]["title"]["value"] == "Fixed Title"
assert body["pack"] == {"title": "Pack Title", "artist": "Pack Artist",
"album": "Pack Album", "year": "1988", "genre": ""}
# a song with no row still gets an all-empty pack (popup always has values)
assert client.get("/api/song/ghost.archive/overrides").json()["pack"]["title"] == ""
def test_api_rejects_unknown_field(client, server): def test_api_rejects_unknown_field(client, server):
_put(server, "a.archive") _put(server, "a.archive")
r = client.put("/api/song/a.archive/overrides", r = client.put("/api/song/a.archive/overrides",