releaseparser: Artist/Title boundary resolution + normalized Type field

Three-level core resolution with priority overrides → dictionaries/model → suspects:

1. dicts/overrides.txt (highest priority)
   Point fixes via exact lookup on Raw BEFORE the positional logic.
   Format: <original line> => artist | title. An override fixes
   Artist/Title and shifts the dictionary tail zone by the actual core
   length (overrideCoreLength, fallback 2 tokens).

2. dicts/artists.txt
   Whitelist of hyphenated artists (Iku-Turso, Post-Mortem, ...): a
   [0]-[1] hit → Artist = 2 tokens, Title = [2], tail zone shifted.
   One entry covers all releases of the artist automatically.

3. Suspected title continuation
   Unknown tokens between the title zone and the tail (followed later by
   dictionary markers) no longer pollute UnknownTokens; they go to a
   dedicated TitleExtra field + warning "suspected title continuation" —
   a reviewer hint that the core is probably longer than 2 tokens.
   Catalogue numbers (parenthesized tokens) are resolved with FIRST
   priority and never land in suspects.

Type field (normalized base release type):
- new normalization map dicts/types.txt (<Base> = variants):
  EP = EP, CDS, CDM, 7INCH_VINYL, CDEP, ..., REMASTERED_EP; Demo = ...;
  Split; Single; Compilation; Collaboration (curator-extendable)
- tokens matched against edition/source are additionally normalized to
  the base type (first match wins); edition.txt/source.txt untouched,
  Source stays raw
- TypeDistribution map[string]int added to GroupStats and the report
  (console/JSON)
- OST/INSTRUMENTAL are release traits, not types; excluded from the map

Tests: old cases updated to the new suspect semantics; acceptance tests
added for overrides (Milicia_Oscura-Anti-Cristo), artist dictionary
(Iku-Turso), the suspect section, Type normalization (CDEP → EP with
raw Source=CDEP, first-match-wins) and TypeDistribution.

go build / go vet / go test ./internal/releaseparser/... — all green.
This commit is contained in:
Yevhen Odynets
2026-09-01 13:18:40 +03:00
parent f2629f002c
commit 347f525801
6 changed files with 294 additions and 32 deletions
+130 -13
View File
@@ -37,8 +37,9 @@ func TestParseEditionAndDemo(t *testing.T) {
}
func TestParseArtistWithHyphen(t *testing.T) {
// [0]=Artist, [1]=Title. Artist сам може містити дефіс — тоді тайтл
// стає токеном [1], а {"Violence", "All_My_Life"} щось лишається unknown.
// Artist сам містить дефіс, але Ultra-Violence ще не в словнику:
// модель [0]/[1] лишається, а All_My_Life (за ним ідуть словникові
// маркери) — suspected title continuation, не unknown.
r := Parse("Ultra-Violence-All_My_Life-EP-16BIT-WEB-FLAC-2026-MOONBLOOD")
if r.Artist != "Ultra" || r.Title != "Violence" {
t.Errorf("Artist/Title = %q/%q, want Ultra / Violence", r.Artist, r.Title)
@@ -46,8 +47,14 @@ func TestParseArtistWithHyphen(t *testing.T) {
if r.Edition != "EP" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2026 {
t.Errorf("tail fields wrong: %+v", r)
}
if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "All_My_Life" {
t.Errorf("UnknownTokens = %v, want [All_My_Life]", r.UnknownTokens)
if len(r.TitleExtra) != 1 || r.TitleExtra[0] != "All_My_Life" {
t.Errorf("TitleExtra = %v, want [All_My_Life]", r.TitleExtra)
}
if !contains(r.Warnings, "suspected title continuation") {
t.Errorf("Warnings = %v, want suspected title continuation", r.Warnings)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
}
}
@@ -123,12 +130,16 @@ func TestParseCaseInsensitiveDictionaries(t *testing.T) {
}
func TestParseUnknownTailTokenReported(t *testing.T) {
// Невідомий токен між двома відомими маркерами → у UnknownTokens.
r := Parse("Artist-Some_Title-SPLIT-FOO-16BIT-WEB-FLAC-2025-MOONBLOOD")
// Невідомий токен ПІСЛЯ останнього словникового маркера (за ним уже
// нічого відомого немає) → у UnknownTokens.
r := Parse("Artist-Some_Title-16BIT-WEB-FLAC-FOO-2025-MOONBLOOD")
if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "FOO" {
t.Errorf("UnknownTokens = %v, want [FOO]", r.UnknownTokens)
}
if r.Edition != "SPLIT" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2025 {
if len(r.TitleExtra) != 0 {
t.Errorf("TitleExtra = %v, want empty", r.TitleExtra)
}
if r.Edition != "" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2025 {
t.Errorf("tail fields wrong: %+v", r)
}
if r.Artist != "Artist" || r.Title != "Some_Title" {
@@ -136,6 +147,24 @@ func TestParseUnknownTailTokenReported(t *testing.T) {
}
}
func TestParseSuspectedTitleContinuation(t *testing.T) {
// Невідомий токен МІЖ title-зоною і хвостом (за ним далі йдуть
// словникові маркери) → не UnknownTokens, а TitleExtra + warning.
r := Parse("Artist-Some_Title-SPLIT-FOO-16BIT-WEB-FLAC-2025-MOONBLOOD")
if len(r.TitleExtra) != 1 || r.TitleExtra[0] != "FOO" {
t.Errorf("TitleExtra = %v, want [FOO]", r.TitleExtra)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
}
if !contains(r.Warnings, "suspected title continuation") {
t.Errorf("Warnings = %v, want suspected title continuation", r.Warnings)
}
if r.Edition != "SPLIT" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2025 {
t.Errorf("tail fields wrong: %+v", r)
}
}
func TestParseCoreTokensNeverReported(t *testing.T) {
// Артист/тайтл ніколи не потрапляють в UnknownTokens.
r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD")
@@ -254,7 +283,8 @@ func TestParseCatalogNumberWithDotAndRoman(t *testing.T) {
func TestParseSplitKeepsParensAndSplitsTitles(t *testing.T) {
// [1]=Title (фіксовано), а Evolution — не словниковий токен у середині:
// потрапляє в UnknownTokens. (SMG409) — катало.
// за ним ідуть словникові маркери → suspected title continuation.
// (SMG409) — катало, залишається каталом.
r := Parse("Ferosity-De-Evolution-(SMG409)-CD-FLAC-2025-86D")
if r.CatalogNo != "SMG409" {
t.Errorf("CatalogNo = %q, want SMG409", r.CatalogNo)
@@ -262,8 +292,11 @@ func TestParseSplitKeepsParensAndSplitsTitles(t *testing.T) {
if r.Artist != "Ferosity" || r.Title != "De" {
t.Errorf("Artist/Title = %q/%q, want Ferosity / De", r.Artist, r.Title)
}
if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "Evolution" {
t.Errorf("UnknownTokens = %v, want [Evolution]", r.UnknownTokens)
if len(r.TitleExtra) != 1 || r.TitleExtra[0] != "Evolution" {
t.Errorf("TitleExtra = %v, want [Evolution]", r.TitleExtra)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
}
}
@@ -330,13 +363,21 @@ func TestParseCompoundEditionRecognized(t *testing.T) {
}
func TestParseMixedCaseTitleNeverReported(t *testing.T) {
// [1]=Title (один токен), решта, що не словник — UnknownTokens.
// [1]=Title (один токен). Хвіст назви (_ і Sky_Burial) — не словник,
// але за ним ідуть словникові маркери → suspected title continuation,
// не UnknownTokens.
r := Parse("Enthrallment-Against_the_Will_to_Live_-_Sky_Burial-(RTM103)-CD-FLAC-2021-86D")
if r.Artist != "Enthrallment" || r.Title != "Against_the_Will_to_Live_" {
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
}
if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "_Sky_Burial" {
t.Errorf("UnknownTokens = %v, want [_Sky_Burial]", r.UnknownTokens)
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
}
if len(r.TitleExtra) != 1 || r.TitleExtra[0] != "_Sky_Burial" {
t.Errorf("TitleExtra = %v, want [_Sky_Burial]", r.TitleExtra)
}
if r.CatalogNo != "RTM103" {
t.Errorf("CatalogNo = %q, want RTM103", r.CatalogNo)
}
}
@@ -365,3 +406,79 @@ func TestParseParenthesizedTitleNotCatNo(t *testing.T) {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
}
}
func TestParseArtistDictionaryCoversReleases(t *testing.T) {
// artists.txt: "Iku-Turso" у словнику → Artist = 2 токени,
// Title = [2], хвостова зона зсувається і парситься як завжди.
r := Parse("Iku-Turso-Ikuinen_Steelfest_MMXXII_Live-24BIT-WEB-FLAC-2016-MOONBLOOD")
if r.Artist != "Iku-Turso" {
t.Errorf("Artist = %q, want Iku-Turso", r.Artist)
}
if r.Title != "Ikuinen_Steelfest_MMXXII_Live" {
t.Errorf("Title = %q, want Ikuinen_Steelfest_MMXXII_Live", r.Title)
}
if r.BitDepth != "24BIT" || r.Source != "WEB" || r.Format != "FLAC" || r.Year != 2016 {
t.Errorf("tail fields wrong: %+v", r)
}
if len(r.UnknownTokens) != 0 || len(r.TitleExtra) != 0 || len(r.Warnings) != 0 {
t.Errorf("Unknown=%v Extra=%v Warnings=%v, want empty",
r.UnknownTokens, r.TitleExtra, r.Warnings)
}
}
func TestParseOverrideWinsOverPositionalModel(t *testing.T) {
// overrides.txt (найвищий пріоритет): точний lookup по Raw
// фіксує Artist/Title; хвіст парситься як завжди.
raw := "Milicia_Oscura-Anti-Cristo-ES-16BIT-WEB-FLAC-2024-GRAVEWAVE"
r := Parse(raw)
if r.Artist != "Milicia_Oscura" {
t.Errorf("Artist = %q, want Milicia_Oscura", r.Artist)
}
if r.Title != "Anti-Cristo" {
t.Errorf("Title = %q, want Anti-Cristo", r.Title)
}
if r.Language != "ES" || r.BitDepth != "16BIT" || r.Source != "WEB" ||
r.Format != "FLAC" || r.Year != 2024 || r.Group != "GRAVEWAVE" {
t.Errorf("tail fields wrong: %+v", r)
}
if len(r.UnknownTokens) != 0 || len(r.TitleExtra) != 0 || len(r.Warnings) != 0 {
t.Errorf("Unknown=%v Extra=%v Warnings=%v, want empty",
r.UnknownTokens, r.TitleExtra, r.Warnings)
}
}
func TestParseTypeNormalizedSourceStaysRaw(t *testing.T) {
// CDEP у source.txt: Source лишається сирим, Type нормалізується в EP.
r := Parse("Black_Ceremonial_Kult-Crowned_in_Chaos-CDEP-FLAC-2021-MOONBLOOD")
if r.Type != "EP" {
t.Errorf("Type = %q, want EP", r.Type)
}
if r.Source != "CDEP" {
t.Errorf("Source = %q, want raw CDEP", r.Source)
}
}
func TestParseTypeFirstMatchWins(t *testing.T) {
// REMASTERED_EP → EP; наступний типовий токен не перезаписує.
r := Parse("Some_Artist-Some_Title-REMASTERED_EP-SINGLE-FLAC-2021-MOONBLOOD")
if r.Type != "EP" {
t.Errorf("Type = %q, want EP (first match wins)", r.Type)
}
if r.Edition != "REMASTERED_EP" {
t.Errorf("Edition = %q, want REMASTERED_EP", r.Edition)
}
}
func TestAnalyzeTypeDistribution(t *testing.T) {
stats := AnalyzeTitles([]string{
"Some_Artist-Some_Title-CDEP-FLAC-2021-MOONBLOOD",
"Other_Artist-Other_Title-EP-FLAC-2022-MOONBLOOD",
"Third_Artist-Third_Title-SPLIT-FLAC-2023-MOONBLOOD",
})
if stats.TypeDistribution["EP"] != 2 {
t.Errorf("TypeDistribution[EP] = %d, want 2", stats.TypeDistribution["EP"])
}
if stats.TypeDistribution["Split"] != 1 {
t.Errorf("TypeDistribution[Split] = %d, want 1", stats.TypeDistribution["Split"])
}
}