From f96312a8107c8f668073ca472611873c23f07a17 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sat, 4 Jul 2026 21:39:43 -0500 Subject: [PATCH 1/2] feat(library): "Write to file" in the Fix-metadata popup's Details tab Completes the confirmed edit model: Save keeps edits as a reversible display overlay (files untouched); "Write to file" bakes the shown title/artist/album/ year into the pack itself via the existing POST /api/song/{fn}/meta (writes the manifest, re-stats, coalesces a rescan). On a real file write the now-redundant override values are cleared (locks kept) and the tab re-renders, so the fields read from the file as "Pack". Loose-folder / unwritable packs fall back to a DB-only update and say so (may revert on a full rescan). Secondary button next to Save; touches only the four file-safe fields, the rest of the pack verbatim. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/v3/match-review.js | 64 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/static/v3/match-review.js b/static/v3/match-review.js index f04e612e..20cef62b 100644 --- a/static/v3/match-review.js +++ b/static/v3/match-review.js @@ -514,12 +514,13 @@ '
' + '
' + '' + - '

Your edits show in the library right away and are never written to the song files. Use Match to pull info from MusicBrainz, or lock a field to keep it.

' + + '

Save keeps edits as a reversible library overlay — the song files aren\'t touched. Write to file bakes them into the pack itself. Lock a field to keep an auto-match from changing it.

' + '
' + DETAIL_FIELDS.map(row).join('') + - '

' + + '

' + '
' + - '
' + + '
' + + '' + '' + '
'; body.querySelectorAll('[data-df-input]').forEach((inp) => { @@ -532,6 +533,7 @@ 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)); + body.querySelector('[data-df-write]')?.addEventListener('click', () => writeToFile(body, song)); } async function saveDetails(body, song) { @@ -572,6 +574,62 @@ if (status) { status.className = 'text-xs h-4 text-fb-good'; status.textContent = 'Saved.'; } } + // "Write to file" — bake the shown title/artist/album/year INTO the pack + // itself (the one action here that touches the file), via the existing + // POST /api/song/{fn}/meta (writes the manifest, re-stats, coalesces a + // rescan). On a real file write the display overrides for those fields are + // now redundant, so clear their VALUES (keeping any locks) and re-render — + // the field then reads from the file as "Pack". Loose-folder / unwritable + // packs fall back to a DB-only update: we say so and keep the overlay. + async function writeToFile(body, song) { + const st = song._detailsState; + const fields = {}; + for (const [f] of DETAIL_FIELDS) fields[f] = String(st[f].value || '').trim(); + const status = body.querySelector('[data-df-status]'); + const writeBtn = body.querySelector('[data-df-write]'); + const saveBtn = body.querySelector('[data-df-save]'); + if (writeBtn) writeBtn.disabled = true; + if (saveBtn) saveBtn.disabled = true; + if (status) { status.className = 'text-xs leading-relaxed text-fb-textDim'; status.textContent = 'Writing to the song file…'; } + let ok = false, persisted = false; + try { + const r = await fetch('/api/song/' + enc(song.filename) + '/meta', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }); + ok = r.ok; + const j = await r.json().catch(() => ({})); + persisted = !!(j && j.persisted); + } catch (_) { ok = false; } + if (writeBtn) writeBtn.disabled = false; + if (saveBtn) saveBtn.disabled = false; + if (!ok) { + if (status) { status.className = 'text-xs leading-relaxed text-fb-accent'; status.textContent = 'Could not write to the file — try again.'; } + return; + } + // Keep the in-memory song + grid in step with what was written. + for (const [f] of DETAIL_FIELDS) song[f] = fields[f]; + try { window.feedBack?.emit('library:changed', { reason: 'write' }); } catch (_) { } + if (persisted) { + const clear = {}; + for (const [f] of DETAIL_FIELDS) clear[f] = { value: null, locked: !!st[f].locked }; + try { + await fetch('/api/song/' + enc(song.filename) + '/overrides', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overrides: clear }), + }); + } catch (_) { /* the file write still succeeded; the overlay just lingers */ } + await renderDetailsTab(body, song); // re-fetch: pack now = written values, overrides cleared + const s2 = body.querySelector('[data-df-status]'); + if (s2) { s2.className = 'text-xs leading-relaxed text-fb-good'; s2.textContent = 'Written to the song file.'; } + } else if (status) { + status.className = 'text-xs leading-relaxed text-fb-textDim'; + status.textContent = 'Saved to the library — this pack’s file couldn’t be written, so it may revert on a full rescan.'; + } + } + // 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 for this song's art — including this thumbnail — so From 0735d44f394f916ed3292d3bd66ae580ea9a166e Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 5 Jul 2026 12:57:21 +0200 Subject: [PATCH 2/2] fix(library): mirror server year coercion in Write-to-file grid sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_song_meta coerces a non-numeric/empty year to "" before persisting, but writeToFile optimistically set song.year to the raw typed text — so the library card flashed e.g. "abcd" until the next natural refresh. Apply the same integer coercion client-side so the in-memory song matches what was written. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/v3/match-review.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/static/v3/match-review.js b/static/v3/match-review.js index 20cef62b..edbc8e05 100644 --- a/static/v3/match-review.js +++ b/static/v3/match-review.js @@ -608,8 +608,16 @@ if (status) { status.className = 'text-xs leading-relaxed text-fb-accent'; status.textContent = 'Could not write to the file — try again.'; } return; } - // Keep the in-memory song + grid in step with what was written. - for (const [f] of DETAIL_FIELDS) song[f] = fields[f]; + // Keep the in-memory song + grid in step with what was *persisted*, not + // the raw input: the server coerces a non-numeric/empty year to "" (see + // update_song_meta), so mirror that here or the grid card flashes the + // typed text (e.g. "abcd") until the next natural refresh corrects it. + const applied = { ...fields }; + if ('year' in applied) { + const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0; + applied.year = yr ? String(yr) : ''; + } + for (const [f] of DETAIL_FIELDS) song[f] = applied[f]; try { window.feedBack?.emit('library:changed', { reason: 'write' }); } catch (_) { } if (persisted) { const clear = {};