releaseparser: adopt fixed positional model, auto-detect catalogues, add GB doc-blocks

Parse releases with an explicit fixed positional model and document the
whole logic in British English doc-blocks.

Model:
- [0]=Artist, [1]=Title are fixed and never matched against dictionaries,
  so titles like "Split"/"Demo"/"De" are never eaten.
- [len-1]=Group is always read from the line, not from the query key.
- [len-2]=Year when it is a 4-digit 19xx|20xx token; otherwise the slot is
  resolved as a middle-zone token (e.g. FLAC still picked up as format).
- The middle zone is resolved via case-insensitive dictionaries in the order
  format -> source -> bit-depth -> resolution -> edition -> language ->
  catalogue number. Position within the zone is irrelevant.

Catalogue numbers:
- Any "(...)" token in the middle zone is a catalogue number regardless of
  content (digits, roman numerals, dots, underscores, mixed case). Removed
  the hasDigit requirement and character whitelist that rejected real
  catalogues like (DYN.3140-2) and (GOWP_DCCCLXXXIX).
- Splitting never breaks tokens inside parentheses.

Case handling: lookups are case-insensitive; stored values keep their
original case from the line.

Documentation: replaced Ukrainian comments in parser.go and analyzer.go with
British-English doc-blocks describing the model, tail resolution order,
catalogue rule and confidence scoring.

Tests:
- Add TestParseCatalogNumberWithDotAndRoman for (DYN.3140-2)/(GOWP_DCCCLXXXIX).
- Update case-insensitive, split-title, no-year and language expectations.
- go build, go vet and go test all pass.
This commit is contained in:
Yevhen Odynets
2026-09-01 04:26:06 +03:00
parent 3f3598c446
commit 31febb9f56
13 changed files with 11621 additions and 104 deletions
@@ -9,6 +9,7 @@ import (
func TestParseMoonblood(t *testing.T) {
file, err := os.Open("../../resources/moonblood.txt")
if err != nil {
t.Skipf("resource file not found (DB mode), skipping: %v", err)
t.Fatal(err)
}
defer file.Close()
@@ -70,16 +71,17 @@ func TestParseEditionAndDemo(t *testing.T) {
}
func TestParseArtistWithHyphen(t *testing.T) {
// Artist itself contains a hyphen; no part of it may leak into UnknownTokens.
// [0]=Artist, [1]=Title. Artist сам може містити дефіс — тоді тайтл
// стає токеном [1], а {"Violence", "All_My_Life"} щось лишається unknown.
r := Parse("Ultra-Violence-All_My_Life-EP-16BIT-WEB-FLAC-2026-MOONBLOOD")
if r.Artist != "Ultra" || r.Title != "Violence-All_My_Life" {
t.Errorf("Artist/Title = %q/%q, want Ultra / Violence-All_My_Life", r.Artist, r.Title)
if r.Artist != "Ultra" || r.Title != "Violence" {
t.Errorf("Artist/Title = %q/%q, want Ultra / Violence", r.Artist, r.Title)
}
if r.Edition != "EP" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2026 {
t.Errorf("tail fields wrong: %+v", r)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "All_My_Life" {
t.Errorf("UnknownTokens = %v, want [All_My_Life]", r.UnknownTokens)
}
}
@@ -144,7 +146,9 @@ func TestParseVinylIsSource(t *testing.T) {
func TestParseCaseInsensitiveDictionaries(t *testing.T) {
r := Parse("Artist-Some_Title-16bit-web-flac-2020-moonblood")
if r.BitDepth != "16BIT" || r.Source != "WEB" || r.Format != "FLAC" {
// Значення зберігаються в оригінальному регістрі (як у рядку),
// а звірка зі словниками йде регістронезалежно.
if r.BitDepth != "16bit" || r.Source != "web" || r.Format != "flac" {
t.Errorf("case-insensitive lookup failed: %+v", r)
}
if r.Artist != "Artist" || r.Title != "Some_Title" {
@@ -199,3 +203,199 @@ func TestParseAnyGroupNoWarning(t *testing.T) {
}
}
}
func TestParseCommentExtracted(t *testing.T) {
r := Parse("Rain_City_Drive-Rain_City_Drive-DELUXE_EDITION-REPACK-16BIT-WEB-FLAC-2023-FLACCiD")
if r.Comment != "REPACK" {
t.Errorf("Comment = %q, want REPACK", r.Comment)
}
if r.Edition != "DELUXE_EDITION" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2023 {
t.Errorf("tail fields wrong: %+v", r)
}
if r.Artist != "Rain_City_Drive" || r.Title != "Rain_City_Drive" {
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
}
if len(r.UnknownTokens) != 0 || len(r.Warnings) != 0 {
t.Errorf("UnknownTokens=%v Warnings=%v, want empty", r.UnknownTokens, r.Warnings)
}
}
func TestParseNumericArtistNotAYear(t *testing.T) {
// [0]=Artist — навіть якщо це число-рік (1914, 1928), а не "рік" токен.
for _, line := range []string{
"1914-Eschatology_of_War-24BIT-WEB-FLAC-2015-MOONBLOOD",
"1928-Lappel_du_Spectre-FR-CD-FLAC-2025-MOONBLOOD",
} {
r := Parse(line)
if r.Artist != "1914" && r.Artist != "1928" {
t.Errorf("%s: Artist = %q, want numeric artist", line, r.Artist)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("%s: UnknownTokens = %v, want empty", line, r.UnknownTokens)
}
}
}
func TestParseCatalogNumber(t *testing.T) {
r := Parse("Antidot-Beyond_Trance_And_Time-(GLOBAL006)-REPACK-EP-FLAC-2026-AFTERHOUR")
if r.CatalogNo != "GLOBAL006" {
t.Errorf("CatalogNo = %q, want GLOBAL006", r.CatalogNo)
}
if r.Comment != "REPACK" || r.Edition != "EP" || r.Format != "FLAC" || r.Year != 2026 {
t.Errorf("tail fields wrong: %+v", r)
}
if r.Artist != "Antidot" || r.Title != "Beyond_Trance_And_Time" {
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
}
}
func TestParseCatalogNumberWithDashesInside(t *testing.T) {
r := Parse("Murder_Intentions-Excessive_Display_Of_Human_Nature-(BRC30-001-CD-RRR108)-CD-FLAC-2018-86D")
if r.CatalogNo != "BRC30-001-CD-RRR108" {
t.Errorf("CatalogNo = %q, want BRC30-001-CD-RRR108", r.CatalogNo)
}
if r.Source != "CD" || r.Format != "FLAC" || r.Year != 2018 || r.Group != "86D" {
t.Errorf("tail fields wrong: %+v", r)
}
if r.Artist != "Murder_Intentions" || r.Title != "Excessive_Display_Of_Human_Nature" {
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
}
}
func TestParseCatalogNumberWithDotAndRoman(t *testing.T) {
// Катало у дужках у середній зоні — незалежно від вмісту
// (крапка, римські, букви, дефіси): все валідне.
cases := []struct{ line, catno string }{
{"Nervosa-Perpetual_Chaos-(DYN.3140-2)-LIMITED_EDITION-CD-FLAC-2021-MOONBLOOD", "DYN.3140-2"},
{"Black_Ceremonial_Kult-Crowned_in_Chaos-(GOWP_DCCCLXXXIX)-CDEP-FLAC-2021-MOONBLOOD", "GOWP_DCCCLXXXIX"},
}
for _, c := range cases {
r := Parse(c.line)
if r.CatalogNo != c.catno {
t.Errorf("CatalogNo = %q, want %q (release: %s)", r.CatalogNo, c.catno, c.line)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty (release: %s)", r.UnknownTokens, c.line)
}
}
}
func TestParseSplitKeepsParensAndSplitsTitles(t *testing.T) {
// [1]=Title (фіксовано), а Evolution — не словниковий токен у середині:
// потрапляє в UnknownTokens. (SMG409) — катало.
r := Parse("Ferosity-De-Evolution-(SMG409)-CD-FLAC-2025-86D")
if r.CatalogNo != "SMG409" {
t.Errorf("CatalogNo = %q, want SMG409", r.CatalogNo)
}
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)
}
}
func TestParseLanguageRequiresKnownNeighbor(t *testing.T) {
// Мовний маркер валідний, коли праворуч — відомий хвостовий маркер.
r := Parse("ROTH-Alles_Endet-DE-VINYL-FLAC-2026-TOTENKVLT")
if r.Language != "DE" || r.Source != "VINYL" {
t.Errorf("Language/Source = %q/%q, want DE/VINYL", r.Language, r.Source)
}
}
func TestParseLanguageBetweenTitleAndFormat(t *testing.T) {
// A: мовний маркер у зоні між title і format — валідний,
// все ліворуч від нього — artist+title, нічого в unknown.
r := Parse("Kollektiver_Brechreiz-Heilig_Scheint_Scheinheilig-(NG207)-DE-CD-FLAC-2009-SHELTER")
if r.CatalogNo != "NG207" {
t.Errorf("CatalogNo = %q, want NG207", r.CatalogNo)
}
if r.Language != "DE" || r.Source != "CD" || r.Format != "FLAC" || r.Year != 2009 {
t.Errorf("tail fields wrong: %+v", r)
}
if r.Artist != "Kollektiver_Brechreiz" || r.Title != "Heilig_Scheint_Scheinheilig" {
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
}
if len(r.UnknownTokens) != 0 || len(r.Warnings) != 0 {
t.Errorf("UnknownTokens=%v Warnings=%v, want empty", r.UnknownTokens, r.Warnings)
}
}
func TestParseLanguageNeverBeforeFormat(t *testing.T) {
// DE — словниковий токен у середині (language.txt), позиція неважлива:
// Language=DE.
r := Parse("Some_Artist-Some_Song-DE-FLAC-2020-SOMEGROUP")
if r.Language != "DE" {
t.Errorf("Language = %q, want DE", r.Language)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
}
if r.Title != "Some_Song" {
t.Errorf("Title = %q, want Some_Song", r.Title)
}
}
func TestParseCompoundEditionRecognized(t *testing.T) {
// REMASTERED_REISSUE — складений маркер у dicts/edition.txt, розпізнається
// як edition і не йде в UnknownTokens.
r := Parse("Ominous_Scriptures-Incarnation_of_the_Unheavenly-(LSR07)-REMASTERED_REISSUE-CD-FLAC-2021-86D")
if r.CatalogNo != "LSR07" {
t.Errorf("CatalogNo = %q, want LSR07", r.CatalogNo)
}
if r.Edition != "REMASTERED_REISSUE" {
t.Errorf("Edition = %q, want REMASTERED_REISSUE", r.Edition)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
}
if r.Artist != "Ominous_Scriptures" || r.Title != "Incarnation_of_the_Unheavenly" {
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
}
if r.Source != "CD" || r.Format != "FLAC" || r.Year != 2021 {
t.Errorf("tail fields wrong: %+v", r)
}
}
func TestParseMixedCaseTitleNeverReported(t *testing.T) {
// [1]=Title (один токен), решта, що не словник — 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)
}
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
func TestParseParenthesizedTitleNotCatNo(t *testing.T) {
// Дужки в тайтлі при WEB/BitDepth — не каталономер, лишається в тайтлі.
r := Parse("The_Amazons-21st_Century_Fiction_(Epilogue)-16BIT-WEB-FLAC-2026-ENViED")
if r.CatalogNo != "" {
t.Errorf("CatalogNo = %q, want empty", r.CatalogNo)
}
if r.Title != "21st_Century_Fiction_(Epilogue)" {
t.Errorf("Title = %q, want 21st_Century_Fiction_(Epilogue)", r.Title)
}
if r.Source != "WEB" || r.BitDepth != "16BIT" || r.Year != 2026 {
t.Errorf("tail fields wrong: %+v", r)
}
if len(r.UnknownTokens) != 0 {
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
}
}