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 }