From 43423d436a33c8d737588af8b840ba3cae50cf53 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Mon, 13 Jul 2026 16:37:50 +0200 Subject: [PATCH 1/2] feat(library): effective genre falls back to MusicBrainz enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 6 +++++ lib/metadata_db.py | 49 ++++++++++++++++++++++++++++------- tests/test_field_overrides.py | 48 ++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d087ae22..ea29318a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Genres fall back to MusicBrainz enrichment** — the effective genre now + resolves override → pack genre → the enrichment match's primary genre + (matched 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: the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger; diff --git a/lib/metadata_db.py b/lib/metadata_db.py index 5b3676ce..ccdbbb9e 100644 --- a/lib/metadata_db.py +++ b/lib/metadata_db.py @@ -1085,26 +1085,57 @@ def pack_fields(self, filename: str) -> dict: vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"]) return vals - # 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 = ( + # Effective genre precedence: per-song OVERRIDE (Fix-metadata popup) → + # scanned pack genre → MusicBrainz enrichment primary genre (matched/manual rows + # only — a 'review'/'failed' candidate's genres could belong to the wrong + # recording). Applied at FILTER/FACET time (like the P4 artist alias) so a + # corrected or enriched genre is browsable. The vast majority of converted + # packs carry no `genres` manifest key, so without the enrichment leg the + # 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 " "WHERE o.filename = songs.filename AND o.field = '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: 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 _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: - """`genre` normally; the override-aware COALESCE only when overrides exist.""" - return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre" + """`genre` normally; the enrichment-aware COALESCE only when trusted + 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: """Replace ALL of a song's tags with the given set (each normalized; diff --git a/tests/test_field_overrides.py b/tests/test_field_overrides.py index 8f46b129..1be9e221 100644 --- a/tests/test_field_overrides.py +++ b/tests/test_field_overrides.py @@ -273,3 +273,51 @@ def test_title_keyset_paging_is_complete_with_overrides(client, server): if not cursor: break 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" From 3b4a250091756868f30a1d2447a99d343e203600 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Mon, 13 Jul 2026 16:52:59 +0200 Subject: [PATCH 2/2] docs: changelog names manual rows in the enrichment fallback Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea29318a..180e8c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Genres fall back to MusicBrainz enrichment** — the effective genre now resolves override → pack genre → the enrichment match's primary genre - (matched rows only). Converted packs rarely carry a `genres` manifest key, + (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.