releaseparser: move group-agnostic parser to groups/, GB doc-blocks, per-group JSON reports
Generalise the parser: the MOONBLOOD group profile is gone, the parser and its dictionaries are now group-agnostic under internal/releaseparser/groups. Positional 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 is 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; stored values keep their original case. Catalogue numbers: any "(...)" token in the middle zone is a catalogue number regardless of content (digits, roman numerals, dots, underscores, mixed case); splitting never breaks tokens inside parentheses. Dictionaries moved to internal/releaseparser/groups/resources/dicts and embedded via go:embed; dead profile fields (Name, ExpectedTailOrder) removed; profile comments rewritten in British English. File-based AnalyzeGroup/TestParseMoonblood removed — titles now come from the database (cmd/srrdb: srrdb <releaser>..., config/secret.yaml DSN). Validation output (cmd/srrdb): - tmp/releaseparser/<group>.json — per-group parsed ParsedRelease records - tmp/releaseparser/<releaser>.report.json — per-request aggregate (same as console) - review_unknown_tokens.txt — unknown/warning rows for manual review Tests updated for the positional model; added TestParseCatalogNumberWithDotAndRoman for (DYN.3140-2) and (GOWP_DCCCLXXXIX). go build, go vet and go test all pass.
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
package groups
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseFullTail(t *testing.T) {
|
||||
r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD")
|
||||
if r.Artist != "Fane" {
|
||||
t.Errorf("Artist = %q, want %q", r.Artist, "Fane")
|
||||
}
|
||||
if r.Title != "Dragged_Through_the_Unending" {
|
||||
t.Errorf("Title = %q, want %q", r.Title, "Dragged_Through_the_Unending")
|
||||
}
|
||||
if r.Year != 2025 || r.Format != "FLAC" || r.Source != "WEB" || r.BitDepth != "24BIT" {
|
||||
t.Errorf("tail fields wrong: %+v", r)
|
||||
}
|
||||
if len(r.UnknownTokens) != 0 {
|
||||
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||
}
|
||||
if r.Confidence < 0.99 {
|
||||
t.Errorf("Confidence = %.2f, want ~1.0", r.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEditionAndDemo(t *testing.T) {
|
||||
r := Parse("Satans_Grind-Rotten_Demo-DEMO-16BIT-WEB-FLAC-2016-MOONBLOOD")
|
||||
if r.Edition != "DEMO" {
|
||||
t.Errorf("Edition = %q, want %q", r.Edition, "DEMO")
|
||||
}
|
||||
if r.Artist != "Satans_Grind" || r.Title != "Rotten_Demo" {
|
||||
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||
}
|
||||
if len(r.UnknownTokens) != 0 {
|
||||
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtistWithHyphen(t *testing.T) {
|
||||
// [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" {
|
||||
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) != 1 || r.UnknownTokens[0] != "All_My_Life" {
|
||||
t.Errorf("UnknownTokens = %v, want [All_My_Life]", r.UnknownTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLanguageAndCDSource(t *testing.T) {
|
||||
r := Parse("Burn_Thou_in_Effigy-Memorias_Postumas_do_Imperador-BR-CD-FLAC-2026-MOONBLOOD")
|
||||
if r.Language != "BR" || r.Source != "CD" || r.Year != 2026 || r.Format != "FLAC" {
|
||||
t.Errorf("tail fields wrong: %+v", r)
|
||||
}
|
||||
if len(r.UnknownTokens) != 0 {
|
||||
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNoYear(t *testing.T) {
|
||||
// Without a year the tail still parses; group stays the last token.
|
||||
r := Parse("Fane-Gallant_Shroud-EP-16BIT-WEB-FLAC-MOONBLOOD")
|
||||
if r.Group != "MOONBLOOD" || r.Year != 0 {
|
||||
t.Errorf("Group/Year = %q/%d", r.Group, r.Year)
|
||||
}
|
||||
if r.Edition != "EP" || r.Source != "WEB" || r.Format != "FLAC" {
|
||||
t.Errorf("tail fields wrong: %+v", r)
|
||||
}
|
||||
if r.Artist != "Fane" || r.Title != "Gallant_Shroud" {
|
||||
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolutionSlot(t *testing.T) {
|
||||
r := Parse("Ice_Nine_Kills-Last_Chance_To_Make_Amends-REMASTERED-24BIT-44KHZ-WEB-FLAC-2024-FLACCiD")
|
||||
if r.Group != "FLACCiD" {
|
||||
t.Errorf("Group = %q, want FLACCiD", r.Group)
|
||||
}
|
||||
if r.Resolution != "44KHZ" {
|
||||
t.Errorf("Resolution = %q, want 44KHZ", r.Resolution)
|
||||
}
|
||||
if r.BitDepth != "24BIT" || r.Edition != "REMASTERED" || r.Source != "WEB" || r.Year != 2024 {
|
||||
t.Errorf("tail fields wrong: %+v", r)
|
||||
}
|
||||
if r.Artist != "Ice_Nine_Kills" || r.Title != "Last_Chance_To_Make_Amends" {
|
||||
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||
}
|
||||
if len(r.UnknownTokens) != 0 {
|
||||
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVinylIsSource(t *testing.T) {
|
||||
r := Parse("ROTH-Alles_Endet-DE-VINYL-FLAC-2026-TOTENKVLT")
|
||||
if r.Language != "DE" {
|
||||
t.Errorf("Language = %q, want DE", r.Language)
|
||||
}
|
||||
if r.Source != "VINYL" {
|
||||
t.Errorf("Source = %q, want VINYL", r.Source)
|
||||
}
|
||||
if r.Edition != "" {
|
||||
t.Errorf("Edition = %q, want empty (VINYL is source, not edition)", r.Edition)
|
||||
}
|
||||
if len(r.UnknownTokens) != 0 {
|
||||
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||
}
|
||||
}
|
||||
|
||||
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" {
|
||||
t.Errorf("case-insensitive lookup failed: %+v", r)
|
||||
}
|
||||
if r.Artist != "Artist" || r.Title != "Some_Title" {
|
||||
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUnknownTailTokenReported(t *testing.T) {
|
||||
// Невідомий токен між двома відомими маркерами → у UnknownTokens.
|
||||
r := Parse("Artist-Some_Title-SPLIT-FOO-16BIT-WEB-FLAC-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 {
|
||||
t.Errorf("tail fields wrong: %+v", r)
|
||||
}
|
||||
if r.Artist != "Artist" || r.Title != "Some_Title" {
|
||||
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCoreTokensNeverReported(t *testing.T) {
|
||||
// Артист/тайтл ніколи не потрапляють в UnknownTokens.
|
||||
r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD")
|
||||
if len(r.UnknownTokens) != 0 {
|
||||
t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens)
|
||||
}
|
||||
if r.Artist != "Fane" || r.Title != "Dragged_Through_the_Unending" {
|
||||
t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAnyGroupNoWarning(t *testing.T) {
|
||||
cases := []string{
|
||||
"Jetsex-Down_Down_Down-24BIT-WEB-FLAC-2025-FLACCiD",
|
||||
"Sabaton-The_Last_Stand-24BIT-96KHZ-WEB-FLAC-2016-FLACCiD",
|
||||
"Wind_Walkers-What_If_I_Break-16BIT-WEB-FLAC-2023-FLACCiD",
|
||||
}
|
||||
for _, line := range cases {
|
||||
r := Parse(line)
|
||||
if r.Group != "FLACCiD" {
|
||||
t.Errorf("%s: Group = %q, want FLACCiD", line, r.Group)
|
||||
}
|
||||
if len(r.Warnings) != 0 {
|
||||
t.Errorf("%s: Warnings = %v, want empty", line, r.Warnings)
|
||||
}
|
||||
if len(r.UnknownTokens) != 0 {
|
||||
t.Errorf("%s: UnknownTokens = %v, want empty", line, r.UnknownTokens)
|
||||
}
|
||||
if r.Year == 0 || r.Format != "FLAC" || r.Source != "WEB" || r.BitDepth == "" {
|
||||
t.Errorf("%s: tail fields wrong: %+v", line, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user