347f525801
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.
289 lines
8.9 KiB
Go
289 lines
8.9 KiB
Go
package groups
|
|
|
|
import (
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/exp/slices"
|
|
|
|
"git.kplus.net.ua/yevhen/resource-scraper/internal/releaseparser"
|
|
)
|
|
|
|
var yearRegexp = regexp.MustCompile(`^(19|20)\d{2}$`)
|
|
|
|
// Parse splits a release line into tokens and maps them onto a fixed
|
|
// positional model while the interior is resolved solely through the
|
|
// group profile's dictionaries.
|
|
//
|
|
// The release line is split on "-" (but never inside curved parentheses, so
|
|
// that a catalogue number such as (BRC30-001-CD-RRR108) stays one token).
|
|
// The positional layout is:
|
|
//
|
|
// [0] Artist - fixed; never matched against dictionaries.
|
|
// [1] Title - fixed; never matched against dictionaries.
|
|
// [len-2] Year - if the token is a 4-digit year (19xx | 20xx).
|
|
// [len-1] Group - always read from the line, never from the query key.
|
|
//
|
|
// Everything between [1] and the reserved trailing slots ([2 .. len-3], or
|
|
// [2 .. len-2] when there is no year) is the middle zone. Each token there is
|
|
// looked up in the dictionaries (format, source, bit-depth, resolution,
|
|
// language, edition) and, if it is a parenthesized token, as a catalogue
|
|
// number. Position within the middle zone is irrelevant. Tokens that match
|
|
// nothing are NOT silently consumed — they are reported in UnknownTokens so
|
|
// that they can be reviewed and added to a dictionary.
|
|
//
|
|
// Why [0]/[1] are fixed and never matched against the dictionaries: titles
|
|
// such as "Split" (Alone_In_The_Morgue-Split-...) or "Demo"
|
|
// (Mystifier-Demo-Nized-...) are legitimate words that collide with edition
|
|
// markers. Since index 1 is always the title, regardless of case or
|
|
// dictionary membership, such words are never eaten.
|
|
//
|
|
// Values are preserved in their original case (as they appear in the line);
|
|
// the dictionary lookups themselves are case-insensitive.
|
|
func Parse(raw string) releaseparser.ParsedRelease {
|
|
result := releaseparser.ParsedRelease{
|
|
Raw: strings.TrimSpace(raw),
|
|
Warnings: []string{},
|
|
UnknownTokens: []string{},
|
|
}
|
|
|
|
tokens, comments := extractComments(split(raw))
|
|
if len(comments) > 0 {
|
|
result.Comment = strings.Join(comments, ",")
|
|
}
|
|
if len(tokens) < 5 {
|
|
result.Warnings = append(result.Warnings, "too few tokens")
|
|
result.Confidence = 0.2
|
|
return result
|
|
}
|
|
|
|
// Where the dictionary-driven tail zone begins: after the resolved
|
|
// Artist/Title core (2 tokens by default, 3 with an artist dictionary hit).
|
|
start := 2
|
|
|
|
// Level 1: exact-lookup override table fixes Artist/Title only;
|
|
// the tail zone is parsed as usual below.
|
|
if o, ok := Profile.Overrides[result.Raw]; ok {
|
|
result.Artist, result.Title = o.Artist, o.Title
|
|
start = overrideCoreLength(tokens, o)
|
|
}
|
|
|
|
// Level 2: artist dictionary — "[0]-[1]" whitelisted as a hyphenated
|
|
// artist shifts the core by one token.
|
|
switch {
|
|
case result.Artist != "":
|
|
// An override already fixed the core — leave it alone.
|
|
case len(tokens) >= 3 &&
|
|
Profile.ArtistTokens[strings.ToUpper(tokens[0]+"-"+tokens[1])]:
|
|
result.Artist = tokens[0] + "-" + tokens[1]
|
|
result.Title = tokens[2]
|
|
start = 3
|
|
default:
|
|
result.Artist = tokens[0]
|
|
if len(tokens) > 1 {
|
|
result.Title = tokens[1]
|
|
}
|
|
}
|
|
|
|
result.Group = tokens[len(tokens)-1]
|
|
end := len(tokens) - 1
|
|
if len(tokens) >= 3 && yearRegexp.MatchString(tokens[len(tokens)-2]) {
|
|
result.Year, _ = strconv.Atoi(tokens[len(tokens)-2])
|
|
end = len(tokens) - 2
|
|
}
|
|
|
|
// Level 3: dictionary-driven tail zone, starting after the resolved core.
|
|
parseTailZone(start, tokens[start:end], &result)
|
|
|
|
score(&result)
|
|
return result
|
|
}
|
|
|
|
// split breaks a raw release line into tokens on "-", but never inside
|
|
// curved parentheses, so that a catalogue number such as
|
|
// (BRC30-001-CD-RRR108) remains a single token.
|
|
func split(raw string) []string {
|
|
s := strings.TrimSpace(raw)
|
|
out := make([]string, 0, 8)
|
|
var cur strings.Builder
|
|
inParen := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '(':
|
|
inParen = true
|
|
cur.WriteRune(r)
|
|
case r == ')':
|
|
inParen = false
|
|
cur.WriteRune(r)
|
|
case r == '-' && !inParen:
|
|
if t := strings.TrimSpace(cur.String()); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
cur.Reset()
|
|
default:
|
|
cur.WriteRune(r)
|
|
}
|
|
}
|
|
if t := strings.TrimSpace(cur.String()); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extractComments removes service comments (CommentTokens) from the token
|
|
// list and returns the cleaned list together with the removed comments.
|
|
func extractComments(tokens []string) ([]string, []string) {
|
|
out := make([]string, 0, len(tokens))
|
|
var comments []string
|
|
for _, tok := range tokens {
|
|
if Profile.CommentTokens[strings.ToUpper(tok)] {
|
|
comments = append(comments, tok)
|
|
continue
|
|
}
|
|
out = append(out, tok)
|
|
}
|
|
return out, comments
|
|
}
|
|
|
|
// catalogNumber reports whether the token is a parenthesized catalogue
|
|
// number. In the positional model any token wrapped in curved parentheses
|
|
// that sits in the middle zone ([2..]) is a catalogue number regardless of
|
|
// its content (digits, letters, roman numerals, dots, underscores, mixed
|
|
// case). Parenthesized tokens at [0]/[1] are handled as artist/title, not
|
|
// here. It returns the parenthesized content, or "" when the token is not
|
|
// a "(...)" group.
|
|
func catalogNumber(token string) string {
|
|
if len(token) < 3 || token[0] != '(' || token[len(token)-1] != ')' {
|
|
return ""
|
|
}
|
|
return token[1 : len(token)-1]
|
|
}
|
|
|
|
// parseTailZone resolves middle-zone tokens against the dictionaries.
|
|
// The first match wins for each field; catalogue numbers are recognized
|
|
// from parenthesized tokens, and anything unrecognized is appended to
|
|
// UnknownTokens so that it can be reviewed and curated into a dictionary.
|
|
func parseTailZone(start int, zone []string, result *releaseparser.ParsedRelease) {
|
|
for i, tok := range zone {
|
|
up := strings.ToUpper(tok)
|
|
switch {
|
|
case Profile.FormatTokens[up]:
|
|
if result.Format == "" {
|
|
result.Format = tok
|
|
}
|
|
case Profile.SourceTokens[up]:
|
|
if result.Source == "" {
|
|
result.Source = tok
|
|
}
|
|
applyType(tok, result)
|
|
case Profile.BitDepthTokens[up]:
|
|
if result.BitDepth == "" {
|
|
result.BitDepth = tok
|
|
}
|
|
case Profile.ResolutionTokens[up]:
|
|
if result.Resolution == "" {
|
|
result.Resolution = tok
|
|
}
|
|
case Profile.EditionTokens[up]:
|
|
if result.Edition == "" {
|
|
result.Edition = tok
|
|
}
|
|
applyType(tok, result)
|
|
case Profile.LanguageMarkers[up]:
|
|
if result.Language == "" {
|
|
result.Language = tok
|
|
}
|
|
default:
|
|
if cat := catalogNumber(tok); cat != "" && result.CatalogNo == "" {
|
|
result.CatalogNo = cat
|
|
} else if hasLaterKnownToken(zone, i) {
|
|
// suspected title continuation: the core is probably longer
|
|
// than 2 tokens. Not eaten, not unknown — review section.
|
|
result.TitleExtra = append(result.TitleExtra, tok)
|
|
if !slices.Contains(result.Warnings, "suspected title continuation") {
|
|
result.Warnings = append(result.Warnings, "suspected title continuation")
|
|
}
|
|
} else {
|
|
result.UnknownTokens = append(result.UnknownTokens, tok)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// applyType normalizes a matched edition/source token into ParsedRelease.Type
|
|
// via the types.txt variant map; first match wins. The token still fills its
|
|
// own raw field (Source stays raw).
|
|
func applyType(tok string, result *releaseparser.ParsedRelease) {
|
|
if base, ok := Profile.TypeVariants[strings.ToUpper(tok)]; ok && result.Type == "" {
|
|
result.Type = base
|
|
}
|
|
}
|
|
|
|
// hasLaterKnownToken reports whether any token after index i in the zone
|
|
// matches one of the profile dictionaries.
|
|
func hasLaterKnownToken(zone []string, i int) bool {
|
|
for _, later := range zone[i+1:] {
|
|
up := strings.ToUpper(later)
|
|
if Profile.FormatTokens[up] || Profile.SourceTokens[up] ||
|
|
Profile.BitDepthTokens[up] || Profile.ResolutionTokens[up] ||
|
|
Profile.EditionTokens[up] || Profile.LanguageMarkers[up] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// overrideCoreLength returns the number of leading tokens covered by the
|
|
// override's Artist and Title (each may span several hyphen-separated
|
|
// tokens), falling back to the default 2-token core when the raw tokens
|
|
// do not reconstruct the override fields.
|
|
func overrideCoreLength(tokens []string, o releaseparser.Override) int {
|
|
for a := 1; a <= len(tokens); a++ {
|
|
if strings.Join(tokens[:a], "-") != o.Artist {
|
|
continue
|
|
}
|
|
for t := a + 1; t <= len(tokens); t++ {
|
|
if strings.Join(tokens[a:t], "-") == o.Title {
|
|
return t
|
|
}
|
|
}
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// score computes a heuristic confidence in [0,1] for a parsed release based
|
|
// on how many core fields (group, year, format, source, artist) were resolved
|
|
// and whether any warnings or unknown tokens are present.
|
|
func score(result *releaseparser.ParsedRelease) {
|
|
score := 0.0
|
|
if result.Group != "" {
|
|
score += 0.25
|
|
}
|
|
if result.Year != 0 {
|
|
score += 0.20
|
|
}
|
|
if result.Format != "" {
|
|
score += 0.15
|
|
}
|
|
if result.Source != "" {
|
|
score += 0.15
|
|
}
|
|
if result.Artist != "" {
|
|
score += 0.15
|
|
}
|
|
if len(result.Warnings) == 0 {
|
|
score += 0.10
|
|
}
|
|
if len(result.UnknownTokens) > 0 {
|
|
score -= 0.10
|
|
}
|
|
if score > 1 {
|
|
score = 1
|
|
}
|
|
if score < 0 {
|
|
score = 0
|
|
}
|
|
result.Confidence = score
|
|
}
|