From 007c43553a80fdf47196e70bc69c389fcabb0e7b Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sat, 4 Jul 2026 22:22:11 -0500 Subject: [PATCH] feat(v3 library): genre field in the Fix-metadata popup Details tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Genre as a fifth Details field (edit / lock / revert / Yours-Pack provenance), backed by the existing override store. To make it actually useful, the genre FILTER and FACET now resolve the per-song override (effective genre = override else scanned pack genre) — guarded so the common no-override case stays on the plain indexed column — so a corrected/added genre is immediately browsable. Genre stays a library-only overlay: it is NOT a write-to-file field (split WRITE_FIELDS = the four file-safe fields from the five DETAIL_FIELDS), so Write to file leaves the genre override in place and the copy says so. The Match→Details bridge also carries a candidate's first genre. Tests: effective-genre facet + filter, and that a value-less lock doesn't invent an effective genre. Co-Authored-By: Claude Opus 4.8 (1M context) --- server.py | 28 +++++++++++++++++++++++++--- static/v3/match-review.js | 15 ++++++++++----- tests/test_field_overrides.py | 24 ++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/server.py b/server.py index ba4650f..0c99ccc 100644 --- a/server.py +++ b/server.py @@ -1277,6 +1277,27 @@ class MetadataDB: (filename,)).fetchone() return {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)} + # Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the + # scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias) + # so a corrected genre is browsable — the correlated subquery is used ONLY + # when genre overrides actually exist; the common case stays on the plain + # indexed `genre` column. Genre stays a library-only overlay (it isn't a + # write-to-file field), so it never touches the pack. + _EFFECTIVE_GENRE_SQL = ( + "COALESCE((SELECT o.value FROM song_field_override o " + "WHERE o.filename = songs.filename AND o.field = 'genre' " + "AND o.value IS NOT NULL AND o.value != ''), genre)" + ) + + def _has_genre_overrides(self) -> bool: + return self.conn.execute( + "SELECT 1 FROM song_field_override WHERE field = 'genre' " + "AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None + + def _effective_genre_expr(self) -> str: + """`genre` normally; the override-aware COALESCE only when overrides exist.""" + return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre" + def set_song_tags(self, filename: str, tags) -> list: """Replace ALL of a song's tags with the given set (each normalized; blanks + case-dupes dropped). Full-replace so the whole personal-meta @@ -3464,7 +3485,7 @@ class MetadataDB: # list on scan). OR within the selected set. if genre: _gph = ",".join(["?"] * len(genre)) - where += f" AND genre COLLATE NOCASE IN ({_gph})" + where += f" AND ({self._effective_genre_expr()}) COLLATE NOCASE IN ({_gph})" params += list(genre) # Mastery bands = best accuracy across a song's arrangements (song_stats, # a separate table -> correlated subquery). mastered >= 0.9, in_progress = @@ -9118,9 +9139,10 @@ def library_genres(provider: str = "local"): if is_remote: return {"genres": []} with meta_db._lock: + g = meta_db._effective_genre_expr() rows = meta_db.conn.execute( - "SELECT DISTINCT genre FROM songs WHERE genre IS NOT NULL AND genre != '' " - "ORDER BY genre COLLATE NOCASE" + f"SELECT g FROM (SELECT DISTINCT ({g}) AS g FROM songs) " + "WHERE g IS NOT NULL AND g != '' ORDER BY g COLLATE NOCASE" ).fetchall() return {"genres": [r[0] for r in rows]} diff --git a/static/v3/match-review.js b/static/v3/match-review.js index 3a36166..cc216d7 100644 --- a/static/v3/match-review.js +++ b/static/v3/match-review.js @@ -462,7 +462,11 @@ // 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']]; + const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year'], ['genre', 'Genre']]; + // Only these four are written into the pack file; genre is a library-only + // overlay (drives the genre filter/facet + the auto-match lock), never baked + // to the file — so Write to file leaves genre's override in place. + const WRITE_FIELDS = ['title', 'artist', 'album', 'year']; async function renderDetailsTab(body, song) { body.innerHTML = '

Loading…

'; @@ -510,6 +514,7 @@ song._pendingDetails = { title: String(cand.title || ''), artist: String(cand.artist || ''), album: String(cand.album || ''), year: String(cand.year || ''), + genre: (Array.isArray(cand.genres) && cand.genres[0]) ? String(cand.genres[0]) : String(cand.genre || ''), }; try { await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand }); @@ -547,7 +552,7 @@ '
' + '
' + '' + - '

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.

' + + '

Save keeps edits as a reversible library overlay — the song files aren\'t touched. Write to file bakes the title, artist, album and year into the pack (genre stays a library-only tag). Lock a field to keep an auto-match from changing it.

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

' + @@ -617,7 +622,7 @@ async function writeToFile(body, song) { const st = song._detailsState; const fields = {}; - for (const [f] of DETAIL_FIELDS) fields[f] = String(st[f].value || '').trim(); + for (const f of WRITE_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]'); @@ -650,11 +655,11 @@ 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]; + for (const f of WRITE_FIELDS) song[f] = applied[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 }; + for (const f of WRITE_FIELDS) clear[f] = { value: null, locked: !!st[f].locked }; try { await fetch('/api/song/' + enc(song.filename) + '/overrides', { method: 'PUT', diff --git a/tests/test_field_overrides.py b/tests/test_field_overrides.py index 2becabf..d4a3f78 100644 --- a/tests/test_field_overrides.py +++ b/tests/test_field_overrides.py @@ -209,6 +209,30 @@ def test_route_strips_private_sort_title(client, server): assert "_sort_title" not in row # private keyset stash never leaks to the client +def test_genre_override_drives_facet_and_filter(client, server): + # a.archive: pack genre "Rock"; b.archive: blank genre, overridden to "City Pop". + _put(server, "a.archive", title="A", genre="Rock") + _put(server, "b.archive", title="B", genre="") + server.meta_db.set_song_override("b.archive", "genre", value="City Pop") + # Facet lists the EFFECTIVE genres (override surfaces; empty raw doesn't). + genres = client.get("/api/library/genres").json()["genres"] + assert "City Pop" in genres and "Rock" in genres + # Filtering by the override genre returns the overridden song… + fns = [s["filename"] for s in client.get("/api/library?genre=City%20Pop").json()["songs"]] + assert fns == ["b.archive"] + # …and its raw (blank) genre no longer matches a stale query for it. + rock = [s["filename"] for s in client.get("/api/library?genre=Rock").json()["songs"]] + assert rock == ["a.archive"] + + +def test_lock_only_genre_does_not_change_facet(server): + # A pure lock (no value) must not invent an effective genre. + _put(server, "a.archive", title="A", genre="Metal") + server.meta_db.set_song_override("a.archive", "genre", locked=True) + assert server.meta_db._has_genre_overrides() is False # value-less rows don't count + assert server.meta_db._effective_genre_expr() == "genre" + + def test_title_keyset_paging_is_complete_with_overrides(client, server): # Raw titles A/B/C → title-sort order is A, B, C on the RAW column. _put(server, "b.archive", title="B")