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:
Yevhen Odynets
2026-09-01 11:28:19 +03:00
parent feef080d80
commit f2629f002c
15 changed files with 150 additions and 293 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ vendor/
config/secret.yaml config/secret.yaml
*.md *.md
mdb mdb
internal/releaseparser/resources/* internal/releaseparser/groups/resources/*
review_unknown_tokens.txt review_unknown_tokens.txt
srrdb.exe srrdb.exe
**/*.exe **/*.exe
+50 -4
View File
@@ -5,13 +5,15 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath"
"strings"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/spf13/viper" "github.com/spf13/viper"
_ "github.com/go-sql-driver/mysql" _ "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() { func main() {
@@ -35,19 +37,33 @@ func main() {
} }
defer dbase.Close() 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 { for _, releaser := range releasers {
var titles []string var titles []string
err = dbase.Select(&titles, err = dbase.Select(&titles,
`SELECT title FROM theamok_mdb.external_sources `SELECT title FROM theamok_mdb.external_sources
WHERE type = 'prescene' AND releaser = ?`, releaser) WHERE type = 'prescene' AND releaser = ? limit 1000`, releaser)
if err != nil { if err != nil {
log.Fatal(err) 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, "", " ") data, err := json.MarshalIndent(out, "", " ")
@@ -77,3 +93,33 @@ func main() {
fmt.Println(string(data)) 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)
}
@@ -1,8 +1,6 @@
package moonblood package groups
import ( import (
"bufio"
"os"
"sort" "sort"
"strings" "strings"
@@ -12,6 +10,7 @@ import (
// GroupStats summarises how a set of release lines parsed: field // GroupStats summarises how a set of release lines parsed: field
// distributions, unknown tokens and warnings aggregated by occurrence, and // distributions, unknown tokens and warnings aggregated by occurrence, and
// the average parsing confidence. The slices are sorted by descending count. // the average parsing confidence. The slices are sorted by descending count.
type GroupStats struct { type GroupStats struct {
Total int Total int
YearDistribution map[int]int YearDistribution map[int]int
@@ -26,34 +25,11 @@ type GroupStats struct {
AvgConfidence float64 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 // AnalyzeTitles parses a set of release lines (for example fetched from the
// database by a particular releaser key) and aggregates the results into a // database by a particular releaser key) and aggregates the results into a
// GroupStats summary. // GroupStats summary.
func AnalyzeTitles(titles []string) GroupStats { func AnalyzeTitles(titles []string) GroupStats {
stats := GroupStats{ stats := GroupStats{
YearDistribution: make(map[int]int), YearDistribution: make(map[int]int),
@@ -67,10 +43,12 @@ func AnalyzeTitles(titles []string) GroupStats {
WarningStats: []releaseparser.UnknownTokenStats{}, WarningStats: []releaseparser.UnknownTokenStats{},
} }
totalConfidence := 0.0 totalConfidence := 0.0
unknownMap := make(map[string]*releaseparser.UnknownTokenStats) unknownMap := make(map[string]*releaseparser.UnknownTokenStats)
warningMap := make(map[string]*releaseparser.UnknownTokenStats) warningMap := make(map[string]*releaseparser.UnknownTokenStats)
for _, title := range titles { for _, title := range titles {
result := Parse(title) result := Parse(title)
stats.Total++ stats.Total++
@@ -131,7 +109,6 @@ func AnalyzeTitles(titles []string) GroupStats {
for _, tokenStats := range unknownMap { for _, tokenStats := range unknownMap {
stats.UnknownTokens = append(stats.UnknownTokens, *tokenStats) stats.UnknownTokens = append(stats.UnknownTokens, *tokenStats)
} }
sort.Slice(stats.UnknownTokens, func(i, j int) bool { sort.Slice(stats.UnknownTokens, func(i, j int) bool {
return stats.UnknownTokens[i].Count > stats.UnknownTokens[j].Count return stats.UnknownTokens[i].Count > stats.UnknownTokens[j].Count
}) })
@@ -149,3 +126,36 @@ func AnalyzeTitles(titles []string) GroupStats {
return stats 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
}
@@ -1,5 +0,0 @@
# {bit_depth}
16BIT
24BIT
32BIT
DE16BIT
@@ -1,6 +0,0 @@
REPACK
REAL_REPACK
READNFO
PROPER
RERIP
199X
@@ -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
@@ -1,3 +0,0 @@
# {format} — аудіоформат
# один токен на рядок, регістр не має значення
FLAC
@@ -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
@@ -1,8 +0,0 @@
# {resolution} — частота дискретизації
44KHZ
48KHZ
48.0kHz
88KHZ
96KHZ
176KHZ
192KHZ
@@ -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
@@ -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"),
}
@@ -1,4 +1,4 @@
package moonblood package groups
import ( import (
"regexp" "regexp"
@@ -110,7 +110,7 @@ func split(raw string) []string {
// list and returns the cleaned list together with the removed comments. // list and returns the cleaned list together with the removed comments.
func extractComments(tokens []string) ([]string, []string) { func extractComments(tokens []string) ([]string, []string) {
out := make([]string, 0, len(tokens)) out := make([]string, 0, len(tokens))
comments := []string{} var comments []string
for _, tok := range tokens { for _, tok := range tokens {
if Profile.CommentTokens[strings.ToUpper(tok)] { if Profile.CommentTokens[strings.ToUpper(tok)] {
comments = append(comments, tok) comments = append(comments, tok)
@@ -1,43 +1,9 @@
package moonblood package groups
import ( import (
"bufio"
"os"
"testing" "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) { func TestParseFullTail(t *testing.T) {
r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD") r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD")
if r.Artist != "Fane" { if r.Artist != "Fane" {
+45
View File
@@ -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"),
}
+6 -8
View File
@@ -1,8 +1,6 @@
package releaseparser package releaseparser
type GroupProfile struct { type GroupProfile struct {
Name string
ExpectedTailOrder []string
FormatTokens map[string]bool FormatTokens map[string]bool
SourceTokens map[string]bool SourceTokens map[string]bool
BitDepthTokens map[string]bool BitDepthTokens map[string]bool
@@ -15,18 +13,18 @@ type GroupProfile struct {
type ParsedRelease struct { type ParsedRelease struct {
Raw string Raw string
Group string Group string
Artist string
Year int Year int
Title string
Source string
Edition string
CatalogNo string
Language string
BitDepth string BitDepth string
Resolution string Resolution string
Comment string Comment string
CatalogNo string
Source string
Format string Format string
Language string
Edition string
Core string Core string
Artist string
Title string
UnknownTokens []string UnknownTokens []string
Warnings []string Warnings []string
Confidence float64 Confidence float64