mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 19:59:35 +00:00
feat(library): effective genre falls back to MusicBrainz enrichment (#949)
* feat(library): effective genre falls back to MusicBrainz enrichment Converted packs rarely carry a genres manifest key — on Byron's real library 1188/1190 songs had no genre, starving the genre facet and the career passport rack (2 usable genres) while song_enrichment already held MB genres for 636 matched songs. The effective-genre expression now resolves: per-song override → pack genre → json_extract(enrichment.genres, '$[0]') for MATCHED rows only (review/failed candidates could carry the wrong recording's genres). Same fast-path gating as before: the plain indexed column is used unless overrides or enrichment genres actually exist; stand-in DBs without the table degrade via the OperationalError guard. Career passports pick this up automatically through _effective_genre_expr(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: changelog names manual rows in the enrichment fallback Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5921157f35
commit
18d77d2d41
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
|
||||||
|
resolves override → pack genre → the enrichment match's primary genre
|
||||||
|
(matched or user-pinned manual rows only). Converted packs rarely carry a `genres` manifest key,
|
||||||
|
which starved the library genre facet and career passports on real
|
||||||
|
libraries; with the fallback, every enriched song's genre is browsable and
|
||||||
|
passport-able immediately, and coverage grows as enrichment runs.
|
||||||
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
|
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
|
||||||
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
|
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
|
||||||
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
|
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
|
||||||
|
|||||||
+40
-9
@@ -1085,26 +1085,57 @@ class MetadataDB:
|
|||||||
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
|
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
|
||||||
return vals
|
return vals
|
||||||
|
|
||||||
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
|
# Effective genre precedence: per-song OVERRIDE (Fix-metadata popup) →
|
||||||
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
|
# scanned pack genre → MusicBrainz enrichment primary genre (matched/manual rows
|
||||||
# so a corrected genre is browsable — the correlated subquery is used ONLY
|
# only — a 'review'/'failed' candidate's genres could belong to the wrong
|
||||||
# when genre overrides actually exist; the common case stays on the plain
|
# recording). Applied at FILTER/FACET time (like the P4 artist alias) so a
|
||||||
# indexed `genre` column. Genre stays a library-only overlay (it isn't a
|
# corrected or enriched genre is browsable. The vast majority of converted
|
||||||
# write-to-file field), so it never touches the pack.
|
# packs carry no `genres` manifest key, so without the enrichment leg the
|
||||||
_EFFECTIVE_GENRE_SQL = (
|
# genre facet (and career passports) starve on real libraries. The
|
||||||
|
# correlated subqueries are used ONLY when overrides/enrichment genres
|
||||||
|
# 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_OVERRIDE_SQL = (
|
||||||
"COALESCE((SELECT o.value FROM song_field_override o "
|
"COALESCE((SELECT o.value FROM song_field_override o "
|
||||||
"WHERE o.filename = songs.filename AND o.field = 'genre' "
|
"WHERE o.filename = songs.filename AND o.field = 'genre' "
|
||||||
"AND o.value IS NOT NULL AND o.value != ''), genre)"
|
"AND o.value IS NOT NULL AND o.value != ''), genre)"
|
||||||
)
|
)
|
||||||
|
_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 != ''), "
|
||||||
|
"NULLIF(genre, ''), "
|
||||||
|
"(SELECT json_extract(e.genres, '$[0]') FROM song_enrichment e "
|
||||||
|
"WHERE e.filename = songs.filename AND e.match_state IN ('matched', 'manual') "
|
||||||
|
"AND e.genres IS NOT NULL AND e.genres NOT IN ('', '[]')), "
|
||||||
|
"'')"
|
||||||
|
)
|
||||||
|
|
||||||
def _has_genre_overrides(self) -> bool:
|
def _has_genre_overrides(self) -> bool:
|
||||||
return self.conn.execute(
|
return self.conn.execute(
|
||||||
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
|
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
|
||||||
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
|
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
|
||||||
|
|
||||||
|
def _has_enrichment_genres(self) -> bool:
|
||||||
|
try:
|
||||||
|
return self.conn.execute(
|
||||||
|
"SELECT 1 FROM song_enrichment WHERE match_state IN ('matched', 'manual') "
|
||||||
|
"AND genres IS NOT NULL AND genres NOT IN ('', '[]') "
|
||||||
|
"LIMIT 1").fetchone() is not None
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
return False # stand-ins / DBs without the enrichment table
|
||||||
|
|
||||||
def _effective_genre_expr(self) -> str:
|
def _effective_genre_expr(self) -> str:
|
||||||
"""`genre` normally; the override-aware COALESCE only when overrides exist."""
|
"""`genre` normally; the enrichment-aware COALESCE only when trusted
|
||||||
return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"
|
enrichment genres exist (which also proves the table exists — a
|
||||||
|
stand-in DB without song_enrichment must never receive SQL that
|
||||||
|
references it); the override-only form when just overrides exist."""
|
||||||
|
if self._has_enrichment_genres():
|
||||||
|
return self._EFFECTIVE_GENRE_SQL
|
||||||
|
if self._has_genre_overrides():
|
||||||
|
return self._EFFECTIVE_GENRE_OVERRIDE_SQL
|
||||||
|
return "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;
|
||||||
|
|||||||
@@ -273,3 +273,51 @@ def test_title_keyset_paging_is_complete_with_overrides(client, server):
|
|||||||
if not cursor:
|
if not cursor:
|
||||||
break
|
break
|
||||||
assert sorted(seen) == ["a.archive", "b.archive", "c.archive"] # each exactly once
|
assert sorted(seen) == ["a.archive", "b.archive", "c.archive"] # each exactly once
|
||||||
|
|
||||||
|
|
||||||
|
def test_enrichment_genre_fallback_precedence(client, server):
|
||||||
|
# Precedence: override → pack genre → MusicBrainz enrichment (matched only).
|
||||||
|
_put(server, "a.archive", title="A", genre="Rock") # pack wins over enrichment
|
||||||
|
_put(server, "b.archive", title="B", genre="") # falls back to enrichment
|
||||||
|
_put(server, "c.archive", title="C", genre="") # override beats enrichment
|
||||||
|
_put(server, "d.archive", title="D", genre="") # unmatched candidate: ignored
|
||||||
|
ins = "INSERT INTO song_enrichment (filename, match_state, genres) VALUES (?, ?, ?)"
|
||||||
|
server.meta_db.conn.execute(ins, ("a.archive", "matched", '["metal"]'))
|
||||||
|
server.meta_db.conn.execute(ins, ("b.archive", "matched", '["progressive rock", "rock"]'))
|
||||||
|
server.meta_db.conn.execute(ins, ("c.archive", "matched", '["jazz"]'))
|
||||||
|
server.meta_db.conn.execute(ins, ("d.archive", "review", '["country"]'))
|
||||||
|
_put(server, "e.archive", title="E", genre="") # manual pin is trusted too
|
||||||
|
server.meta_db.conn.execute(ins, ("e.archive", "manual", '["ska"]'))
|
||||||
|
server.meta_db.conn.commit()
|
||||||
|
server.meta_db.set_song_override("c.archive", "genre", value="City Pop")
|
||||||
|
|
||||||
|
genres = client.get("/api/library/genres").json()["genres"]
|
||||||
|
assert "Rock" in genres # pack value kept for a
|
||||||
|
assert "metal" not in genres # enrichment never overrides a pack genre
|
||||||
|
assert "progressive rock" in genres # b: enrichment primary ([0]) surfaces
|
||||||
|
assert "City Pop" in genres and "jazz" not in genres # override beats enrichment
|
||||||
|
assert "country" not in genres # review/failed candidates never leak
|
||||||
|
assert "ska" in genres # user-pinned (manual) matches count
|
||||||
|
|
||||||
|
# Filtering by the enriched genre finds the song.
|
||||||
|
r = client.get("/api/library", params={"genre": "progressive rock"}).json()
|
||||||
|
assert [s["filename"] for s in r["songs"]] == ["b.archive"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_enrichment_and_no_overrides_uses_plain_column(server):
|
||||||
|
_put(server, "a.archive", title="A", genre="Rock")
|
||||||
|
assert server.meta_db._effective_genre_expr() == "genre"
|
||||||
|
|
||||||
|
|
||||||
|
def test_overrides_without_enrichment_table_stay_safe(server):
|
||||||
|
# A stand-in scenario: overrides exist but song_enrichment is gone — the
|
||||||
|
# expression must not reference the missing table.
|
||||||
|
server.meta_db.conn.execute("DROP TABLE song_enrichment")
|
||||||
|
_put(server, "a.archive", title="A", genre="")
|
||||||
|
server.meta_db.set_song_override("a.archive", "genre", value="City Pop")
|
||||||
|
expr = server.meta_db._effective_genre_expr()
|
||||||
|
assert "song_enrichment" not in expr
|
||||||
|
# And it still evaluates: the override surfaces through the facet query.
|
||||||
|
row = server.meta_db.conn.execute(
|
||||||
|
f"SELECT {expr} FROM songs WHERE filename = 'a.archive'").fetchone()
|
||||||
|
assert row[0] == "City Pop"
|
||||||
|
|||||||
Reference in New Issue
Block a user