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:
@@ -419,3 +419,102 @@ func equalStrings(a, b []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestTrueUpCasing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// The damage actually present in the operator's library (#2468).
|
||||
{"edm acronym", "Edm", "EDM"},
|
||||
{"idm acronym", "Idm", "IDM"},
|
||||
{"aor acronym", "Aor", "AOR"},
|
||||
{"uk prefix", "Uk Garage", "UK Garage"},
|
||||
{"uk hardcore", "Uk Hardcore", "UK Hardcore"},
|
||||
{"acronym mid-phrase", "Trap Edm", "Trap EDM"},
|
||||
{"acronym at the end", "Glitch Hop Edm", "Glitch Hop EDM"},
|
||||
{"possessive", "Children'S Music", "Children's Music"},
|
||||
|
||||
// Already correct input must be left exactly alone.
|
||||
{"correct acronym", "EDM", "EDM"},
|
||||
{"correct possessive", "Children's Music", "Children's Music"},
|
||||
|
||||
// Case-insensitive, so a lower-cased tag also lands on the canonical
|
||||
// form rather than becoming a third variant.
|
||||
{"lowercase acronym", "edm", "EDM"},
|
||||
|
||||
// Names with an apostrophe followed by a real word are NOT contractions
|
||||
// and must keep their capital — this is why the suffix list is fixed
|
||||
// rather than "lowercase anything after an apostrophe".
|
||||
{"irish surname", "O'Brien Core", "O'Brien Core"},
|
||||
{"french elision", "D'Angelo Soul", "D'Angelo Soul"},
|
||||
|
||||
// Ordinary genres pass through untouched. Case is otherwise exposed as
|
||||
// the file says it — "Rock" vs "rock" stays a real distinction.
|
||||
{"plain", "Alternative Rock", "Alternative Rock"},
|
||||
{"lowercase plain", "rock", "rock"},
|
||||
{"hyphenated", "Lo-Fi Hip Hop", "Lo-Fi Hip Hop"},
|
||||
{"ampersand", "R&B", "R&B"},
|
||||
{"empty", "", ""},
|
||||
|
||||
// Punctuation around a word must not hide the acronym inside it.
|
||||
{"parenthesised acronym", "Hip Hop (Edm)", "Hip Hop (EDM)"},
|
||||
{"acronym with comma", "Edm, Trap", "EDM, Trap"},
|
||||
|
||||
// Interior punctuation stays in the core, so these are compared whole
|
||||
// and cannot match a fragment by accident.
|
||||
{"hyphenated stays whole", "Lo-Fi", "Lo-Fi"},
|
||||
{"ampersand stays whole", "Drum & Bass", "Drum & Bass"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := trueUpCasing(tc.in); got != tc.want {
|
||||
t.Errorf("trueUpCasing(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The casing repair must never touch a name resolved from the ID3v1 table. The
|
||||
// operator chose to keep that table canonical, so entry 40's 1990s spelling
|
||||
// "AlternRock" stays as-is even though it reads like damage.
|
||||
func TestNormaliseGenreValue_CanonicalTableNamesNotRecased(t *testing.T) {
|
||||
if got := normaliseGenreValue("40"); !equalStrings(got, []string{"AlternRock"}) {
|
||||
t.Errorf("bare 40 = %q, want [AlternRock]", got)
|
||||
}
|
||||
if got := normaliseGenreValue("(40)"); !equalStrings(got, []string{"AlternRock"}) {
|
||||
t.Errorf("(40) = %q, want [AlternRock]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Casing runs on values that came from the file, including the parenthesised
|
||||
// and refinement paths.
|
||||
func TestNormaliseGenreValue_CasingAppliedToFileText(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"Edm", []string{"EDM"}},
|
||||
{"(17)Uk Garage", []string{"Rock", "UK Garage"}},
|
||||
// Punctuation around the acronym must not hide it — the letter core is
|
||||
// what gets matched, and the trimmed edges are re-attached.
|
||||
{"(Live Edm)", []string{"(Live EDM)"}},
|
||||
{"((Children'S Music", []string{"(Children's Music"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := normaliseGenreValue(tc.in); !equalStrings(got, tc.want) {
|
||||
t.Errorf("normaliseGenreValue(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Two spellings of one acronym in the same file collapse to a single tag rather
|
||||
// than surviving as near-duplicates.
|
||||
func TestNormaliseGenres_AcronymVariantsDedupe(t *testing.T) {
|
||||
if got := normaliseGenres([]string{"Edm", "EDM", "edm"}); !equalStrings(got, []string{"EDM"}) {
|
||||
t.Errorf("genres = %q, want [EDM]", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user