mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
feat(v3 library): genre field in the Fix-metadata popup Details tab (#780)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6397a959a4
commit
3100d68a45
@@ -1277,6 +1277,27 @@ class MetadataDB:
|
|||||||
(filename,)).fetchone()
|
(filename,)).fetchone()
|
||||||
return {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
|
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:
|
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
|
||||||
@@ -3464,7 +3485,7 @@ class MetadataDB:
|
|||||||
# list on scan). OR within the selected set.
|
# list on scan). OR within the selected set.
|
||||||
if genre:
|
if genre:
|
||||||
_gph = ",".join(["?"] * len(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)
|
params += list(genre)
|
||||||
# Mastery bands = best accuracy across a song's arrangements (song_stats,
|
# Mastery bands = best accuracy across a song's arrangements (song_stats,
|
||||||
# a separate table -> correlated subquery). mastered >= 0.9, in_progress =
|
# a separate table -> correlated subquery). mastered >= 0.9, in_progress =
|
||||||
@@ -9118,9 +9139,10 @@ def library_genres(provider: str = "local"):
|
|||||||
if is_remote:
|
if is_remote:
|
||||||
return {"genres": []}
|
return {"genres": []}
|
||||||
with meta_db._lock:
|
with meta_db._lock:
|
||||||
|
g = meta_db._effective_genre_expr()
|
||||||
rows = meta_db.conn.execute(
|
rows = meta_db.conn.execute(
|
||||||
"SELECT DISTINCT genre FROM songs WHERE genre IS NOT NULL AND genre != '' "
|
f"SELECT g FROM (SELECT DISTINCT ({g}) AS g FROM songs) "
|
||||||
"ORDER BY genre COLLATE NOCASE"
|
"WHERE g IS NOT NULL AND g != '' ORDER BY g COLLATE NOCASE"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {"genres": [r[0] for r in rows]}
|
return {"genres": [r[0] for r in rows]}
|
||||||
|
|
||||||
|
|||||||
@@ -462,7 +462,11 @@
|
|||||||
// Each field sits on its pack value: editing above the pack makes it an
|
// 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
|
// override ("Yours"); a lock pins it so an auto-match can't recanonicalize
|
||||||
// it; revert (↺) drops back to the pack value.
|
// 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) {
|
async function renderDetailsTab(body, song) {
|
||||||
body.innerHTML = '<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
|
body.innerHTML = '<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
|
||||||
@@ -510,6 +514,7 @@
|
|||||||
song._pendingDetails = {
|
song._pendingDetails = {
|
||||||
title: String(cand.title || ''), artist: String(cand.artist || ''),
|
title: String(cand.title || ''), artist: String(cand.artist || ''),
|
||||||
album: String(cand.album || ''), year: String(cand.year || ''),
|
album: String(cand.album || ''), year: String(cand.year || ''),
|
||||||
|
genre: (Array.isArray(cand.genres) && cand.genres[0]) ? String(cand.genres[0]) : String(cand.genre || ''),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand });
|
await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand });
|
||||||
@@ -547,7 +552,7 @@
|
|||||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
|
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
|
||||||
'<div class="flex items-start gap-3">' +
|
'<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">' +
|
'<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"><span class="text-fb-text">Save</span> keeps edits as a reversible library overlay — the song files aren\'t touched. <span class="text-fb-text">Write to file</span> bakes them into the pack itself. Lock a field to keep an auto-match from changing it.</p>' +
|
'<p class="text-xs text-fb-textDim pt-1"><span class="text-fb-text">Save</span> keeps edits as a reversible library overlay — the song files aren\'t touched. <span class="text-fb-text">Write to file</span> 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.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
DETAIL_FIELDS.map(row).join('') +
|
DETAIL_FIELDS.map(row).join('') +
|
||||||
'<p data-df-status class="text-xs leading-relaxed"></p>' +
|
'<p data-df-status class="text-xs leading-relaxed"></p>' +
|
||||||
@@ -617,7 +622,7 @@
|
|||||||
async function writeToFile(body, song) {
|
async function writeToFile(body, song) {
|
||||||
const st = song._detailsState;
|
const st = song._detailsState;
|
||||||
const fields = {};
|
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 status = body.querySelector('[data-df-status]');
|
||||||
const writeBtn = body.querySelector('[data-df-write]');
|
const writeBtn = body.querySelector('[data-df-write]');
|
||||||
const saveBtn = body.querySelector('[data-df-save]');
|
const saveBtn = body.querySelector('[data-df-save]');
|
||||||
@@ -650,11 +655,11 @@
|
|||||||
const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0;
|
const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0;
|
||||||
applied.year = yr ? String(yr) : '';
|
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 (_) { }
|
try { window.feedBack?.emit('library:changed', { reason: 'write' }); } catch (_) { }
|
||||||
if (persisted) {
|
if (persisted) {
|
||||||
const clear = {};
|
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 {
|
try {
|
||||||
await fetch('/api/song/' + enc(song.filename) + '/overrides', {
|
await fetch('/api/song/' + enc(song.filename) + '/overrides', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -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
|
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):
|
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.
|
# Raw titles A/B/C → title-sort order is A, B, C on the RAW column.
|
||||||
_put(server, "b.archive", title="B")
|
_put(server, "b.archive", title="B")
|
||||||
|
|||||||
Reference in New Issue
Block a user