Files
minstrel/internal/library/genre.go
T
bvandeusenandClaude Opus 5.5 37d3a5bcd3
release / govulncheck (push) Successful in 50s
release / web (push) Successful in 2m3s
release / go (push) Successful in 2m18s
release / integration (push) Successful in 5m42s
release / android (push) Successful in 6m30s
release / Build signed APK (releases and dev) (push) Successful in 6m0s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(library): read every genre of FLAC, Ogg, Opus and MP4 files (#2500)
Vorbis comments repeat a field to give it several values (GENRE=Boom Bap,
GENRE=Downtempo, ...). dhowden/tag keeps comments in a map keyed by field
name, so each repeat overwrote the previous one and only the last genre
was stored. MP4 has the same gap: several data atoms in one ©gen atom, or
repeated ©gen atoms, collapse to one value.

On the operator's library 3,658 of 4,092 FLACs declare more than one
genre. Kupla's Life Forms carries eight and was stored as "Instrumental
Hip Hop" alone, so browse and the taste profile never saw the other seven.

- vorbisgenre.go reads the comment block directly: FLAC's metadata block
  (including FLACs behind an ID3v2 tag), and the comment packet of Ogg
  Vorbis and Opus, reassembled across pages when cover art makes it span
  several.
- mp4genre.go walks moov > udta > meta > ilst and returns every ©gen text
  value. It handles ISO and QuickTime meta layouts and a moov placed after
  mdat. A file with only the numeric gnre atom still falls back to
  dhowden, which resolves it.
- extractGenres routes VORBIS and MP4 through them, as #2499 did for
  ID3v2.
- tagReadVersion 3 -> 4, so the next scan re-reads the tags of files
  already indexed. Unchanged files keep their duration and fingerprint,
  so the pass costs tag reads only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:40:56 -04:00

287 lines
11 KiB
Go

package library
import (
"io"
"strconv"
"strings"
"github.com/dhowden/tag"
)
// genreDelimiter is what we join multi-value genres with on the way into
// tracks.genre. It has to be one of the characters the read side already splits
// on — internal/taste and internal/recommendation both split on [;,], as do
// browse.sql, recommendation.sql and discover.sql. Storing values joined with
// ";" means the entire fix lands in the scanner and no query changes.
const genreDelimiter = ";"
// extractGenres returns the genre values for a file, normalised and
// deduplicated, ready to be joined with genreDelimiter.
//
// fellBack reports that an ID3v2 file's genre frame could not be parsed and the
// value came from dhowden/tag instead. That path yields the old welded string,
// so it is worth logging — but it is still the best available answer, and
// degrading to it beats storing no genre at all.
func extractGenres(meta tag.Metadata, rs io.ReadSeeker) (genres []string, fellBack bool) {
switch meta.Format() {
case tag.ID3v2_2, tag.ID3v2_3, tag.ID3v2_4:
values, err := readID3v2GenreValues(rs)
if err == nil {
return normaliseGenres(values), false
}
// No frame at all is the common case for untagged files, and
// dhowden/tag will have nothing either — not worth flagging.
fellBack = meta.Genre() != ""
case tag.VORBIS, tag.MP4:
// dhowden/tag keeps only the last of a repeated field here (#2500):
// see vorbisgenre.go and mp4genre.go.
var values []string
var err error
switch {
case meta.Format() == tag.MP4:
values, err = readMP4GenreValues(rs)
case meta.FileType() == tag.OGG:
values, err = readOggGenreValues(rs)
default:
values, err = readFLACGenreValues(rs)
}
if err == nil && len(values) > 0 {
return normaliseGenres(values), false
}
// err == nil with no values: the file declares no genre of its own
// (or, for MP4, only the numeric gnre atom, which dhowden resolves).
fellBack = err != nil && meta.Genre() != ""
}
return normaliseGenres([]string{meta.Genre()}), fellBack
}
// normaliseGenres expands each raw value, then drops case-insensitive
// duplicates while keeping the first spelling seen. Duplicates are common once
// numeric references are resolved: "(40)AlternRock" declares the same genre
// twice, and so does a file tagged both "Rock" and "rock".
func normaliseGenres(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
for _, g := range normaliseGenreValue(v) {
key := strings.ToLower(g)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, g)
}
}
if len(out) == 0 {
return nil
}
return out
}
// normaliseGenreValue turns one raw tag value into zero or more genre names,
// resolving the ID3 numeric-reference syntax.
//
// A value may be:
// - plain text ("Alternative Rock") — passed through
// - a bare ID3v1 index ("17") — resolved to "Rock". This is what the spec
// says a numeric TCON means, and what ffmpeg does. It is why the operator's
// library showed genres like "4017" and "526617": several numeric values
// welded together by the old reader.
// - ID3v2.3 refinement syntax ("(17)", "(51)(39)", "(17)Hard Rock", "(RX)")
// — each parenthesised index becomes its own genre, and trailing text
// becomes one more.
//
// Values that are numeric but out of range carry no meaning as a label, so they
// are dropped rather than stored as digits.
func normaliseGenreValue(v string) []string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
var out []string
for strings.HasPrefix(v, "(") {
// "((" is the spec's escape for a literal "(" — the rest is plain text.
if strings.HasPrefix(v, "((") {
return append(out, trueUpCasing(strings.TrimSpace(v[1:])))
}
end := strings.IndexByte(v, ')')
if end < 0 {
break
}
inner := strings.TrimSpace(v[1:end])
switch {
case strings.EqualFold(inner, "RX"):
out = append(out, "Remix")
case strings.EqualFold(inner, "CR"):
out = append(out, "Cover")
default:
n, err := strconv.Atoi(inner)
if err != nil {
// Parenthesised but not a reference, e.g. "(Live)". Keep the
// whole remainder as written.
return append(out, trueUpCasing(v))
}
if name, ok := id3v1GenreName(n); ok {
out = append(out, name)
}
}
v = strings.TrimSpace(v[end+1:])
}
if v == "" {
return out
}
if n, err := strconv.Atoi(v); err == nil {
if name, ok := id3v1GenreName(n); ok {
// Canonical table name, deliberately NOT re-cased — see id3v1Genres.
return append(out, name)
}
return out
}
return append(out, trueUpCasing(v))
}
// genreAcronyms are tokens that belong in caps. Tag editors that title-case the
// whole genre field turn "EDM" into "Edm" and "UK Garage" into "Uk Garage", and
// the operator's library carries all of these (#2468).
//
// Matched case-INSENSITIVELY, so "edm", "Edm" and "EDM" all land on "EDM". That
// is a deliberate, narrow exception to the project's rule that genre case is
// exposed as the file says it: "Rock" and "rock" still stay separate rows,
// because folding those would be a judgement about labels. Fixing a token that
// is unambiguously an initialism is not — there is no genre named "Edm".
//
// Kept short and evidence-led. Add a token here only when a real library shows
// it damaged; a speculative list risks mangling a word that legitimately looks
// like an acronym.
var genreAcronyms = map[string]string{
"edm": "EDM",
"idm": "IDM",
"aor": "AOR",
"uk": "UK",
"us": "US",
"ebm": "EBM",
}
// contractionSuffixes are the word-endings that follow an apostrophe in normal
// English. Title-casing capitalises the letter after ANY non-letter, which is
// how "Children's Music" became "Children'S Music".
//
// Deliberately a fixed list rather than "lowercase whatever follows an
// apostrophe": that broader rule would break "O'Brien" and "D'Angelo", which are
// correctly capitalised after the apostrophe.
var contractionSuffixes = map[string]bool{
"s": true, "t": true, "re": true, "ll": true, "ve": true, "d": true, "m": true,
}
// trueUpCasing repairs casing damage done by tag editors that title-case the
// genre field. It only ever changes case, never the letters — so it cannot
// silently turn one genre into a different one, which is what separates it from
// the label-remapping idea #2468 rejected.
func trueUpCasing(s string) string {
if s == "" {
return s
}
words := strings.Split(s, " ")
for i, w := range words {
if w == "" {
continue
}
// Match the word's letter core, not the raw word, so surrounding
// punctuation doesn't hide the acronym: "(Edm)" and "Edm," both need
// fixing, and re-attaching the trimmed edges keeps them intact.
lead, core, trail := splitWordCore(w)
if fixed, ok := genreAcronyms[strings.ToLower(core)]; ok {
words[i] = lead + fixed + trail
continue
}
words[i] = fixApostrophe(w)
}
return strings.Join(words, " ")
}
// splitWordCore peels leading and trailing non-alphanumerics off a word.
// Interior punctuation stays in the core, so "Lo-Fi" and "R&B" are compared
// whole rather than being split into fragments that might match by accident.
func splitWordCore(w string) (lead, core, trail string) {
isCore := func(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}
start := 0
for start < len(w) && !isCore(rune(w[start])) {
start++
}
end := len(w)
for end > start && !isCore(rune(w[end-1])) {
end--
}
return w[:start], w[start:end], w[end:]
}
// fixApostrophe lowercases a capitalised contraction or possessive suffix:
// "Children'S" -> "Children's". Leaves "O'Brien" alone, since "Brien" is not a
// contraction suffix.
func fixApostrophe(w string) string {
idx := strings.LastIndexByte(w, '\'')
if idx <= 0 || idx == len(w)-1 {
return w
}
suffix := w[idx+1:]
if !contractionSuffixes[strings.ToLower(suffix)] {
return w
}
return w[:idx+1] + strings.ToLower(suffix)
}
func id3v1GenreName(n int) (string, bool) {
if n < 0 || n >= len(id3v1Genres) {
return "", false
}
return id3v1Genres[n], true
}
// id3v1Genres is the ID3v1 genre index: entries 0-79 are the original list,
// 80-125 were added by Winamp, and 126-191 later still. Index is meaningful, so
// never reorder or remove an entry — a numeric tag written years ago resolves
// through this table by position.
//
// Entry 133 is "Afro-Punk"; the 1990s list used a slur there, and no file in
// practice depends on the original spelling.
var id3v1Genres = []string{
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
"Rap", "Reggae", "Rock", "Techno", "Industrial", "Alternative", "Ska",
"Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient",
"Trip-Hop", "Vocal", "Jazz+Funk", "Fusion", "Trance", "Classical",
"Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise",
"AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative",
"Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic", "Darkwave",
"Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream",
"Southern Rock", "Comedy", "Cult", "Gangsta", "Top 40", "Christian Rap",
"Pop/Funk", "Jungle", "Native American", "Cabaret", "New Wave",
"Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll",
"Hard Rock", "Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion",
"Bebob", "Latin", "Revival", "Celtic", "Bluegrass", "Avantgarde",
"Gothic Rock", "Progressive Rock", "Psychedelic Rock", "Symphonic Rock",
"Slow Rock", "Big Band", "Chorus", "Easy Listening", "Acoustic", "Humour",
"Speech", "Chanson", "Opera", "Chamber Music", "Sonata", "Symphony",
"Booty Bass", "Primus", "Porn Groove", "Satire", "Slow Jam", "Club",
"Tango", "Samba", "Folklore", "Ballad", "Power Ballad", "Rhythmic Soul",
"Freestyle", "Duet", "Punk Rock", "Drum Solo", "A capella", "Euro-House",
"Dance Hall", "Goa", "Drum & Bass", "Club-House", "Hardcore", "Terror",
"Indie", "BritPop", "Afro-Punk", "Polsk Punk", "Beat",
"Christian Gangsta Rap", "Heavy Metal", "Black Metal", "Crossover",
"Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
"Thrash Metal", "Anime", "JPop", "Synthpop", "Abstract", "Art Rock",
"Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout", "Downtempo",
"Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo",
"Experimental", "Garage", "Global", "IDM", "Illbient", "Industro-Goth",
"Jam Band", "Krautrock", "Leftfield", "Lounge", "Math Rock",
"New Romantic", "Nu-Breakz", "Post-Punk", "Post-Rock", "Psytrance",
"Shoegaze", "Space Rock", "Trop Rock", "World Music", "Neoclassical",
"Audiobook", "Audio Theatre", "Neue Deutsche Welle", "Podcast",
"Indie Rock", "G-Funk", "Dubstep", "Garage Rock", "Psybient",
}