mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-16 09:50:08 +00:00
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:
committed by
byrongamatos
co-authored by
Claude Opus 4.8
parent
c61aa008ba
commit
9ace7a3401
@@ -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")
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+241
-47
@@ -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 won’t 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
@@ -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…' },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user