package musicbrainz import ( "context" "fmt" "net/url" "regexp" "strings" "unicode" "naviwatcher/internal/database" ) // excludedStatuses contains release-group statuses that should be filtered out. var excludedStatuses = map[string]bool{ "Bootleg": true, "Promotion": true, "Pseudo-Release": true, } // includedTypes contains release-group types that should be included. var includedTypes = map[string]bool{ "Album": true, "Single": true, "EP": true, "Compilation": true, } // Precompiled regexes for NormalizeString — compiled once at package init. var ( bracketRe = regexp.MustCompile(`\[[^\]]*\]`) parenRe = regexp.MustCompile(`\([^)]*\)`) yearRe = regexp.MustCompile(`\b(1[0-9]{3}|2[0-9]{3})\b`) spaceRe = regexp.MustCompile(`\s+`) ) // GetArtistReleaseGroups fetches all release groups for a given artist from MusicBrainz. // It queries the artist's release groups via the MusicBrainz Web Service API, // parses the XML response, and applies status and type filtering. // // The method handles pagination automatically by following offset parameters // until all release groups are fetched. func (c *MusicBrainzClient) GetArtistReleaseGroups(ctx context.Context, artistMBID string) ([]ReleaseGroup, error) { var allGroups []ReleaseGroup offset := 0 limit := 100 // MusicBrainz max limit per request for { params := url.Values{} params.Set("artist", artistMBID) params.Set("limit", fmt.Sprintf("%d", limit)) params.Set("offset", fmt.Sprintf("%d", offset)) path := buildPath("/release-group", params) body, err := c.doGet(ctx, path) if err != nil { return nil, fmt.Errorf("fetch release groups for artist %s: %w", artistMBID, err) } parsed, err := ParseReleaseGroups(body) if err != nil { return nil, fmt.Errorf("parse release groups for artist %s: %w", artistMBID, err) } allGroups = append(allGroups, parsed.ReleaseGroups...) // If we've fetched all results, we've reached the end. // Also break on empty page to prevent infinite loop if API // returns fewer items than advertised by count. if len(parsed.ReleaseGroups) == 0 || offset+len(parsed.ReleaseGroups) >= parsed.Count { break } // Check context cancellation between pages for responsive shutdown. if err := ctx.Err(); err != nil { return nil, fmt.Errorf("fetch release groups for artist %s: %w", artistMBID, err) } offset += limit } return allGroups, nil } // FilterOptions holds per-artist type filtering preferences. type FilterOptions struct { IgnoreSingles bool IgnoreCompilations bool } // FilterReleaseGroups applies status and type filtering to a list of release groups. // It excludes Bootleg, Promotion, and Pseudo-Release statuses. // It includes only Album, Single, EP, and Compilation types, unless the type // is disabled via FilterOptions. func FilterReleaseGroups(groups []ReleaseGroup, opts FilterOptions) []ReleaseGroup { var filtered []ReleaseGroup for _, rg := range groups { if IsStatusExcluded(rg.Status) { continue } if !IsTypeIncluded(rg.Type) { continue } if opts.IgnoreSingles && rg.Type == "Single" { continue } if opts.IgnoreCompilations && rg.Type == "Compilation" { continue } filtered = append(filtered, rg) } return filtered } // IsStatusExcluded returns true if the given status should be excluded. func IsStatusExcluded(status string) bool { return excludedStatuses[status] } // IsTypeIncluded returns true if the given type is in the base included set. func IsTypeIncluded(releaseType string) bool { return includedTypes[releaseType] } // NormalizeString normalizes a string for fuzzy matching by: // - Converting to lowercase // - Removing special characters (keeping only letters, digits, and spaces) // - Removing years (4-digit numbers that look like years) // - Removing bracketed keywords (e.g., [Deluxe], [Remastered]) // - Collapsing multiple spaces into one // - Trimming leading/trailing whitespace func NormalizeString(s string) string { // Convert to lowercase s = strings.ToLower(s) // Remove bracketed content first (e.g., [Deluxe Edition], [Remastered 2020]) s = bracketRe.ReplaceAllString(s, "") // Remove parenthesized content (e.g., (Deluxe), (Remastered)) s = parenRe.ReplaceAllString(s, "") // Remove years (4-digit numbers between 1000-2999) s = yearRe.ReplaceAllString(s, "") // Replace common separators with spaces before stripping other special chars s = strings.ReplaceAll(s, "-", " ") s = strings.ReplaceAll(s, "_", " ") // Keep only letters, digits, and spaces var b strings.Builder for _, r := range s { if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) { b.WriteRune(r) } } s = b.String() // Collapse multiple spaces s = spaceRe.ReplaceAllString(s, " ") // Trim s = strings.TrimSpace(s) return s } // NormalizeArtistName normalizes an artist name for comparison. // It applies NormalizeString and additionally handles common prefixes. func NormalizeArtistName(name string) string { name = NormalizeString(name) // Remove common leading articles for better matching prefixes := []string{"the ", "a ", "an "} for _, prefix := range prefixes { if strings.HasPrefix(name, prefix) { name = strings.TrimPrefix(name, prefix) break } } return strings.TrimSpace(name) } // ToExternalRelease converts a ReleaseGroup to an ExternalRelease // for database persistence. func (rg *ReleaseGroup) ToExternalRelease() *database.ExternalRelease { return &database.ExternalRelease{ RGID: rg.ID, ArtistID: rg.ArtistID, Title: rg.Title, Type: rg.Type, ReleaseDate: rg.ReleaseDate, } }