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")
+ }
+}