- Add context cancellation check between pagination pages in GetArtistReleaseGroups for responsive graceful shutdown during large discography fetches. - Eliminate double DB query on cache hit by having GetCachedReleases return []ExternalRelease directly instead of just a count, avoiding a redundant second query in SyncArtistDiscography. - Update cache_test.go to match new GetCachedReleases return type. - Format main.go (pre-existing whitespace issue).
176 lines
5.0 KiB
Go
176 lines
5.0 KiB
Go
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,
|
|
}
|
|
|
|
// 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.
|
|
if 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
|
|
}
|
|
|
|
// 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.
|
|
func FilterReleaseGroups(groups []ReleaseGroup) []ReleaseGroup {
|
|
var filtered []ReleaseGroup
|
|
for _, rg := range groups {
|
|
if IsStatusExcluded(rg.Status) {
|
|
continue
|
|
}
|
|
if !IsTypeIncluded(rg.Type) {
|
|
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])
|
|
bracketRe := regexp.MustCompile(`\[[^\]]*\]`)
|
|
s = bracketRe.ReplaceAllString(s, "")
|
|
|
|
// Remove parenthesized content (e.g., (Deluxe), (Remastered))
|
|
parenRe := regexp.MustCompile(`\([^)]*\)`)
|
|
s = parenRe.ReplaceAllString(s, "")
|
|
|
|
// Remove years (4-digit numbers between 1000-2999)
|
|
yearRe := regexp.MustCompile(`\b(1[0-9]{3}|2[0-9]{3})\b`)
|
|
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
|
|
spaceRe := regexp.MustCompile(`\s+`)
|
|
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,
|
|
}
|
|
}
|