Fixes#2499. Two commits, server-only, carries migration 0054.
CI green on fd27819c (run 3456): sqlc verify, go vet, golangci-lint, go test (short, race), and the integration lane — which runs real migrations, so 0054 applied against Postgres.
What was wrong
internal/library/scanner.go stored dhowden/tag's meta.Genre() verbatim. That library's readTFrame does:
ID3v2 separates multiple values in a text frame with a null byte. That splits on the null and rejoins with the empty string, so a file tagged Alternative Rock + Rock was stored as the single token Alternative RockRock. It also leaves bare numeric ID3v1 references unresolved, which is why the library reported genres like 4017 and 526617.
Roughly two-thirds of the ~700 distinct "genres" in the operator's library were artifacts of this, not real labels.
Why it mattered beyond the browse axis
tracks.genre has four consumers and only the first is cosmetic:
taste_profile.sql reads t.genredirectly, so welded tokens were entering the taste profile's tag vocabulary, feeding TasteMatch and taste-tag overlap
recommendation.sql / discover.sql split on [;,], which finds nothing to split — they were comparing welded pseudo-tags for genre similarity
Subsonic getAlbumList?type=byGenre
Every count was also wrong: Rock reported 3413 while excluding every row welded into Alternative RockRock.
Why not ffprobe
ffprobe is already a dependency and was the obvious candidate, but ffmpeg's read_ttag calls decode_stronce with no loop — it keeps only the first value and discards the rest. Trading welding for silent truncation of multi-genre tags would blunt the signal genre mainly feeds, so the frame is parsed directly instead.
internal/library/id3v2genre.go handles v2.2/2.3/2.4 frame layouts (2.4 synchsafe vs 2.3 plain sizes), all four text encodings, tag-level and v2.4 frame-level unsynchronisation, the extended header, and refuses compressed/encrypted frames. Everything other than the genre frame still comes from dhowden/tag.
Values are stored ;-delimited — a delimiter the read side already splits on — so no query changes and the blast radius stays in the scanner.
Repairing existing rows
The fix alone would have repaired nothing. scanFile's incremental skip short-circuits any file whose mtime hasn't moved, re-tagging doesn't move mtime, and no force-rescan affordance existed anywhere.
Migration 0054 adds tracks.tag_read_version (DEFAULT 0) against library.tagReadVersion = 1, following the precedent already in that skip clause for duration_ms = 0 backfill. Every existing row sorts below the constant and gets re-read on the next ordinary scan — no operator action, no rebuild. Bumping the constant is how a future extraction fix reaches an existing library, which is why it's a version rather than a needs_reread boolean.
A version-driven re-read reuses the stored duration instead of re-running ffprobe, since the file's bytes haven't moved. That keeps a library-wide repair pass tag-read-bound rather than one fork+exec per file.
taste_profile_tags needs nothing: BuildTasteProfile is an atomic delete-and-reinsert, so polluted rows are replaced on the next scheduled rebuild.
Scope
ID3v2 only. dhowden welds nowhere else — readTFrame is ID3v2-specific — so by construction every welded genre is an mp3. Confirmed independently by decoding the numerics against the ID3v1 table: 526617 = Electronic + New Wave + Rock, 1766Post-Punk43 = Rock + New Wave + Post-Punk + Punk. The Vorbis/MP4 repeated-field question is #2500, unproven and deliberately not built.
Tests
20 cases in internal/library/genre_test.go, including the Alternative RockRock regression across all three major versions, the operator's 8-value blob, both UTF-16 BOM placements, Latin-1 widening, and the unsynchronisation collapse (with a guard that fails if the test ever goes vacuous).
Also
Corrected two comments that misattributed this bug: recommendation/sessionvector.go blamed "broken tag-editor output" — it was ours — and api/library_browse.go now warns that #2468's taxonomy question must only be judged against a re-scanned library.
Fixes #2499. Two commits, server-only, **carries migration 0054**.
CI green on `fd27819c` (run 3456): sqlc verify, `go vet`, golangci-lint, `go test (short, race)`, and the integration lane — which runs real migrations, so 0054 applied against Postgres.
## What was wrong
`internal/library/scanner.go` stored `dhowden/tag`'s `meta.Genre()` verbatim. That library's `readTFrame` does:
```go
strings.Join(strings.Split(txt, string(singleZero)), "")
```
ID3v2 separates multiple values in a text frame with a **null byte**. That splits on the null and rejoins with the **empty string**, so a file tagged `Alternative Rock` + `Rock` was stored as the single token `Alternative RockRock`. It also leaves bare numeric ID3v1 references unresolved, which is why the library reported genres like `4017` and `526617`.
Roughly two-thirds of the ~700 distinct "genres" in the operator's library were artifacts of this, not real labels.
## Why it mattered beyond the browse axis
`tracks.genre` has four consumers and only the first is cosmetic:
- `browse.sql` — the genre index added in #367
- `taste_profile.sql` reads `t.genre` **directly**, so welded tokens were entering the taste profile's tag vocabulary, feeding `TasteMatch` and taste-tag overlap
- `recommendation.sql` / `discover.sql` split on `[;,]`, which finds nothing to split — they were comparing welded pseudo-tags for genre similarity
- Subsonic `getAlbumList?type=byGenre`
Every count was also wrong: `Rock` reported 3413 while excluding every row welded into `Alternative RockRock`.
## Why not ffprobe
ffprobe is already a dependency and was the obvious candidate, but ffmpeg's `read_ttag` calls `decode_str` **once** with no loop — it keeps only the first value and discards the rest. Trading welding for silent truncation of multi-genre tags would blunt the signal genre mainly feeds, so the frame is parsed directly instead.
`internal/library/id3v2genre.go` handles v2.2/2.3/2.4 frame layouts (2.4 synchsafe vs 2.3 plain sizes), all four text encodings, tag-level and v2.4 frame-level unsynchronisation, the extended header, and refuses compressed/encrypted frames. Everything other than the genre frame still comes from `dhowden/tag`.
Values are stored `;`-delimited — a delimiter the read side already splits on — so **no query changes** and the blast radius stays in the scanner.
## Repairing existing rows
The fix alone would have repaired nothing. `scanFile`'s incremental skip short-circuits any file whose mtime hasn't moved, re-tagging doesn't move mtime, and no force-rescan affordance existed anywhere.
Migration 0054 adds `tracks.tag_read_version` (`DEFAULT 0`) against `library.tagReadVersion = 1`, following the precedent already in that skip clause for `duration_ms = 0` backfill. Every existing row sorts below the constant and gets re-read on the next ordinary scan — no operator action, no rebuild. Bumping the constant is how a future extraction fix reaches an existing library, which is why it's a version rather than a `needs_reread` boolean.
A version-driven re-read **reuses the stored duration** instead of re-running ffprobe, since the file's bytes haven't moved. That keeps a library-wide repair pass tag-read-bound rather than one fork+exec per file.
`taste_profile_tags` needs nothing: `BuildTasteProfile` is an atomic delete-and-reinsert, so polluted rows are replaced on the next scheduled rebuild.
## Scope
ID3v2 only. `dhowden` welds nowhere else — `readTFrame` is ID3v2-specific — so by construction every welded genre is an mp3. Confirmed independently by decoding the numerics against the ID3v1 table: `526617` = Electronic + New Wave + Rock, `1766Post-Punk43` = Rock + New Wave + Post-Punk + Punk. The Vorbis/MP4 repeated-field question is #2500, unproven and deliberately not built.
## Tests
20 cases in `internal/library/genre_test.go`, including the `Alternative RockRock` regression across all three major versions, the operator's 8-value blob, both UTF-16 BOM placements, Latin-1 widening, and the unsynchronisation collapse (with a guard that fails if the test ever goes vacuous).
## Also
Corrected two comments that misattributed this bug: `recommendation/sessionvector.go` blamed "broken tag-editor output" — it was ours — and `api/library_browse.go` now warns that #2468's taxonomy question must only be judged against a re-scanned library.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5
dhowden/tag's readTFrame splits ID3v2 null-separated multi-value text
frames and rejoins them with the EMPTY string, so a file tagged
"Alternative Rock" + "Rock" was stored as "Alternative RockRock". It also
leaves bare numeric ID3v1 references unresolved, which is why the
library showed genres like "4017" and "526617".
This corrupted more than the browse axis added in #367: taste_profile.sql
reads tracks.genre directly, so the welded tokens were entering the taste
profile's tag vocabulary, and recommendation.sql/discover.sql were
comparing them as single opaque tags. Genre counts were wrong everywhere.
ffprobe is not a fix — ffmpeg's read_ttag calls decode_str once with no
loop, keeping only the first value. Truncating multi-genre tags would
blunt the similarity signal genre mainly feeds. So the TCON frame is now
parsed directly (ID3v2.2/2.3/2.4, all four text encodings, per-frame and
tag-level unsynchronisation, numeric and parenthesised ID3v1 references);
everything else still comes from dhowden/tag. Values are stored
";"-delimited, which the read side already splits on, so no query changes.
Existing rows are repaired without an operator-run rebuild: migration
0054 adds tracks.tag_read_version DEFAULT 0, below the scanner's current
tagReadVersion, so the next scan re-reads tags it would otherwise skip on
mtime. Such a re-read reuses the stored duration instead of re-running
ffprobe, keeping a repair pass tag-read-bound rather than one fork+exec
per file. Bumping the constant is how a future extraction fix reaches an
existing library.
Only ID3v2 is in scope — dhowden welds nowhere else. The Vorbis/MP4
repeated-field question is #2500, unproven and deliberately not built.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #2499. Two commits, server-only, carries migration 0054.
CI green on
fd27819c(run 3456): sqlc verify,go vet, golangci-lint,go test (short, race), and the integration lane — which runs real migrations, so 0054 applied against Postgres.What was wrong
internal/library/scanner.gostoreddhowden/tag'smeta.Genre()verbatim. That library'sreadTFramedoes:ID3v2 separates multiple values in a text frame with a null byte. That splits on the null and rejoins with the empty string, so a file tagged
Alternative Rock+Rockwas stored as the single tokenAlternative RockRock. It also leaves bare numeric ID3v1 references unresolved, which is why the library reported genres like4017and526617.Roughly two-thirds of the ~700 distinct "genres" in the operator's library were artifacts of this, not real labels.
Why it mattered beyond the browse axis
tracks.genrehas four consumers and only the first is cosmetic:browse.sql— the genre index added in #367taste_profile.sqlreadst.genredirectly, so welded tokens were entering the taste profile's tag vocabulary, feedingTasteMatchand taste-tag overlaprecommendation.sql/discover.sqlsplit on[;,], which finds nothing to split — they were comparing welded pseudo-tags for genre similaritygetAlbumList?type=byGenreEvery count was also wrong:
Rockreported 3413 while excluding every row welded intoAlternative RockRock.Why not ffprobe
ffprobe is already a dependency and was the obvious candidate, but ffmpeg's
read_ttagcallsdecode_stronce with no loop — it keeps only the first value and discards the rest. Trading welding for silent truncation of multi-genre tags would blunt the signal genre mainly feeds, so the frame is parsed directly instead.internal/library/id3v2genre.gohandles v2.2/2.3/2.4 frame layouts (2.4 synchsafe vs 2.3 plain sizes), all four text encodings, tag-level and v2.4 frame-level unsynchronisation, the extended header, and refuses compressed/encrypted frames. Everything other than the genre frame still comes fromdhowden/tag.Values are stored
;-delimited — a delimiter the read side already splits on — so no query changes and the blast radius stays in the scanner.Repairing existing rows
The fix alone would have repaired nothing.
scanFile's incremental skip short-circuits any file whose mtime hasn't moved, re-tagging doesn't move mtime, and no force-rescan affordance existed anywhere.Migration 0054 adds
tracks.tag_read_version(DEFAULT 0) againstlibrary.tagReadVersion = 1, following the precedent already in that skip clause forduration_ms = 0backfill. Every existing row sorts below the constant and gets re-read on the next ordinary scan — no operator action, no rebuild. Bumping the constant is how a future extraction fix reaches an existing library, which is why it's a version rather than aneeds_rereadboolean.A version-driven re-read reuses the stored duration instead of re-running ffprobe, since the file's bytes haven't moved. That keeps a library-wide repair pass tag-read-bound rather than one fork+exec per file.
taste_profile_tagsneeds nothing:BuildTasteProfileis an atomic delete-and-reinsert, so polluted rows are replaced on the next scheduled rebuild.Scope
ID3v2 only.
dhowdenwelds nowhere else —readTFrameis ID3v2-specific — so by construction every welded genre is an mp3. Confirmed independently by decoding the numerics against the ID3v1 table:526617= Electronic + New Wave + Rock,1766Post-Punk43= Rock + New Wave + Post-Punk + Punk. The Vorbis/MP4 repeated-field question is #2500, unproven and deliberately not built.Tests
20 cases in
internal/library/genre_test.go, including theAlternative RockRockregression across all three major versions, the operator's 8-value blob, both UTF-16 BOM placements, Latin-1 widening, and the unsynchronisation collapse (with a guard that fails if the test ever goes vacuous).Also
Corrected two comments that misattributed this bug:
recommendation/sessionvector.goblamed "broken tag-editor output" — it was ours — andapi/library_browse.gonow warns that #2468's taxonomy question must only be judged against a re-scanned library.🤖 Generated with Claude Code
https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5