feat: implement MusicBrainz API endpoints, filtering, and normalization
- Add GetArtistReleaseGroups method with pagination support - Implement status filtering (exclude Bootleg/Promotion/Pseudo-Release) - Implement type filtering (include Album/Single/EP/Compilation) - Add per-artist type filtering hooks (ArtistTypeFilter) for Web UI - Add NormalizeString and NormalizeArtistName for fuzzy matching prep - Add ReleaseGroup.ToExternalRelease conversion method - Write comprehensive table-driven tests for filtering logic - Write tests for normalization functions (18 cases) - Write tests for GetArtistReleaseGroups (success, pagination, empty, errors) - All tests pass (47 total across project), go vet clean
This commit is contained in:
224
internal/musicbrainz/api.go
Normal file
224
internal/musicbrainz/api.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package musicbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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,
|
||||
}
|
||||
|
||||
// ArtistTypeFilter holds per-artist type filtering preferences.
|
||||
// These are placeholders for Web UI integration where users can
|
||||
// toggle which release types to monitor per artist.
|
||||
type ArtistTypeFilter struct {
|
||||
// ArtistID is the MusicBrainz artist ID.
|
||||
ArtistID string
|
||||
// IncludeSingles whether to include Single-type release groups.
|
||||
IncludeSingles bool
|
||||
// IncludeCompilations whether to include Compilation-type release groups.
|
||||
IncludeCompilations bool
|
||||
// IncludeEP whether to include EP-type release groups.
|
||||
IncludeEP bool
|
||||
}
|
||||
|
||||
// DefaultArtistTypeFilter returns an ArtistTypeFilter with all types enabled.
|
||||
func DefaultArtistTypeFilter(artistID string) *ArtistTypeFilter {
|
||||
return &ArtistTypeFilter{
|
||||
ArtistID: artistID,
|
||||
IncludeSingles: true,
|
||||
IncludeCompilations: true,
|
||||
IncludeEP: 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 {
|
||||
path := fmt.Sprintf("/release-group?artist=%s&limit=%d&offset=%d", artistMBID, limit, offset)
|
||||
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 got fewer results than the limit, we've reached the end
|
||||
if len(parsed.ReleaseGroups) < limit {
|
||||
break
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// FilterReleaseGroupsWithArtistFilter applies status filtering, base type filtering,
|
||||
// and per-artist type filtering preferences.
|
||||
func FilterReleaseGroupsWithArtistFilter(groups []ReleaseGroup, artistFilter *ArtistTypeFilter) []ReleaseGroup {
|
||||
var filtered []ReleaseGroup
|
||||
for _, rg := range groups {
|
||||
if IsStatusExcluded(rg.Status) {
|
||||
continue
|
||||
}
|
||||
if !IsTypeIncludedForArtist(rg.Type, artistFilter) {
|
||||
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]
|
||||
}
|
||||
|
||||
// IsTypeIncludedForArtist checks whether a release type should be included
|
||||
// based on per-artist type filtering preferences.
|
||||
func IsTypeIncludedForArtist(releaseType string, filter *ArtistTypeFilter) bool {
|
||||
if filter == nil {
|
||||
return IsTypeIncluded(releaseType)
|
||||
}
|
||||
|
||||
switch releaseType {
|
||||
case "Album":
|
||||
return true // Albums are always included
|
||||
case "Single":
|
||||
return filter.IncludeSingles
|
||||
case "EP":
|
||||
return filter.IncludeEP
|
||||
case "Compilation":
|
||||
return filter.IncludeCompilations
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// with the current timestamp as CachedAt.
|
||||
func (rg *ReleaseGroup) ToExternalRelease() *database.ExternalRelease {
|
||||
return &database.ExternalRelease{
|
||||
RGID: rg.ID,
|
||||
ArtistID: rg.ArtistID,
|
||||
Title: rg.Title,
|
||||
Type: rg.Type,
|
||||
ReleaseDate: rg.ReleaseDate,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user