Files
2026-09-27 01:12:52 +03:00

311 lines
9.7 KiB
Go

package groups
import (
"regexp"
"strconv"
"strings"
"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
}
// Left core boundary: after the resolved Artist (2 tokens by default,
// 3 with an artist dictionary hit, or fixed by an override).
start := 2
// Level 1: exact-lookup override table fixes Artist/Title only;
// the tail zone is parsed as usual below. The override is
// authoritative about the core: leftover tokens are plain unknowns,
// not part of the title.
if o, ok := Profile.Overrides[result.Raw]; ok {
result.Artist, result.Title = o.Artist, o.Title
start = overrideCoreLength(tokens, o)
}
// Level 2: artist dictionary — a hyphenated artist may span ANY number
// of leading tokens ("Shadu-Nar-Mattaru" = 3). The LONGEST whitelisted
// prefix wins; the title is the token right after it. At least one
// title token and one tail token must remain.
artistDict := false
if result.Artist == "" {
for k := len(tokens) - 2; k >= 2; k-- {
if Profile.ArtistTokens[strings.ToUpper(strings.Join(tokens[:k], "-"))] {
artistDict = true
start = k + 1
break
}
}
}
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
}
// Right core boundary: the first dictionary-known token after the
// resolved artist. The boundary is contextual, but the title itself
// stays a single token ([1], or [2] with an artist dictionary hit).
// Unknown tokens inside the core region ([start .. tailStart)) are
// REPORTED in UnknownTokens — never eaten, never glued into the title.
tailStart := end
for i := start; i < end; i++ {
if isKnownToken(tokens[i]) {
tailStart = i
break
}
}
// Resolve Artist/Title from the core unless the override already did.
if result.Artist == "" {
if artistDict {
// The artist spans tokens [0 .. start-2]; re-join them.
result.Artist = strings.Join(tokens[:start-1], "-")
} else {
result.Artist = tokens[0]
}
if len(tokens) > start-1 {
result.Title = tokens[start-1]
}
}
// Core-region leftovers (e.g. the rest of a wildcards title after an
// override, or a hyphenated title fragment without an artist entry):
// visible in UnknownTokens for review.
for _, tok := range tokens[start:tailStart] {
result.UnknownTokens = append(result.UnknownTokens, tok)
}
// Level 3: dictionary-driven tail zone, starting after the resolved core.
parseTailZone(tokens[tailStart: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]
}
// isKnownToken reports whether the token belongs to the tail zone: it
// matches one of the profile dictionaries or is a parenthesized catalogue
// number. The first such token after the core marks the right core
// boundary.
func isKnownToken(tok string) bool {
up := strings.ToUpper(tok)
return Profile.FormatTokens[up] || Profile.SourceTokens[up] ||
Profile.BitDepthTokens[up] || Profile.ResolutionTokens[up] ||
Profile.EditionTokens[up] || Profile.LanguageMarkers[up] ||
catalogNumber(tok) != ""
}
// 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(zone []string, result *releaseparser.ParsedRelease) {
for _, 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 {
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
}
}
// 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
}