fix(scanner): repair acronym and apostrophe casing on genre tags — #2468
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.
This commit is contained in:
@@ -89,7 +89,7 @@ func normaliseGenreValue(v string) []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, strings.TrimSpace(v[1:]))
|
||||
return append(out, trueUpCasing(strings.TrimSpace(v[1:])))
|
||||
}
|
||||
end := strings.IndexByte(v, ')')
|
||||
if end < 0 {
|
||||
@@ -106,7 +106,7 @@ func normaliseGenreValue(v string) []string {
|
||||
if err != nil {
|
||||
// Parenthesised but not a reference, e.g. "(Live)". Keep the
|
||||
// whole remainder as written.
|
||||
return append(out, v)
|
||||
return append(out, trueUpCasing(v))
|
||||
}
|
||||
if name, ok := id3v1GenreName(n); ok {
|
||||
out = append(out, name)
|
||||
@@ -120,11 +120,104 @@ func normaliseGenreValue(v string) []string {
|
||||
}
|
||||
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, v)
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user