Operator decision: keep the ID3v1 table canonical, fix the casing. The operator's library carries "Edm", "Idm", "Aor", "Uk Garage", "Uk Hardcore", "Trap Edm", "Glitch Hop Edm" and "Children'S Music" — an external tag editor title-cased the whole genre field. The "'S" is the giveaway. Fixed at SCAN time, not in the display layer: taste_profile.sql reads tracks.genre directly, so a cosmetic-only fix would leave the taste vocabulary holding "Edm" while the UI showed "EDM", and any correctly tagged file would contribute a second, separate tag. trueUpCasing only ever changes case, never letters, so it cannot silently turn one genre into a different one — that is what separates it from the label-remapping idea this task rejected. Two narrow rules: - A short, evidence-led acronym list, matched case-insensitively so "edm", "Edm" and "EDM" all land on "EDM". This is a deliberate 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 is a judgement about labels, whereas there is no genre named "Edm". - Apostrophe suffixes from a FIXED contraction list, so "Children'S" is repaired while "O'Brien" and "D'Angelo" keep their capital. A blanket "lowercase after an apostrophe" would have broken both. Matching uses the word's letter core rather than the raw word, so "(Edm)" and "Edm," are repaired and their punctuation re-attached. Interior punctuation stays in the core, so "Lo-Fi" and "R&B" are compared whole and cannot match a fragment by accident. My first version missed this and a test expecting "(Live EDM)" caught it. Names resolved from the ID3v1 table are deliberately NOT re-cased, per the operator's call — entry 40's "AlternRock" stays as the table spells it, with a test pinning that so a later tidy-up doesn't quietly "fix" it. tagReadVersion 1 -> 2, so this reaches the existing library on the next scan rather than new files only. That re-read reuses stored durations, so it costs tag reads and no ffprobe.
273 lines
10 KiB
Go
273 lines
10 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() != ""
|
|
default:
|
|
// Vorbis comments (FLAC/OGG/Opus) and MP4 atoms don't go through
|
|
// dhowden's welding path, so its value is already a faithful read of
|
|
// the primary genre. Multi-value handling for those containers is a
|
|
// separate, unproven concern — see #2500.
|
|
}
|
|
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",
|
|
}
|