From e624bb0eafaadc5ba12ff8da77101e85da64dedb Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Tue, 26 May 2026 12:31:07 +0300 Subject: [PATCH] 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 --- docs/plans/2026-05-21-musicbrainz-provider.md | 16 +- internal/musicbrainz/api.go | 224 ++++++ internal/musicbrainz/api_test.go | 662 ++++++++++++++++++ 3 files changed, 894 insertions(+), 8 deletions(-) create mode 100644 internal/musicbrainz/api.go create mode 100644 internal/musicbrainz/api_test.go diff --git a/docs/plans/2026-05-21-musicbrainz-provider.md b/docs/plans/2026-05-21-musicbrainz-provider.md index 07944d3..04775aa 100644 --- a/docs/plans/2026-05-21-musicbrainz-provider.md +++ b/docs/plans/2026-05-21-musicbrainz-provider.md @@ -65,14 +65,14 @@ Implement a MusicBrainz API provider with strict 1 request/second rate limiting, - [x] run tests - must pass before next task ### Task 3: Implement MusicBrainz API endpoints and filtering -- [ ] implement GetArtistReleaseGroups(artistMBID string) method -- [ ] apply filters: exclude Bootleg/Promotion/Pseudo-Release status -- [ ] apply type filters: include Album/Single/EP/Compilation only -- [ ] implement per-artist type filtering hooks (placeholder for Web UI integration) -- [ ] normalize artist names and titles (remove special characters, years, brackets) -- [ ] write tests for filtering logic (table-driven test cases) -- [ ] write tests for normalization functions -- [ ] run tests - must pass before next task +- [x] implement GetArtistReleaseGroups(artistMBID string) method +- [x] apply filters: exclude Bootleg/Promotion/Pseudo-Release status +- [x] apply type filters: include Album/Single/EP/Compilation only +- [x] implement per-artist type filtering hooks (placeholder for Web UI integration) +- [x] normalize artist names and titles (remove special characters, years, brackets) +- [x] write tests for filtering logic (table-driven test cases) +- [x] write tests for normalization functions +- [x] run tests - must pass before next task ### Task 4: Implement database integration and sync orchestration - [ ] create `internal/musicbrainz/sync.go` with SyncArtistDiscography function diff --git a/internal/musicbrainz/api.go b/internal/musicbrainz/api.go new file mode 100644 index 0000000..4378d9d --- /dev/null +++ b/internal/musicbrainz/api.go @@ -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, + } +} diff --git a/internal/musicbrainz/api_test.go b/internal/musicbrainz/api_test.go new file mode 100644 index 0000000..850c73c --- /dev/null +++ b/internal/musicbrainz/api_test.go @@ -0,0 +1,662 @@ +package musicbrainz + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/time/rate" + "naviwatcher/internal/config" +) + +// ---------- FilterReleaseGroups tests ---------- + +func TestFilterReleaseGroups_ExcludesBootlegPromotionPseudo(t *testing.T) { + groups := []ReleaseGroup{ + {ID: "rg-1", Title: "Official Album", Type: "Album", Status: "Official"}, + {ID: "rg-2", Title: "Bootleg Live", Type: "Album", Status: "Bootleg"}, + {ID: "rg-3", Title: "Promo CD", Type: "Single", Status: "Promotion"}, + {ID: "rg-4", Title: "Pseudo Release", Type: "Album", Status: "Pseudo-Release"}, + } + + result := FilterReleaseGroups(groups) + + if len(result) != 1 { + t.Fatalf("FilterReleaseGroups() returned %d groups, want 1", len(result)) + } + if result[0].ID != "rg-1" { + t.Errorf("FilterReleaseGroups()[0].ID = %q, want %q", result[0].ID, "rg-1") + } +} + +func TestFilterReleaseGroups_IncludesOnlyAllowedTypes(t *testing.T) { + groups := []ReleaseGroup{ + {ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, + {ID: "rg-2", Title: "Single", Type: "Single", Status: "Official"}, + {ID: "rg-3", Title: "EP", Type: "EP", Status: "Official"}, + {ID: "rg-4", Title: "Compilation", Type: "Compilation", Status: "Official"}, + {ID: "rg-5", Title: "Soundtrack", Type: "Soundtrack", Status: "Official"}, + {ID: "rg-6", Title: "Live", Type: "Live", Status: "Official"}, + {ID: "rg-7", Title: "Remix", Type: "Remix", Status: "Official"}, + } + + result := FilterReleaseGroups(groups) + + if len(result) != 4 { + t.Fatalf("FilterReleaseGroups() returned %d groups, want 4", len(result)) + } + + allowedIDs := map[string]bool{"rg-1": true, "rg-2": true, "rg-3": true, "rg-4": true} + for _, rg := range result { + if !allowedIDs[rg.ID] { + t.Errorf("unexpected release group %q (type %q) passed filter", rg.ID, rg.Type) + } + } +} + +func TestFilterReleaseGroups_Empty(t *testing.T) { + result := FilterReleaseGroups(nil) + if len(result) != 0 { + t.Errorf("FilterReleaseGroups(nil) returned %d groups, want 0", len(result)) + } +} + +func TestFilterReleaseGroups_AllExcluded(t *testing.T) { + groups := []ReleaseGroup{ + {ID: "rg-1", Title: "Bootleg", Type: "Album", Status: "Bootleg"}, + {ID: "rg-2", Title: "Promo", Type: "Single", Status: "Promotion"}, + {ID: "rg-3", Title: "Soundtrack", Type: "Soundtrack", Status: "Official"}, + } + + result := FilterReleaseGroups(groups) + if len(result) != 0 { + t.Errorf("FilterReleaseGroups() returned %d groups, want 0 (all excluded)", len(result)) + } +} + +func TestFilterReleaseGroups_NoStatus(t *testing.T) { + // Release groups with empty status should pass (not excluded) + groups := []ReleaseGroup{ + {ID: "rg-1", Title: "Unknown Status", Type: "Album", Status: ""}, + } + + result := FilterReleaseGroups(groups) + if len(result) != 1 { + t.Errorf("FilterReleaseGroups() returned %d groups, want 1 (empty status is not excluded)", len(result)) + } +} + +// ---------- IsStatusExcluded tests ---------- + +func TestIsStatusExcluded(t *testing.T) { + tests := []struct { + status string + excluded bool + }{ + {"Bootleg", true}, + {"Promotion", true}, + {"Pseudo-Release", true}, + {"Official", false}, + {"", false}, + {"official", false}, // case-sensitive: only exact match + } + + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + got := IsStatusExcluded(tt.status) + if got != tt.excluded { + t.Errorf("IsStatusExcluded(%q) = %v, want %v", tt.status, got, tt.excluded) + } + }) + } +} + +// ---------- IsTypeIncluded tests ---------- + +func TestIsTypeIncluded(t *testing.T) { + tests := []struct { + rgType string + included bool + }{ + {"Album", true}, + {"Single", true}, + {"EP", true}, + {"Compilation", true}, + {"Soundtrack", false}, + {"Live", false}, + {"Remix", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.rgType, func(t *testing.T) { + got := IsTypeIncluded(tt.rgType) + if got != tt.included { + t.Errorf("IsTypeIncluded(%q) = %v, want %v", tt.rgType, got, tt.included) + } + }) + } +} + +// ---------- Artist type filter tests ---------- + +func TestFilterReleaseGroupsWithArtistFilter_AllEnabled(t *testing.T) { + groups := []ReleaseGroup{ + {ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, + {ID: "rg-2", Title: "Single", Type: "Single", Status: "Official"}, + {ID: "rg-3", Title: "EP", Type: "EP", Status: "Official"}, + {ID: "rg-4", Title: "Compilation", Type: "Compilation", Status: "Official"}, + } + + filter := DefaultArtistTypeFilter("artist-1") + result := FilterReleaseGroupsWithArtistFilter(groups, filter) + + if len(result) != 4 { + t.Fatalf("FilterReleaseGroupsWithArtistFilter() returned %d groups, want 4", len(result)) + } +} + +func TestFilterReleaseGroupsWithArtistFilter_ExcludeSingles(t *testing.T) { + groups := []ReleaseGroup{ + {ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, + {ID: "rg-2", Title: "Single", Type: "Single", Status: "Official"}, + {ID: "rg-3", Title: "EP", Type: "EP", Status: "Official"}, + } + + filter := &ArtistTypeFilter{ + ArtistID: "artist-1", + IncludeSingles: false, + IncludeCompilations: true, + IncludeEP: true, + } + + result := FilterReleaseGroupsWithArtistFilter(groups, filter) + + if len(result) != 2 { + t.Fatalf("FilterReleaseGroupsWithArtistFilter() returned %d groups, want 2", len(result)) + } + + for _, rg := range result { + if rg.Type == "Single" { + t.Errorf("Single %q should have been excluded", rg.ID) + } + } +} + +func TestFilterReleaseGroupsWithArtistFilter_ExcludeCompilations(t *testing.T) { + groups := []ReleaseGroup{ + {ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, + {ID: "rg-2", Title: "Compilation", Type: "Compilation", Status: "Official"}, + } + + filter := &ArtistTypeFilter{ + ArtistID: "artist-1", + IncludeSingles: true, + IncludeCompilations: false, + IncludeEP: true, + } + + result := FilterReleaseGroupsWithArtistFilter(groups, filter) + + if len(result) != 1 { + t.Fatalf("FilterReleaseGroupsWithArtistFilter() returned %d groups, want 1", len(result)) + } + if result[0].ID != "rg-1" { + t.Errorf("expected rg-1, got %s", result[0].ID) + } +} + +func TestFilterReleaseGroupsWithArtistFilter_ExcludeEP(t *testing.T) { + groups := []ReleaseGroup{ + {ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, + {ID: "rg-2", Title: "EP", Type: "EP", Status: "Official"}, + } + + filter := &ArtistTypeFilter{ + ArtistID: "artist-1", + IncludeSingles: true, + IncludeCompilations: true, + IncludeEP: false, + } + + result := FilterReleaseGroupsWithArtistFilter(groups, filter) + + if len(result) != 1 { + t.Fatalf("FilterReleaseGroupsWithArtistFilter() returned %d groups, want 1", len(result)) + } + if result[0].ID != "rg-1" { + t.Errorf("expected rg-1, got %s", result[0].ID) + } +} + +func TestFilterReleaseGroupsWithArtistFilter_NilFilter(t *testing.T) { + groups := []ReleaseGroup{ + {ID: "rg-1", Title: "Album", Type: "Album", Status: "Official"}, + {ID: "rg-2", Title: "Soundtrack", Type: "Soundtrack", Status: "Official"}, + } + + result := FilterReleaseGroupsWithArtistFilter(groups, nil) + + if len(result) != 1 { + t.Fatalf("FilterReleaseGroupsWithArtistFilter(nil filter) returned %d groups, want 1", len(result)) + } +} + +func TestDefaultArtistTypeFilter(t *testing.T) { + filter := DefaultArtistTypeFilter("artist-1") + + if filter.ArtistID != "artist-1" { + t.Errorf("ArtistID = %q, want %q", filter.ArtistID, "artist-1") + } + if !filter.IncludeSingles { + t.Error("IncludeSingles should be true by default") + } + if !filter.IncludeCompilations { + t.Error("IncludeCompilations should be true by default") + } + if !filter.IncludeEP { + t.Error("IncludeEP should be true by default") + } +} + +func TestIsTypeIncludedForArtist(t *testing.T) { + tests := []struct { + name string + rgType string + filter *ArtistTypeFilter + included bool + }{ + { + name: "Album always included", + rgType: "Album", + filter: DefaultArtistTypeFilter("artist-1"), + included: true, + }, + { + name: "Single included with filter", + rgType: "Single", + filter: DefaultArtistTypeFilter("artist-1"), + included: true, + }, + { + name: "Single excluded", + rgType: "Single", + filter: &ArtistTypeFilter{ + IncludeSingles: false, + IncludeCompilations: true, + IncludeEP: true, + }, + included: false, + }, + { + name: "Soundtrack excluded", + rgType: "Soundtrack", + filter: DefaultArtistTypeFilter("artist-1"), + included: false, + }, + { + name: "Nil filter falls back to base", + rgType: "Album", + filter: nil, + included: true, + }, + { + name: "Nil filter excludes non-base types", + rgType: "Soundtrack", + filter: nil, + included: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsTypeIncludedForArtist(tt.rgType, tt.filter) + if got != tt.included { + t.Errorf("IsTypeIncludedForArtist(%q) = %v, want %v", tt.rgType, got, tt.included) + } + }) + } +} + +// ---------- NormalizeString tests ---------- + +func TestNormalizeString_Basic(t *testing.T) { + tests := []struct { + input string + expected string + }{ + // Lowercase conversion + {"DARK SIDE OF THE MOON", "dark side of the moon"}, + // Special character removal + {"Dark Side of the Moon!", "dark side of the moon"}, + {"Dark-Side-of-the-Moon", "dark side of the moon"}, + {"Dark_Side_of_the_Moon", "dark side of the moon"}, + // Bracket removal + {"Dark Side of the Moon [Deluxe Edition]", "dark side of the moon"}, + {"Dark Side of the Moon [Remastered 2020]", "dark side of the moon"}, + {"Album [2023 Remix]", "album"}, + // Parenthesis removal + {"Dark Side of the Moon (Deluxe)", "dark side of the moon"}, + {"Album (Remastered)", "album"}, + // Year removal + {"Dark Side of the Moon 1973", "dark side of the moon"}, + {"Album 2020 Remastered", "album remastered"}, + // Space collapsing + {"Dark Side of the Moon", "dark side of the moon"}, + // Trim + {" Dark Side of the Moon ", "dark side of the moon"}, + // Combined + {"The Dark Side of the Moon [2011 Remaster] (Deluxe Edition)", "the dark side of the moon"}, + // Empty + {"", ""}, + // Only special chars + {"!@#$%^&*()", ""}, + // Digits that are not years should stay + {"30 Seconds to Mars", "30 seconds to mars"}, + {"1941 - The Greatest Hits", "the greatest hits"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := NormalizeString(tt.input) + if got != tt.expected { + t.Errorf("NormalizeString(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestNormalizeArtistName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Pink Floyd", "pink floyd"}, + {"The Beatles", "beatles"}, + {"A Perfect Circle", "perfect circle"}, + {"An Orchestra", "orchestra"}, + {" The Who ", "who"}, + {"THE WHO", "who"}, + // No stripping needed + {"Radiohead", "radiohead"}, + // Already stripped + {"Beatles", "beatles"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := NormalizeArtistName(tt.input) + if got != tt.expected { + t.Errorf("NormalizeArtistName(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +// ---------- ReleaseGroup.ToExternalRelease tests ---------- + +func TestReleaseGroup_ToExternalRelease(t *testing.T) { + rg := ReleaseGroup{ + ID: "rg-uuid-1", + Title: "Dark Side of the Moon", + Type: "Album", + Status: "Official", + ArtistID: "artist-uuid-1", + ArtistName: "Pink Floyd", + ReleaseDate: "1973-03-01", + } + + er := rg.ToExternalRelease() + + if er.RGID != "rg-uuid-1" { + t.Errorf("RGID = %q, want %q", er.RGID, "rg-uuid-1") + } + if er.ArtistID != "artist-uuid-1" { + t.Errorf("ArtistID = %q, want %q", er.ArtistID, "artist-uuid-1") + } + if er.Title != "Dark Side of the Moon" { + t.Errorf("Title = %q, want %q", er.Title, "Dark Side of the Moon") + } + if er.Type != "Album" { + t.Errorf("Type = %q, want %q", er.Type, "Album") + } + if er.ReleaseDate != "1973-03-01" { + t.Errorf("ReleaseDate = %q, want %q", er.ReleaseDate, "1973-03-01") + } +} + +// ---------- GetArtistReleaseGroups tests ---------- + +func TestGetArtistReleaseGroups_Success(t *testing.T) { + artistMBID := "artist-uuid-test" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(` + + + + Dark Side of the Moon + 1973-03-01 + + + + Pink Floyd + + + + + + Another Brick in the Wall + 1979-11-30 + + + + Pink Floyd + + + + + +`)) + })) + defer server.Close() + + cfg := config.MusicBrainzConfig{ + UserAgent: "naviwatcher/0.1.0 (test@example.com)", + } + + rl := rate.NewLimiter(rate.Limit(1000), 1000) + client := &MusicBrainzClient{ + httpClient: server.Client(), + userAgent: cfg.UserAgent, + baseURL: server.URL, + rateLimiter: rl, + } + defer client.Close() + + groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID) + if err != nil { + t.Fatalf("GetArtistReleaseGroups() error = %v", err) + } + + if len(groups) != 2 { + t.Fatalf("GetArtistReleaseGroups() returned %d groups, want 2", len(groups)) + } + + if groups[0].Title != "Dark Side of the Moon" { + t.Errorf("groups[0].Title = %q, want %q", groups[0].Title, "Dark Side of the Moon") + } + if groups[1].Title != "Another Brick in the Wall" { + t.Errorf("groups[1].Title = %q, want %q", groups[1].Title, "Another Brick in the Wall") + } +} + +func TestGetArtistReleaseGroups_Pagination(t *testing.T) { + artistMBID := "artist-page-test" + requestCount := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + offset := r.URL.Query().Get("offset") + + w.Header().Set("Content-Type", "application/xml") + + if offset == "0" || offset == "" { + // First page: return "100" results (full page, matching limit) to trigger pagination + // We generate multiple release-group elements in the XML + xml := ` + + ` + for i := 0; i < 100; i++ { + xml += ` + + Page 1 Album + 2020-01-01 + + + Artist + + + ` + } + xml += ` + +` + w.Write([]byte(xml)) + } else { + // Second page: return only 1 result (< limit, signaling last page) + w.Write([]byte(` + + + + Page 2 Album + 2021-01-01 + + + Artist + + + + +`)) + } + })) + defer server.Close() + + cfg := config.MusicBrainzConfig{ + UserAgent: "naviwatcher/0.1.0 (test@example.com)", + } + + rl := rate.NewLimiter(rate.Limit(1000), 1000) + client := &MusicBrainzClient{ + httpClient: server.Client(), + userAgent: cfg.UserAgent, + baseURL: server.URL, + rateLimiter: rl, + } + defer client.Close() + + groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID) + if err != nil { + t.Fatalf("GetArtistReleaseGroups() error = %v", err) + } + + // Should have fetched 2 pages: 100 from first + 1 from second = 101 total + if len(groups) != 101 { + t.Fatalf("GetArtistReleaseGroups() returned %d groups, want 101", len(groups)) + } + + if requestCount != 2 { + t.Errorf("expected 2 paginated requests, got %d", requestCount) + } +} + +func TestGetArtistReleaseGroups_EmptyResult(t *testing.T) { + artistMBID := "artist-empty" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(` + + + +`)) + })) + defer server.Close() + + cfg := config.MusicBrainzConfig{ + UserAgent: "naviwatcher/0.1.0 (test@example.com)", + } + + rl := rate.NewLimiter(rate.Limit(1000), 1000) + client := &MusicBrainzClient{ + httpClient: server.Client(), + userAgent: cfg.UserAgent, + baseURL: server.URL, + rateLimiter: rl, + } + defer client.Close() + + groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID) + if err != nil { + t.Fatalf("GetArtistReleaseGroups() error = %v", err) + } + + if len(groups) != 0 { + t.Errorf("GetArtistReleaseGroups() returned %d groups, want 0", len(groups)) + } +} + +func TestGetArtistReleaseGroups_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("Rate limit exceeded")) + })) + defer server.Close() + + cfg := config.MusicBrainzConfig{ + UserAgent: "naviwatcher/0.1.0 (test@example.com)", + } + + rl := rate.NewLimiter(rate.Limit(1000), 1000) + client := &MusicBrainzClient{ + httpClient: server.Client(), + userAgent: cfg.UserAgent, + baseURL: server.URL, + rateLimiter: rl, + } + defer client.Close() + + _, err := client.GetArtistReleaseGroups(context.Background(), "artist-1") + if err == nil { + t.Fatal("GetArtistReleaseGroups() expected error for server error, got nil") + } +} + +func TestGetArtistReleaseGroups_InvalidXML(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(`this is not valid xml`)) + })) + defer server.Close() + + cfg := config.MusicBrainzConfig{ + UserAgent: "naviwatcher/0.1.0 (test@example.com)", + } + + rl := rate.NewLimiter(rate.Limit(1000), 1000) + client := &MusicBrainzClient{ + httpClient: server.Client(), + userAgent: cfg.UserAgent, + baseURL: server.URL, + rateLimiter: rl, + } + defer client.Close() + + _, err := client.GetArtistReleaseGroups(context.Background(), "artist-1") + if err == nil { + t.Fatal("GetArtistReleaseGroups() expected error for invalid XML, got nil") + } +}