From f2629f002c05631eba703f0b24a429413e57ad8e Mon Sep 17 00:00:00 2001 From: Yevhen Odynets <6lo5jp@proton.me> Date: Tue, 1 Sep 2026 11:28:19 +0300 Subject: [PATCH] releaseparser: move group-agnostic parser to groups/, GB doc-blocks, per-group JSON reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ..., config/secret.yaml DSN). Validation output (cmd/srrdb): - tmp/releaseparser/.json — per-group parsed ParsedRelease records - tmp/releaseparser/.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. --- .gitignore | 2 +- cmd/srrdb/main.go | 54 ++++++++++++-- .../groups/{moonblood => }/analyzer.go | 66 +++++++++-------- .../groups/moonblood/dicts/bitdepth.txt | 5 -- .../groups/moonblood/dicts/comment.txt | 6 -- .../groups/moonblood/dicts/edition.txt | 70 ------------------- .../groups/moonblood/dicts/format.txt | 3 - .../groups/moonblood/dicts/language.txt | 38 ---------- .../groups/moonblood/dicts/resolution.txt | 8 --- .../groups/moonblood/dicts/source.txt | 36 ---------- .../releaseparser/groups/moonblood/profile.go | 42 ----------- .../groups/{moonblood => }/parser.go | 4 +- .../groups/{moonblood => }/parser_test.go | 36 +--------- internal/releaseparser/groups/profile.go | 45 ++++++++++++ internal/releaseparser/types.go | 28 ++++---- 15 files changed, 150 insertions(+), 293 deletions(-) rename internal/releaseparser/groups/{moonblood => }/analyzer.go (76%) delete mode 100644 internal/releaseparser/groups/moonblood/dicts/bitdepth.txt delete mode 100644 internal/releaseparser/groups/moonblood/dicts/comment.txt delete mode 100644 internal/releaseparser/groups/moonblood/dicts/edition.txt delete mode 100644 internal/releaseparser/groups/moonblood/dicts/format.txt delete mode 100644 internal/releaseparser/groups/moonblood/dicts/language.txt delete mode 100644 internal/releaseparser/groups/moonblood/dicts/resolution.txt delete mode 100644 internal/releaseparser/groups/moonblood/dicts/source.txt delete mode 100644 internal/releaseparser/groups/moonblood/profile.go rename internal/releaseparser/groups/{moonblood => }/parser.go (99%) rename internal/releaseparser/groups/{moonblood => }/parser_test.go (94%) create mode 100644 internal/releaseparser/groups/profile.go diff --git a/.gitignore b/.gitignore index 86761c2..cc96208 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ vendor/ config/secret.yaml *.md mdb -internal/releaseparser/resources/* +internal/releaseparser/groups/resources/* review_unknown_tokens.txt srrdb.exe **/*.exe diff --git a/cmd/srrdb/main.go b/cmd/srrdb/main.go index 3dea15b..ab24350 100644 --- a/cmd/srrdb/main.go +++ b/cmd/srrdb/main.go @@ -5,13 +5,15 @@ import ( "fmt" "log" "os" + "path/filepath" + "strings" "github.com/jmoiron/sqlx" "github.com/spf13/viper" _ "github.com/go-sql-driver/mysql" - "git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser/groups/moonblood" + "git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser/groups" ) func main() { @@ -35,19 +37,33 @@ func main() { } defer dbase.Close() - out := make(map[string]moonblood.GroupStats, len(releasers)) + const outDir = "tmp/releaseparser" + if err := os.MkdirAll(outDir, 0o755); err != nil { + log.Fatal(err) + } + + out := make(map[string]groups.GroupStats, len(releasers)) for _, releaser := range releasers { var titles []string err = dbase.Select(&titles, `SELECT title FROM theamok_mdb.external_sources - WHERE type = 'prescene' AND releaser = ?`, releaser) + WHERE type = 'prescene' AND releaser = ? limit 1000`, releaser) if err != nil { log.Fatal(err) } - out[releaser] = moonblood.AnalyzeTitles(titles) + out[releaser] = groups.AnalyzeTitles(titles) + + // Per-group parsed records keyed by the actual group read from each + // line: every release with all resolved fields for manual validation. + for group, parsed := range groups.ParseByGroup(titles) { + writeJSON(filepath.Join(outDir, safeName(group)+".json"), parsed) + } + + // Per-request report identical to what is printed to the console. + writeJSON(filepath.Join(outDir, safeName(releaser)+".report.json"), out[releaser]) } data, err := json.MarshalIndent(out, "", " ") @@ -77,3 +93,33 @@ func main() { fmt.Println(string(data)) } + +// writeJSON writes value as an indented JSON document to the given path. +func writeJSON(path string, value any) { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + log.Fatalf("marshal %s: %v", path, err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + log.Fatalf("write %s: %v", path, err) + } +} + +// safeName strips characters that are unsafe in file names. +func safeName(s string) string { + repl := func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= 'A' && r <= 'Z': + return r + case r >= '0' && r <= '9': + return r + case r == '-' || r == '_' || r == '.': + return r + default: + return '_' + } + } + return strings.Map(repl, s) +} diff --git a/internal/releaseparser/groups/moonblood/analyzer.go b/internal/releaseparser/groups/analyzer.go similarity index 76% rename from internal/releaseparser/groups/moonblood/analyzer.go rename to internal/releaseparser/groups/analyzer.go index 15a420c..585e0a7 100644 --- a/internal/releaseparser/groups/moonblood/analyzer.go +++ b/internal/releaseparser/groups/analyzer.go @@ -1,8 +1,6 @@ -package moonblood +package groups import ( - "bufio" - "os" "sort" "strings" @@ -12,6 +10,7 @@ import ( // GroupStats summarises how a set of release lines parsed: field // distributions, unknown tokens and warnings aggregated by occurrence, and // the average parsing confidence. The slices are sorted by descending count. + type GroupStats struct { Total int YearDistribution map[int]int @@ -26,34 +25,11 @@ type GroupStats struct { AvgConfidence float64 } -// AnalyzeGroup parses the release lines from the local resource file. It is -// used for a quick local check of the dictionaries without touching a -// database. -func AnalyzeGroup() (GroupStats, error) { - file, err := os.Open("../../resources/moonblood.txt") - if err != nil { - return GroupStats{}, err - } - defer file.Close() - - var titles []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line != "" { - titles = append(titles, line) - } - } - if err := scanner.Err(); err != nil { - return GroupStats{}, err - } - - return AnalyzeTitles(titles), nil -} // AnalyzeTitles parses a set of release lines (for example fetched from the // database by a particular releaser key) and aggregates the results into a // GroupStats summary. + func AnalyzeTitles(titles []string) GroupStats { stats := GroupStats{ YearDistribution: make(map[int]int), @@ -67,10 +43,12 @@ func AnalyzeTitles(titles []string) GroupStats { WarningStats: []releaseparser.UnknownTokenStats{}, } + totalConfidence := 0.0 unknownMap := make(map[string]*releaseparser.UnknownTokenStats) warningMap := make(map[string]*releaseparser.UnknownTokenStats) + for _, title := range titles { result := Parse(title) stats.Total++ @@ -131,7 +109,6 @@ func AnalyzeTitles(titles []string) GroupStats { for _, tokenStats := range unknownMap { stats.UnknownTokens = append(stats.UnknownTokens, *tokenStats) } - sort.Slice(stats.UnknownTokens, func(i, j int) bool { return stats.UnknownTokens[i].Count > stats.UnknownTokens[j].Count }) @@ -149,3 +126,36 @@ func AnalyzeTitles(titles []string) GroupStats { return stats } + + +// AnalyzeByGroup parses a set of release lines and returns per-group +// statistics keyed by the actual group read from each line (not from the +// query key, which may cover several groups). It is used to validate parsing +// quality against the original data, grouped by the real releaser name. +func AnalyzeByGroup(titles []string) map[string]GroupStats { + byGroup := make(map[string][]string) + for _, title := range titles { + group := Parse(title).Group + byGroup[group] = append(byGroup[group], title) + } + + out := make(map[string]GroupStats, len(byGroup)) + for group, lines := range byGroup { + out[group] = AnalyzeTitles(lines) + } + return out +} + + +// ParseByGroup parses a set of release lines into a per-group list of fully +// populated ParsedRelease records, keyed by the actual group read from each +// line. It is used to validate parsing quality field-by-field against the +// original data. +func ParseByGroup(titles []string) map[string][]releaseparser.ParsedRelease { + byGroup := make(map[string][]releaseparser.ParsedRelease) + for _, title := range titles { + result := Parse(title) + byGroup[result.Group] = append(byGroup[result.Group], result) + } + return byGroup +} diff --git a/internal/releaseparser/groups/moonblood/dicts/bitdepth.txt b/internal/releaseparser/groups/moonblood/dicts/bitdepth.txt deleted file mode 100644 index 0c539ac..0000000 --- a/internal/releaseparser/groups/moonblood/dicts/bitdepth.txt +++ /dev/null @@ -1,5 +0,0 @@ -# {bit_depth} -16BIT -24BIT -32BIT -DE16BIT diff --git a/internal/releaseparser/groups/moonblood/dicts/comment.txt b/internal/releaseparser/groups/moonblood/dicts/comment.txt deleted file mode 100644 index 20e3f4c..0000000 --- a/internal/releaseparser/groups/moonblood/dicts/comment.txt +++ /dev/null @@ -1,6 +0,0 @@ -REPACK -REAL_REPACK -READNFO -PROPER -RERIP -199X \ No newline at end of file diff --git a/internal/releaseparser/groups/moonblood/dicts/edition.txt b/internal/releaseparser/groups/moonblood/dicts/edition.txt deleted file mode 100644 index c93f391..0000000 --- a/internal/releaseparser/groups/moonblood/dicts/edition.txt +++ /dev/null @@ -1,70 +0,0 @@ -# {edition} — тип видання -EP -DEMO -SPLIT -REISSUE -DELUXE_EDITION -REMASTERED -BOXSET -SINGLE -PROMO -REISSUE_REMASTERED -REMASTERED_25TH_ANNIVERSARY_EDITION -REMASTERED_20TH_ANNIVERSARY_EDITION -20TH_ANNIVERSARY_EDITION_REMASTERED -Deluxe_Edition -LIMITED_EDITION_BOXSET -REMASTERED_EP -10TH_ANNIVERSARY_EDITION -OST -XX_Legacy_Edition -REISSUE_DEMO -JP_Deluxe_Edition -Remastered_Edition -Anniversary_Edition -Limited_Edition -CDS -CDM -RETAIL -JP_RETAIL -JP_REISSUE -DEMO_REMASTERED -EU_RETAIL -DE_RETAIL -REISSUE_DELUXE_EDITION -RETAIL_REISSUE -SPECIAL_EDITION -SPECIAL_COLLECTORS_EDITION_REMASTERED -30_YEARS_SPECIAL_COLLECTORS_EDITION_REMASTERED -LIMITED_EDITION_REMASTERED -25th_Anniversary_Edition -DEMO_REISSUE -Ultimate_Edition -Issue_255 -Issue_254 -Issue_198 -7INCH_VINYL -REMASTERED_REISSUE -PROMO_DEMO -ES_RETAIL -BOX_SET -BONUS -REISSUE_EP -30th_Anniversary -REDUX -REMASTERED_BOXSET -MAG -REISSUE_BOXSET -30TH_ANNIVERSARY_EDITION_REMASTERED -DELUXE -Deluxe_Edition_Reissue -INSTRUMENTAL -REISSUE_EXPANDED_EDITION -BOOTLEG -EXPANDED_EDITION -ADVANCE -15TH_ANNIVERSARY_DELUXE_EDITION -TOUR_EDITION -REMASTERED_DEMO -REMASTERED_DELUXE_EDITION -30TH_ANNIVERSARY_EDITION \ No newline at end of file diff --git a/internal/releaseparser/groups/moonblood/dicts/format.txt b/internal/releaseparser/groups/moonblood/dicts/format.txt deleted file mode 100644 index b7c21f2..0000000 --- a/internal/releaseparser/groups/moonblood/dicts/format.txt +++ /dev/null @@ -1,3 +0,0 @@ -# {format} — аудіоформат -# один токен на рядок, регістр не має значення -FLAC diff --git a/internal/releaseparser/groups/moonblood/dicts/language.txt b/internal/releaseparser/groups/moonblood/dicts/language.txt deleted file mode 100644 index 681f69e..0000000 --- a/internal/releaseparser/groups/moonblood/dicts/language.txt +++ /dev/null @@ -1,38 +0,0 @@ -# {language} — мовні маркери -DE -FR -ES -SK -FI -SE -BR -PL -NL -NO -PT -IT -HU -DK -AT -IS -UA -JP -GR -CN -RU -ID -PH -RO -LT -BY -CZ -SP -IR -FO -RS -DA -HI -CH -LU -CY -EE \ No newline at end of file diff --git a/internal/releaseparser/groups/moonblood/dicts/resolution.txt b/internal/releaseparser/groups/moonblood/dicts/resolution.txt deleted file mode 100644 index fda0157..0000000 --- a/internal/releaseparser/groups/moonblood/dicts/resolution.txt +++ /dev/null @@ -1,8 +0,0 @@ -# {resolution} — частота дискретизації -44KHZ -48KHZ -48.0kHz -88KHZ -96KHZ -176KHZ -192KHZ \ No newline at end of file diff --git a/internal/releaseparser/groups/moonblood/dicts/source.txt b/internal/releaseparser/groups/moonblood/dicts/source.txt deleted file mode 100644 index 3d16cd0..0000000 --- a/internal/releaseparser/groups/moonblood/dicts/source.txt +++ /dev/null @@ -1,36 +0,0 @@ -# {source} — джерело релізу -# VINYL сюди входить як source (за домовленістю, не edition) -WEB -CD -2CD -CDEP -VINYL -TAPE -CDR -17CD -3CD -4CD -18CD -3CDEP -DVD -7CD -2CDR -10CD -6CD -2CDEP -2VINYL -CDREP -LP -2LP -3VINYL -3TAPE -13CD -13CDR -10INCH_VINYL -25CD -WEBFLAC -2TAPE -BLURAY -VLS -MCD -24BIT_WEB \ No newline at end of file diff --git a/internal/releaseparser/groups/moonblood/profile.go b/internal/releaseparser/groups/moonblood/profile.go deleted file mode 100644 index 63babdc..0000000 --- a/internal/releaseparser/groups/moonblood/profile.go +++ /dev/null @@ -1,42 +0,0 @@ -package moonblood - -import ( - "embed" - "strings" - - "git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser" -) - -// Словники токенів лежать у dicts/*.txt — один токен на рядок. -// Їх можна редагувати без перекомпіляції логіки (embed тягне їх у бінарник на збірці). - -//go:embed dicts/*.txt -var dictFS embed.FS - -func loadDict(name string) map[string]bool { - data, err := dictFS.ReadFile("dicts/" + name + ".txt") - if err != nil { - panic("moonblood: cannot load dictionary " + name + ": " + err.Error()) - } - out := make(map[string]bool) - for _, line := range strings.Split(string(data), "\n") { - line = strings.ToUpper(strings.TrimSpace(line)) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - out[line] = true - } - return out -} - -var Profile = releaseparser.GroupProfile{ - Name: "MOONBLOOD", - ExpectedTailOrder: []string{"bit_depth", "resolution", "source", "format", "year", "group"}, - FormatTokens: loadDict("format"), - SourceTokens: loadDict("source"), - BitDepthTokens: loadDict("bitdepth"), - ResolutionTokens: loadDict("resolution"), - LanguageMarkers: loadDict("language"), - EditionTokens: loadDict("edition"), - CommentTokens: loadDict("comment"), -} diff --git a/internal/releaseparser/groups/moonblood/parser.go b/internal/releaseparser/groups/parser.go similarity index 99% rename from internal/releaseparser/groups/moonblood/parser.go rename to internal/releaseparser/groups/parser.go index 63fdd8a..38b888b 100644 --- a/internal/releaseparser/groups/moonblood/parser.go +++ b/internal/releaseparser/groups/parser.go @@ -1,4 +1,4 @@ -package moonblood +package groups import ( "regexp" @@ -110,7 +110,7 @@ func split(raw string) []string { // list and returns the cleaned list together with the removed comments. func extractComments(tokens []string) ([]string, []string) { out := make([]string, 0, len(tokens)) - comments := []string{} + var comments []string for _, tok := range tokens { if Profile.CommentTokens[strings.ToUpper(tok)] { comments = append(comments, tok) diff --git a/internal/releaseparser/groups/moonblood/parser_test.go b/internal/releaseparser/groups/parser_test.go similarity index 94% rename from internal/releaseparser/groups/moonblood/parser_test.go rename to internal/releaseparser/groups/parser_test.go index bbbe1e2..7d12a76 100644 --- a/internal/releaseparser/groups/moonblood/parser_test.go +++ b/internal/releaseparser/groups/parser_test.go @@ -1,43 +1,9 @@ -package moonblood +package groups import ( - "bufio" - "os" "testing" ) -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() - - scanner := bufio.NewScanner(file) - total, success := 0, 0 - - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - - result := Parse(line) - total++ - - if result.Group == "MOONBLOOD" && result.Year != 0 && result.Format == "FLAC" { - success++ - } - - if result.Confidence < 0.7 { - t.Logf("Low confidence: %s (%.2f)", line, result.Confidence) - } - } - - t.Logf("Parsed %d releases, %d successful (%.1f%%)", total, success, float64(success)/float64(total)*100) -} - func TestParseFullTail(t *testing.T) { r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD") if r.Artist != "Fane" { diff --git a/internal/releaseparser/groups/profile.go b/internal/releaseparser/groups/profile.go new file mode 100644 index 0000000..1db32c1 --- /dev/null +++ b/internal/releaseparser/groups/profile.go @@ -0,0 +1,45 @@ +package groups + +import ( + "embed" + "strings" + + "git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser" +) + +// The token dictionaries live under resources/dicts/*.txt — one token (in any +// case) per line; blank lines and lines starting with "#" are ignored. They +// can be edited without recompiling the parsing logic: go:embed pulls them +// into the binary at build time. + +//go:embed resources/dicts/*.txt +var dictFS embed.FS + +func loadDict(name string) map[string]bool { + data, err := dictFS.ReadFile("resources/dicts/" + name + ".txt") + if err != nil { + panic("release parser: cannot load dictionary " + name + ": " + err.Error()) + } + out := make(map[string]bool) + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.ToUpper(strings.TrimSpace(line)) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out[line] = true + } + return out +} + +// Profile holds the shared dictionaries for the release parser. The parser is +// group-agnostic: the group name is read from each release line, never from +// this profile, so there is a single set of dictionaries covering all groups. +var Profile = releaseparser.GroupProfile{ + FormatTokens: loadDict("format"), + SourceTokens: loadDict("source"), + BitDepthTokens: loadDict("bitdepth"), + ResolutionTokens: loadDict("resolution"), + LanguageMarkers: loadDict("language"), + EditionTokens: loadDict("edition"), + CommentTokens: loadDict("comment"), +} diff --git a/internal/releaseparser/types.go b/internal/releaseparser/types.go index 21e0ecb..f15efa0 100644 --- a/internal/releaseparser/types.go +++ b/internal/releaseparser/types.go @@ -1,32 +1,30 @@ package releaseparser type GroupProfile struct { - Name string - ExpectedTailOrder []string - FormatTokens map[string]bool - SourceTokens map[string]bool - BitDepthTokens map[string]bool - ResolutionTokens map[string]bool - LanguageMarkers map[string]bool - EditionTokens map[string]bool - CommentTokens map[string]bool + FormatTokens map[string]bool + SourceTokens map[string]bool + BitDepthTokens map[string]bool + ResolutionTokens map[string]bool + LanguageMarkers map[string]bool + EditionTokens map[string]bool + CommentTokens map[string]bool } type ParsedRelease struct { Raw string Group string + Artist string Year int + Title string + Source string + Edition string + CatalogNo string + Language string BitDepth string Resolution string Comment string - CatalogNo string - Source string Format string - Language string - Edition string Core string - Artist string - Title string UnknownTokens []string Warnings []string Confidence float64