musicbrainz-provider #2
@@ -65,14 +65,14 @@ Implement a MusicBrainz API provider with strict 1 request/second rate limiting,
|
|||||||
- [x] run tests - must pass before next task
|
- [x] run tests - must pass before next task
|
||||||
|
|
||||||
### Task 3: Implement MusicBrainz API endpoints and filtering
|
### Task 3: Implement MusicBrainz API endpoints and filtering
|
||||||
- [ ] implement GetArtistReleaseGroups(artistMBID string) method
|
- [x] implement GetArtistReleaseGroups(artistMBID string) method
|
||||||
- [ ] apply filters: exclude Bootleg/Promotion/Pseudo-Release status
|
- [x] apply filters: exclude Bootleg/Promotion/Pseudo-Release status
|
||||||
- [ ] apply type filters: include Album/Single/EP/Compilation only
|
- [x] apply type filters: include Album/Single/EP/Compilation only
|
||||||
- [ ] implement per-artist type filtering hooks (placeholder for Web UI integration)
|
- [x] implement per-artist type filtering hooks (placeholder for Web UI integration)
|
||||||
- [ ] normalize artist names and titles (remove special characters, years, brackets)
|
- [x] normalize artist names and titles (remove special characters, years, brackets)
|
||||||
- [ ] write tests for filtering logic (table-driven test cases)
|
- [x] write tests for filtering logic (table-driven test cases)
|
||||||
- [ ] write tests for normalization functions
|
- [x] write tests for normalization functions
|
||||||
- [ ] run tests - must pass before next task
|
- [x] run tests - must pass before next task
|
||||||
|
|
||||||
### Task 4: Implement database integration and sync orchestration
|
### Task 4: Implement database integration and sync orchestration
|
||||||
- [ ] create `internal/musicbrainz/sync.go` with SyncArtistDiscography function
|
- [ ] create `internal/musicbrainz/sync.go` with SyncArtistDiscography function
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
662
internal/musicbrainz/api_test.go
Normal file
662
internal/musicbrainz/api_test.go
Normal file
@@ -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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
||||||
|
<release-group-list count="2">
|
||||||
|
<release-group id="rg-uuid-1" type="Album">
|
||||||
|
<title>Dark Side of the Moon</title>
|
||||||
|
<first-release-date>1973-03-01</first-release-date>
|
||||||
|
<artist-credit>
|
||||||
|
<name-credit>
|
||||||
|
<artist id="` + artistMBID + `">
|
||||||
|
<name>Pink Floyd</name>
|
||||||
|
</artist>
|
||||||
|
</name-credit>
|
||||||
|
</artist-credit>
|
||||||
|
</release-group>
|
||||||
|
<release-group id="rg-uuid-2" type="Single">
|
||||||
|
<title>Another Brick in the Wall</title>
|
||||||
|
<first-release-date>1979-11-30</first-release-date>
|
||||||
|
<artist-credit>
|
||||||
|
<name-credit>
|
||||||
|
<artist id="` + artistMBID + `">
|
||||||
|
<name>Pink Floyd</name>
|
||||||
|
</artist>
|
||||||
|
</name-credit>
|
||||||
|
</artist-credit>
|
||||||
|
</release-group>
|
||||||
|
</release-group-list>
|
||||||
|
</metadata>`))
|
||||||
|
}))
|
||||||
|
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
||||||
|
<release-group-list count="150">`
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
xml += `
|
||||||
|
<release-group id="rg-page1-` + string(rune('0'+i%10)) + `" type="Album">
|
||||||
|
<title>Page 1 Album</title>
|
||||||
|
<first-release-date>2020-01-01</first-release-date>
|
||||||
|
<artist-credit>
|
||||||
|
<name-credit>
|
||||||
|
<artist id="` + artistMBID + `"><name>Artist</name></artist>
|
||||||
|
</name-credit>
|
||||||
|
</artist-credit>
|
||||||
|
</release-group>`
|
||||||
|
}
|
||||||
|
xml += `
|
||||||
|
</release-group-list>
|
||||||
|
</metadata>`
|
||||||
|
w.Write([]byte(xml))
|
||||||
|
} else {
|
||||||
|
// Second page: return only 1 result (< limit, signaling last page)
|
||||||
|
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
||||||
|
<release-group-list count="150">
|
||||||
|
<release-group id="rg-page2" type="Album">
|
||||||
|
<title>Page 2 Album</title>
|
||||||
|
<first-release-date>2021-01-01</first-release-date>
|
||||||
|
<artist-credit>
|
||||||
|
<name-credit>
|
||||||
|
<artist id="` + artistMBID + `"><name>Artist</name></artist>
|
||||||
|
</name-credit>
|
||||||
|
</artist-credit>
|
||||||
|
</release-group>
|
||||||
|
</release-group-list>
|
||||||
|
</metadata>`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
||||||
|
<release-group-list count="0">
|
||||||
|
</release-group-list>
|
||||||
|
</metadata>`))
|
||||||
|
}))
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user