musicbrainz-provider #2
@@ -45,7 +45,8 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
sig := <-sigCh
|
sig := <-sigCh
|
||||||
log.Printf("Received signal %v, shutting down...", sig)
|
log.Printf("Received signal %v, shutting down...", sig)
|
||||||
cancel()}()
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
app, err := NewApp(ctx, cfg)
|
app, err := NewApp(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,9 +83,6 @@ func NewApp(ctx context.Context, cfg *config.Config) (*App, error) {
|
|||||||
|
|
||||||
// Close cleans up all application resources in reverse order of initialization.
|
// Close cleans up all application resources in reverse order of initialization.
|
||||||
func (a *App) Close() {
|
func (a *App) Close() {
|
||||||
if a.mbClient != nil {
|
|
||||||
a.mbClient.Close()
|
|
||||||
}
|
|
||||||
if a.db != nil {
|
if a.db != nil {
|
||||||
if err := a.db.Close(); err != nil {
|
if err := a.db.Close(); err != nil {
|
||||||
log.Printf("Error closing database: %v", err)
|
log.Printf("Error closing database: %v", err)
|
||||||
|
|||||||
@@ -9,36 +9,6 @@ import (
|
|||||||
"naviwatcher/internal/config"
|
"naviwatcher/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRun_GracefulShutdown(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Server: config.ServerConfig{
|
|
||||||
Host: "127.0.0.1",
|
|
||||||
Port: 9090,
|
|
||||||
},
|
|
||||||
Navidrome: config.NavidromeConfig{
|
|
||||||
URL: "http://localhost:4533",
|
|
||||||
User: "test",
|
|
||||||
Password: "test",
|
|
||||||
},
|
|
||||||
MusicBrainz: config.MusicBrainzConfig{
|
|
||||||
UserAgent: "NaviWatcher/1.0 ( test@example.com )",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
app, err := NewApp(ctx, cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewApp returned error: %v", err)
|
|
||||||
}
|
|
||||||
defer app.Close()
|
|
||||||
|
|
||||||
if err := app.run(ctx); err != nil {
|
|
||||||
t.Fatalf("app.run returned error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigIntegration(t *testing.T) {
|
func TestConfigIntegration(t *testing.T) {
|
||||||
// Integration test: write a minimal valid config and load it via config.LoadConfig,
|
// Integration test: write a minimal valid config and load it via config.LoadConfig,
|
||||||
// verifying the full path that main() uses.
|
// verifying the full path that main() uses.
|
||||||
@@ -77,10 +47,6 @@ musicbrainz:
|
|||||||
|
|
||||||
func TestNewApp_CreatesMusicBrainzClient(t *testing.T) {
|
func TestNewApp_CreatesMusicBrainzClient(t *testing.T) {
|
||||||
// Verify that NewApp initializes the MusicBrainz client from config.
|
// Verify that NewApp initializes the MusicBrainz client from config.
|
||||||
dir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(dir, "test.db")
|
|
||||||
_ = dbPath // database.New uses a hardcoded path in this version; we test the client creation.
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Server: config.ServerConfig{
|
Server: config.ServerConfig{
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ func GetExternalRelease(db *DB, rgid string) (*ExternalRelease, error) {
|
|||||||
|
|
||||||
// SaveExternalRelease inserts or replaces an external_release row.
|
// SaveExternalRelease inserts or replaces an external_release row.
|
||||||
func SaveExternalRelease(db *DB, release *ExternalRelease) error {
|
func SaveExternalRelease(db *DB, release *ExternalRelease) error {
|
||||||
cachedAtStr := ""
|
var cachedAt interface{}
|
||||||
if !release.CachedAt.IsZero() {
|
if !release.CachedAt.IsZero() {
|
||||||
cachedAtStr = release.CachedAt.Format("2006-01-02 15:04:05")
|
cachedAt = release.CachedAt.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
_, err := db.Conn().Exec(
|
_, err := db.Conn().Exec(
|
||||||
"INSERT OR REPLACE INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored, cached_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"INSERT OR REPLACE INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored, cached_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
release.RGID, release.ArtistID, release.Title, release.Type, release.ReleaseDate, release.IsIgnored, cachedAtStr,
|
release.RGID, release.ArtistID, release.Title, release.Type, release.ReleaseDate, release.IsIgnored, cachedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("save external release: %w", err)
|
return fmt.Errorf("save external release: %w", err)
|
||||||
@@ -158,8 +158,8 @@ func GetExternalReleasesByArtistWithCache(db *DB, artistID string, ttl time.Dura
|
|||||||
var results []ExternalRelease
|
var results []ExternalRelease
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ExternalRelease
|
var r ExternalRelease
|
||||||
var releaseDate sql.NullString
|
|
||||||
var releaseType sql.NullString
|
var releaseType sql.NullString
|
||||||
|
var releaseDate sql.NullString
|
||||||
var cachedAt sql.NullTime
|
var cachedAt sql.NullTime
|
||||||
if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &releaseType, &releaseDate, &r.IsIgnored, &cachedAt); err != nil {
|
if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &releaseType, &releaseDate, &r.IsIgnored, &cachedAt); err != nil {
|
||||||
return nil, fmt.Errorf("scan cached external release: %w", err)
|
return nil, fmt.Errorf("scan cached external release: %w", err)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package musicbrainz
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -25,30 +26,6 @@ var includedTypes = map[string]bool{
|
|||||||
"Compilation": 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.
|
// GetArtistReleaseGroups fetches all release groups for a given artist from MusicBrainz.
|
||||||
// It queries the artist's release groups via the MusicBrainz Web Service API,
|
// It queries the artist's release groups via the MusicBrainz Web Service API,
|
||||||
// parses the XML response, and applies status and type filtering.
|
// parses the XML response, and applies status and type filtering.
|
||||||
@@ -61,7 +38,12 @@ func (c *MusicBrainzClient) GetArtistReleaseGroups(ctx context.Context, artistMB
|
|||||||
limit := 100 // MusicBrainz max limit per request
|
limit := 100 // MusicBrainz max limit per request
|
||||||
|
|
||||||
for {
|
for {
|
||||||
path := fmt.Sprintf("/release-group?artist=%s&limit=%d&offset=%d", artistMBID, limit, offset)
|
params := url.Values{}
|
||||||
|
params.Set("artist", artistMBID)
|
||||||
|
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||||
|
params.Set("offset", fmt.Sprintf("%d", offset))
|
||||||
|
path := buildPath("/release-group", params)
|
||||||
|
|
||||||
body, err := c.doGet(ctx, path)
|
body, err := c.doGet(ctx, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch release groups for artist %s: %w", artistMBID, err)
|
return nil, fmt.Errorf("fetch release groups for artist %s: %w", artistMBID, err)
|
||||||
@@ -101,22 +83,6 @@ func FilterReleaseGroups(groups []ReleaseGroup) []ReleaseGroup {
|
|||||||
return filtered
|
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.
|
// IsStatusExcluded returns true if the given status should be excluded.
|
||||||
func IsStatusExcluded(status string) bool {
|
func IsStatusExcluded(status string) bool {
|
||||||
return excludedStatuses[status]
|
return excludedStatuses[status]
|
||||||
@@ -127,27 +93,6 @@ func IsTypeIncluded(releaseType string) bool {
|
|||||||
return includedTypes[releaseType]
|
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:
|
// NormalizeString normalizes a string for fuzzy matching by:
|
||||||
// - Converting to lowercase
|
// - Converting to lowercase
|
||||||
// - Removing special characters (keeping only letters, digits, and spaces)
|
// - Removing special characters (keeping only letters, digits, and spaces)
|
||||||
@@ -212,7 +157,7 @@ func NormalizeArtistName(name string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ToExternalRelease converts a ReleaseGroup to an ExternalRelease
|
// ToExternalRelease converts a ReleaseGroup to an ExternalRelease
|
||||||
// with the current timestamp as CachedAt.
|
// for database persistence.
|
||||||
func (rg *ReleaseGroup) ToExternalRelease() *database.ExternalRelease {
|
func (rg *ReleaseGroup) ToExternalRelease() *database.ExternalRelease {
|
||||||
return &database.ExternalRelease{
|
return &database.ExternalRelease{
|
||||||
RGID: rg.ID,
|
RGID: rg.ID,
|
||||||
|
|||||||
@@ -50,275 +50,11 @@ func TestFilterReleaseGroups_IncludesOnlyAllowedTypes(t *testing.T) {
|
|||||||
allowedIDs := map[string]bool{"rg-1": true, "rg-2": true, "rg-3": true, "rg-4": true}
|
allowedIDs := map[string]bool{"rg-1": true, "rg-2": true, "rg-3": true, "rg-4": true}
|
||||||
for _, rg := range result {
|
for _, rg := range result {
|
||||||
if !allowedIDs[rg.ID] {
|
if !allowedIDs[rg.ID] {
|
||||||
t.Errorf("unexpected release group %q (type %q) passed filter", rg.ID, rg.Type)
|
t.Errorf("unexpected group %q in filtered results", rg.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ----------
|
// ---------- NormalizeString tests ----------
|
||||||
|
|
||||||
func TestNormalizeString_Basic(t *testing.T) {
|
func TestNormalizeString_Basic(t *testing.T) {
|
||||||
@@ -474,7 +210,6 @@ func TestGetArtistReleaseGroups_Success(t *testing.T) {
|
|||||||
baseURL: server.URL,
|
baseURL: server.URL,
|
||||||
rateLimiter: rl,
|
rateLimiter: rl,
|
||||||
}
|
}
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -504,8 +239,7 @@ func TestGetArtistReleaseGroups_Pagination(t *testing.T) {
|
|||||||
w.Header().Set("Content-Type", "application/xml")
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
|
||||||
if offset == "0" || offset == "" {
|
if offset == "0" || offset == "" {
|
||||||
// First page: return "100" results (full page, matching limit) to trigger pagination
|
// 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"?>
|
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
||||||
<release-group-list count="150">`
|
<release-group-list count="150">`
|
||||||
@@ -556,7 +290,6 @@ func TestGetArtistReleaseGroups_Pagination(t *testing.T) {
|
|||||||
baseURL: server.URL,
|
baseURL: server.URL,
|
||||||
rateLimiter: rl,
|
rateLimiter: rl,
|
||||||
}
|
}
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -597,7 +330,6 @@ func TestGetArtistReleaseGroups_EmptyResult(t *testing.T) {
|
|||||||
baseURL: server.URL,
|
baseURL: server.URL,
|
||||||
rateLimiter: rl,
|
rateLimiter: rl,
|
||||||
}
|
}
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -627,7 +359,6 @@ func TestGetArtistReleaseGroups_ServerError(t *testing.T) {
|
|||||||
baseURL: server.URL,
|
baseURL: server.URL,
|
||||||
rateLimiter: rl,
|
rateLimiter: rl,
|
||||||
}
|
}
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
_, err := client.GetArtistReleaseGroups(context.Background(), "artist-1")
|
_, err := client.GetArtistReleaseGroups(context.Background(), "artist-1")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -653,7 +384,6 @@ func TestGetArtistReleaseGroups_InvalidXML(t *testing.T) {
|
|||||||
baseURL: server.URL,
|
baseURL: server.URL,
|
||||||
rateLimiter: rl,
|
rateLimiter: rl,
|
||||||
}
|
}
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
_, err := client.GetArtistReleaseGroups(context.Background(), "artist-1")
|
_, err := client.GetArtistReleaseGroups(context.Background(), "artist-1")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -7,47 +7,13 @@ import (
|
|||||||
"naviwatcher/internal/database"
|
"naviwatcher/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CacheStats holds the result of a cache lookup for a given artist.
|
|
||||||
type CacheStats struct {
|
|
||||||
// CachedRGIDs is the list of RGIDs that are currently cached (within TTL).
|
|
||||||
CachedRGIDs []string
|
|
||||||
// CacheHitCount is the number of entries found in cache.
|
|
||||||
CacheHitCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsCached returns true if the given RGID is in the cached set.
|
|
||||||
func (cs *CacheStats) IsCached(rgid string) bool {
|
|
||||||
for _, id := range cs.CachedRGIDs {
|
|
||||||
if id == rgid {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCachedReleases queries the external_releases table for entries
|
// GetCachedReleases queries the external_releases table for entries
|
||||||
// belonging to the given artist that were cached within the specified TTL.
|
// belonging to the given artist that were cached within the specified TTL.
|
||||||
// It returns a CacheStats with the list of valid RGIDs already in cache.
|
// It returns the count of cached entries and any error encountered.
|
||||||
func GetCachedReleases(db *database.DB, artistID string, ttl time.Duration) (*CacheStats, error) {
|
func GetCachedReleases(db *database.DB, artistID string, ttl time.Duration) (int, error) {
|
||||||
releases, err := database.GetExternalReleasesByArtistWithCache(db, artistID, ttl)
|
releases, err := database.GetExternalReleasesByArtistWithCache(db, artistID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get cached releases: %w", err)
|
return 0, fmt.Errorf("get cached releases: %w", err)
|
||||||
}
|
}
|
||||||
|
return len(releases), nil
|
||||||
stats := &CacheStats{}
|
|
||||||
for _, r := range releases {
|
|
||||||
stats.CachedRGIDs = append(stats.CachedRGIDs, r.RGID)
|
|
||||||
stats.CacheHitCount++
|
|
||||||
}
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsArtistCacheValid checks whether the cache for an artist is still valid.
|
|
||||||
// Returns true if any entries exist within the TTL for this artist.
|
|
||||||
func IsArtistCacheValid(db *database.DB, artistID string, ttl time.Duration) (bool, error) {
|
|
||||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return stats.CacheHitCount > 0, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,20 +45,13 @@ func TestGetCachedReleases_CacheHit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ttl := 24 * time.Hour
|
ttl := 24 * time.Hour
|
||||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
count, err := GetCachedReleases(db, artistID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stats.CacheHitCount != 2 {
|
if count != 2 {
|
||||||
t.Errorf("CacheHitCount = %d, want 2", stats.CacheHitCount)
|
t.Errorf("GetCachedReleases() = %d, want 2", count)
|
||||||
}
|
|
||||||
|
|
||||||
if !stats.IsCached("rg-hit-1") {
|
|
||||||
t.Error("expected rg-hit-1 to be cached")
|
|
||||||
}
|
|
||||||
if !stats.IsCached("rg-hit-2") {
|
|
||||||
t.Error("expected rg-hit-2 to be cached")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,17 +78,13 @@ func TestGetCachedReleases_CacheMiss_Expired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ttl := 24 * time.Hour
|
ttl := 24 * time.Hour
|
||||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
count, err := GetCachedReleases(db, artistID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stats.CacheHitCount != 0 {
|
if count != 0 {
|
||||||
t.Errorf("CacheHitCount = %d, want 0 (expired entry should not be cached)", stats.CacheHitCount)
|
t.Errorf("GetCachedReleases() = %d, want 0 (expired entry should not be cached)", count)
|
||||||
}
|
|
||||||
|
|
||||||
if stats.IsCached("rg-expired") {
|
|
||||||
t.Error("expected rg-expired to NOT be cached")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,13 +110,13 @@ func TestGetCachedReleases_CacheMiss_NoCachedAt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ttl := 24 * time.Hour
|
ttl := 24 * time.Hour
|
||||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
count, err := GetCachedReleases(db, artistID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stats.CacheHitCount != 0 {
|
if count != 0 {
|
||||||
t.Errorf("CacheHitCount = %d, want 0 (NULL cached_at should not be cached)", stats.CacheHitCount)
|
t.Errorf("GetCachedReleases() = %d, want 0 (NULL cached_at should not be cached)", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,70 +128,13 @@ func TestGetCachedReleases_EmptyArtist(t *testing.T) {
|
|||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
ttl := 24 * time.Hour
|
ttl := 24 * time.Hour
|
||||||
stats, err := GetCachedReleases(db, "nonexistent-artist", ttl)
|
count, err := GetCachedReleases(db, "nonexistent-artist", ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stats.CacheHitCount != 0 {
|
if count != 0 {
|
||||||
t.Errorf("CacheHitCount = %d, want 0 for nonexistent artist", stats.CacheHitCount)
|
t.Errorf("GetCachedReleases() = %d, want 0 for nonexistent artist", count)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsArtistCacheValid_Valid(t *testing.T) {
|
|
||||||
db, err := database.New(":memory:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
artistID := "artist-valid-cache"
|
|
||||||
if err := insertTestArtistForCache(db, artistID); err != nil {
|
|
||||||
t.Fatalf("insertTestArtist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
|
||||||
_, err = db.Conn().Exec(
|
|
||||||
"INSERT INTO external_releases (rgid, artist_id, title, cached_at) VALUES (?, ?, ?, ?)",
|
|
||||||
"rg-valid", artistID, "Valid Album", now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ttl := 24 * time.Hour
|
|
||||||
valid, err := IsArtistCacheValid(db, artistID, ttl)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("IsArtistCacheValid() error: %v", err)
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
t.Error("expected cache to be valid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsArtistCacheValid_Invalid(t *testing.T) {
|
|
||||||
db, err := database.New(":memory:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error: %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
ttl := 24 * time.Hour
|
|
||||||
|
|
||||||
// No releases at all
|
|
||||||
valid, err := IsArtistCacheValid(db, "no-releases", ttl)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("IsArtistCacheValid() error: %v", err)
|
|
||||||
}
|
|
||||||
if valid {
|
|
||||||
t.Error("expected cache to be invalid for artist with no releases")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCacheStats_IsCached_Empty(t *testing.T) {
|
|
||||||
stats := &CacheStats{}
|
|
||||||
if stats.IsCached("anything") {
|
|
||||||
t.Error("expected IsCached to return false for empty stats")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,18 +170,12 @@ func TestGetCachedReleases_MixedExpiry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ttl := 24 * time.Hour
|
ttl := 24 * time.Hour
|
||||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
count, err := GetCachedReleases(db, artistID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stats.CacheHitCount != 1 {
|
if count != 1 {
|
||||||
t.Errorf("CacheHitCount = %d, want 1 (only fresh entry)", stats.CacheHitCount)
|
t.Errorf("GetCachedReleases() = %d, want 1 (only fresh entry)", count)
|
||||||
}
|
|
||||||
if !stats.IsCached("rg-fresh") {
|
|
||||||
t.Error("expected rg-fresh to be cached")
|
|
||||||
}
|
|
||||||
if stats.IsCached("rg-old") {
|
|
||||||
t.Error("expected rg-old to NOT be cached (expired)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
@@ -35,23 +36,6 @@ func NewClient(cfg config.MusicBrainzConfig) *MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClientWithLimiter creates a MusicBrainzClient with a custom rate limiter.
|
|
||||||
// This is primarily used for testing to inject a mock rate limiter.
|
|
||||||
func NewClientWithLimiter(cfg config.MusicBrainzConfig, rl *rate.Limiter) *MusicBrainzClient {
|
|
||||||
return &MusicBrainzClient{
|
|
||||||
httpClient: &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
},
|
|
||||||
userAgent: cfg.UserAgent,
|
|
||||||
baseURL: "https://musicbrainz.org/ws/2",
|
|
||||||
rateLimiter: rl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close is a no-op for the x/time/rate-based client (the limiter does not
|
|
||||||
// spawn goroutines), but retained for API compatibility.
|
|
||||||
func (c *MusicBrainzClient) Close() {}
|
|
||||||
|
|
||||||
// doGet performs a rate-limited HTTP GET request to the MusicBrainz API.
|
// doGet performs a rate-limited HTTP GET request to the MusicBrainz API.
|
||||||
// It blocks until the rate limiter allows the request, then sets the proper
|
// It blocks until the rate limiter allows the request, then sets the proper
|
||||||
// User-Agent header and returns the response body.
|
// User-Agent header and returns the response body.
|
||||||
@@ -126,18 +110,6 @@ type mbReleaseGroupList struct {
|
|||||||
ReleaseGroupList mbReleaseGroupListXML `xml:"release-group-list"`
|
ReleaseGroupList mbReleaseGroupListXML `xml:"release-group-list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// mbArtistData represents the artist element inside metadata.
|
|
||||||
type mbArtistData struct {
|
|
||||||
ID string `xml:"id,attr"`
|
|
||||||
Name string `xml:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// mbArtist represents the XML structure of a MusicBrainz artist response.
|
|
||||||
type mbArtist struct {
|
|
||||||
XMLName xml.Name `xml:"metadata"`
|
|
||||||
Artist mbArtistData `xml:"artist"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseReleaseGroups parses a MusicBrainz release-group list XML response
|
// ParseReleaseGroups parses a MusicBrainz release-group list XML response
|
||||||
// into a ParsedReleaseGroups struct.
|
// into a ParsedReleaseGroups struct.
|
||||||
func ParseReleaseGroups(data []byte) (*ParsedReleaseGroups, error) {
|
func ParseReleaseGroups(data []byte) (*ParsedReleaseGroups, error) {
|
||||||
@@ -163,14 +135,7 @@ func ParseReleaseGroups(data []byte) (*ParsedReleaseGroups, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseArtist parses a MusicBrainz artist XML response into a ParsedArtist struct.
|
// buildPath constructs a properly URL-encoded query path for the MusicBrainz API.
|
||||||
func ParseArtist(data []byte) (*ParsedArtist, error) {
|
func buildPath(endpoint string, params url.Values) string {
|
||||||
var artist mbArtist
|
return endpoint + "?" + params.Encode()
|
||||||
if err := xml.Unmarshal(data, &artist); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse artist XML: %w", err)
|
|
||||||
}
|
|
||||||
return &ParsedArtist{
|
|
||||||
ID: artist.Artist.ID,
|
|
||||||
Name: artist.Artist.Name,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,7 +18,6 @@ func TestNewClient_ValidConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := NewClient(cfg)
|
client := NewClient(cfg)
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
if client == nil {
|
if client == nil {
|
||||||
t.Fatal("NewClient() returned nil client")
|
t.Fatal("NewClient() returned nil client")
|
||||||
@@ -41,32 +41,15 @@ func TestNewClient_ValidConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewClientWithLimiter(t *testing.T) {
|
|
||||||
cfg := config.MusicBrainzConfig{
|
|
||||||
UserAgent: "naviwatcher/0.1.0 (test@example.com)",
|
|
||||||
}
|
|
||||||
|
|
||||||
rl := rate.NewLimiter(rate.Limit(1), 1)
|
|
||||||
client := NewClientWithLimiter(cfg, rl)
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
if client == nil {
|
|
||||||
t.Fatal("NewClientWithLimiter() returned nil client")
|
|
||||||
}
|
|
||||||
|
|
||||||
if client.rateLimiter != rl {
|
|
||||||
t.Error("NewClientWithLimiter() did not use provided rate limiter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDoGet_Success(t *testing.T) {
|
func TestDoGet_Success(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var gotUserAgent, gotAccept string
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get("User-Agent") == "" {
|
mu.Lock()
|
||||||
t.Error("doGet() request missing User-Agent header")
|
gotUserAgent = r.Header.Get("User-Agent")
|
||||||
}
|
gotAccept = r.Header.Get("Accept")
|
||||||
if r.Header.Get("Accept") != "application/xml" {
|
mu.Unlock()
|
||||||
t.Errorf("doGet() Accept header = %q, want %q", r.Header.Get("Accept"), "application/xml")
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
w.Write([]byte(`<metadata><test>ok</test></metadata>`))
|
w.Write([]byte(`<metadata><test>ok</test></metadata>`))
|
||||||
}))
|
}))
|
||||||
@@ -83,7 +66,6 @@ func TestDoGet_Success(t *testing.T) {
|
|||||||
baseURL: server.URL,
|
baseURL: server.URL,
|
||||||
rateLimiter: rl,
|
rateLimiter: rl,
|
||||||
}
|
}
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
body, err := client.doGet(context.Background(), "/test")
|
body, err := client.doGet(context.Background(), "/test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,6 +75,15 @@ func TestDoGet_Success(t *testing.T) {
|
|||||||
if string(body) != `<metadata><test>ok</test></metadata>` {
|
if string(body) != `<metadata><test>ok</test></metadata>` {
|
||||||
t.Errorf("doGet() body = %q", string(body))
|
t.Errorf("doGet() body = %q", string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
if gotUserAgent == "" {
|
||||||
|
t.Error("doGet() request missing User-Agent header")
|
||||||
|
}
|
||||||
|
if gotAccept != "application/xml" {
|
||||||
|
t.Errorf("doGet() Accept header = %q, want %q", gotAccept, "application/xml")
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDoGet_Non200Status(t *testing.T) {
|
func TestDoGet_Non200Status(t *testing.T) {
|
||||||
@@ -113,7 +104,6 @@ func TestDoGet_Non200Status(t *testing.T) {
|
|||||||
baseURL: server.URL,
|
baseURL: server.URL,
|
||||||
rateLimiter: rl,
|
rateLimiter: rl,
|
||||||
}
|
}
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
_, err := client.doGet(context.Background(), "/test")
|
_, err := client.doGet(context.Background(), "/test")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -136,7 +126,6 @@ func TestDoGet_ServerUnreachable(t *testing.T) {
|
|||||||
baseURL: server.URL,
|
baseURL: server.URL,
|
||||||
rateLimiter: rl,
|
rateLimiter: rl,
|
||||||
}
|
}
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
_, err := client.doGet(context.Background(), "/test")
|
_, err := client.doGet(context.Background(), "/test")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -162,7 +151,6 @@ func TestDoGet_ContextCancellation(t *testing.T) {
|
|||||||
baseURL: server.URL,
|
baseURL: server.URL,
|
||||||
rateLimiter: rl,
|
rateLimiter: rl,
|
||||||
}
|
}
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel() // cancel immediately
|
cancel() // cancel immediately
|
||||||
@@ -183,7 +171,7 @@ func TestRateLimiter_OnePerSecond(t *testing.T) {
|
|||||||
t.Fatalf("first Wait() error: %v", err)
|
t.Fatalf("first Wait() error: %v", err)
|
||||||
}
|
}
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
if elapsed > 100*time.Millisecond {
|
if elapsed > 200*time.Millisecond {
|
||||||
t.Errorf("first Wait() took %v, expected near-instant", elapsed)
|
t.Errorf("first Wait() took %v, expected near-instant", elapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +197,7 @@ func TestRateLimiter_BurstBehavior(t *testing.T) {
|
|||||||
rl.Wait(context.Background())
|
rl.Wait(context.Background())
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
if elapsed > 50*time.Millisecond {
|
if elapsed > 200*time.Millisecond {
|
||||||
t.Errorf("burst Wait() took %v, expected near-instant", elapsed)
|
t.Errorf("burst Wait() took %v, expected near-instant", elapsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package musicbrainz
|
package musicbrainz
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// ReleaseGroup represents a MusicBrainz Release Group entity.
|
// ReleaseGroup represents a MusicBrainz Release Group entity.
|
||||||
// This is the primary data model for the provider - we work with
|
// This is the primary data model for the provider - we work with
|
||||||
// Release Groups to minimize duplicates from different releases.
|
// Release Groups to minimize duplicates from different releases.
|
||||||
@@ -15,32 +13,9 @@ type ReleaseGroup struct {
|
|||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist represents a MusicBrainz artist entity.
|
|
||||||
type Artist struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExternalRelease is the normalized form stored in the database,
|
|
||||||
// matching the external_releases table schema.
|
|
||||||
type ExternalRelease struct {
|
|
||||||
RGID string
|
|
||||||
ArtistID string
|
|
||||||
Title string
|
|
||||||
Type string
|
|
||||||
ReleaseDate string
|
|
||||||
CachedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParsedReleaseGroups holds the result of parsing a MusicBrainz
|
// ParsedReleaseGroups holds the result of parsing a MusicBrainz
|
||||||
// release-group list XML response.
|
// release-group list XML response.
|
||||||
type ParsedReleaseGroups struct {
|
type ParsedReleaseGroups struct {
|
||||||
ReleaseGroups []ReleaseGroup
|
ReleaseGroups []ReleaseGroup
|
||||||
Count int
|
Count int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParsedArtist holds the result of parsing a MusicBrainz artist lookup.
|
|
||||||
type ParsedArtist struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -168,34 +168,3 @@ func TestParseReleaseGroups_WithStatus(t *testing.T) {
|
|||||||
t.Errorf("ReleaseGroups[0].Status = %q, want %q", result.ReleaseGroups[0].Status, "Bootleg")
|
t.Errorf("ReleaseGroups[0].Status = %q, want %q", result.ReleaseGroups[0].Status, "Bootleg")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseArtist_Success(t *testing.T) {
|
|
||||||
data := []byte(`<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
|
||||||
<artist id="artist-uuid-1" type="Group">
|
|
||||||
<name>Pink Floyd</name>
|
|
||||||
</artist>
|
|
||||||
</metadata>`)
|
|
||||||
|
|
||||||
result, err := ParseArtist(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseArtist() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.ID != "artist-uuid-1" {
|
|
||||||
t.Errorf("ParseArtist().ID = %q, want %q", result.ID, "artist-uuid-1")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Name != "Pink Floyd" {
|
|
||||||
t.Errorf("ParseArtist().Name = %q, want %q", result.Name, "Pink Floyd")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseArtist_MalformedXML(t *testing.T) {
|
|
||||||
data := []byte(`this is not xml`)
|
|
||||||
|
|
||||||
_, err := ParseArtist(data)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("ParseArtist() expected error for malformed XML, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// 2. If cache hit, return the cached releases immediately.
|
// 2. If cache hit, return the cached releases immediately.
|
||||||
// 3. If cache miss or expired, fetch release groups from MusicBrainz API.
|
// 3. If cache miss or expired, fetch release groups from MusicBrainz API.
|
||||||
// 4. Apply status and type filtering.
|
// 4. Apply status and type filtering.
|
||||||
// 5. Upsert each filtered release group into external_releases with current timestamp.
|
// 5. Within a transaction: delete old entries, then upsert each filtered release group.
|
||||||
// 6. Return the list of external releases.
|
// 6. Return the list of external releases.
|
||||||
//
|
//
|
||||||
// Context cancellation is checked before the API call and between each upsert
|
// Context cancellation is checked before the API call and between each upsert
|
||||||
@@ -32,27 +32,38 @@ func SyncArtistDiscography(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Check cache.
|
// Step 1: Check cache.
|
||||||
cached, err := GetCachedReleases(db, artistMBID, ttl)
|
cachedCount, err := GetCachedReleases(db, artistMBID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("sync artist discography: cache check failed: %w", err)
|
return nil, fmt.Errorf("sync artist discography: cache check failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: If we have cached data, return it.
|
// Step 2: If we have cached data, return it.
|
||||||
if cached.CacheHitCount > 0 {
|
if cachedCount > 0 {
|
||||||
return database.GetExternalReleasesByArtistWithCache(db, artistMBID, ttl)
|
return database.GetExternalReleasesByArtistWithCache(db, artistMBID, ttl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Cache miss — fetch from MusicBrainz API.
|
// Step 3: Cache miss — fetch from MusicBrainz API.
|
||||||
groups, err := client.GetArtistReleaseGroups(ctx, artistMBID)
|
groups, err := client.GetArtistReleaseGroups(ctx, artistMBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("sync artist discography: fetch release groups: %w", err)
|
return nil, fmt.Errorf("sync artist discography: fetch release groups for artist %s: %w", artistMBID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Apply filtering.
|
// Step 4: Apply filtering.
|
||||||
filtered := FilterReleaseGroups(groups)
|
filtered := FilterReleaseGroups(groups)
|
||||||
|
|
||||||
// Step 5: Upsert each release group into the database.
|
// Step 5: Upsert within a transaction — delete old entries first, then insert new ones.
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sync artist discography: begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Delete old entries for this artist to avoid stale records.
|
||||||
|
if _, err := tx.Exec("DELETE FROM external_releases WHERE artist_id = ?", artistMBID); err != nil {
|
||||||
|
return nil, fmt.Errorf("sync artist discography: delete old releases: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var releases []database.ExternalRelease
|
var releases []database.ExternalRelease
|
||||||
for _, rg := range filtered {
|
for _, rg := range filtered {
|
||||||
// Check context cancellation between each upsert.
|
// Check context cancellation between each upsert.
|
||||||
@@ -63,67 +74,19 @@ func SyncArtistDiscography(
|
|||||||
ext := rg.ToExternalRelease()
|
ext := rg.ToExternalRelease()
|
||||||
ext.CachedAt = now
|
ext.CachedAt = now
|
||||||
|
|
||||||
if err := database.SaveExternalRelease(db, ext); err != nil {
|
cachedAtStr := ext.CachedAt.Format("2006-01-02 15:04:05")
|
||||||
return nil, fmt.Errorf("sync artist discography: save release %s: %w", rg.ID, err)
|
if _, err := tx.Exec(
|
||||||
|
"INSERT INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored, cached_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
ext.RGID, ext.ArtistID, ext.Title, ext.Type, ext.ReleaseDate, ext.IsIgnored, cachedAtStr,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("sync artist discography: insert release %s: %w", rg.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
releases = append(releases, *ext)
|
releases = append(releases, *ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
return releases, nil
|
if err := tx.Commit(); err != nil {
|
||||||
}
|
return nil, fmt.Errorf("sync artist discography: commit transaction: %w", err)
|
||||||
|
|
||||||
// SyncArtistDiscographyWithFilter works like SyncArtistDiscography but applies
|
|
||||||
// per-artist type filtering preferences in addition to the base filters.
|
|
||||||
func SyncArtistDiscographyWithFilter(
|
|
||||||
ctx context.Context,
|
|
||||||
client *MusicBrainzClient,
|
|
||||||
db *database.DB,
|
|
||||||
artistMBID string,
|
|
||||||
ttl time.Duration,
|
|
||||||
artistFilter *ArtistTypeFilter,
|
|
||||||
) ([]database.ExternalRelease, error) {
|
|
||||||
// Check context before starting.
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("sync artist discography with filter: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Check cache.
|
|
||||||
cached, err := GetCachedReleases(db, artistMBID, ttl)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sync artist discography with filter: cache check failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: If we have cached data, return it.
|
|
||||||
if cached.CacheHitCount > 0 {
|
|
||||||
return database.GetExternalReleasesByArtistWithCache(db, artistMBID, ttl)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Cache miss — fetch from MusicBrainz API.
|
|
||||||
groups, err := client.GetArtistReleaseGroups(ctx, artistMBID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("sync artist discography with filter: fetch release groups: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Apply filtering with artist-specific type preferences.
|
|
||||||
filtered := FilterReleaseGroupsWithArtistFilter(groups, artistFilter)
|
|
||||||
|
|
||||||
// Step 5: Upsert each release group into the database.
|
|
||||||
now := time.Now()
|
|
||||||
var releases []database.ExternalRelease
|
|
||||||
for _, rg := range filtered {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("sync artist discography with filter: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := rg.ToExternalRelease()
|
|
||||||
ext.CachedAt = now
|
|
||||||
|
|
||||||
if err := database.SaveExternalRelease(db, ext); err != nil {
|
|
||||||
return nil, fmt.Errorf("sync artist discography with filter: save release %s: %w", rg.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
releases = append(releases, *ext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return releases, nil
|
return releases, nil
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func newTestClient(serverURL string) *MusicBrainzClient {
|
|||||||
return &MusicBrainzClient{
|
return &MusicBrainzClient{
|
||||||
httpClient: &http.Client{},
|
httpClient: &http.Client{},
|
||||||
userAgent: cfg.UserAgent,
|
userAgent: cfg.UserAgent,
|
||||||
baseURL: serverURL + "/ws/2",
|
baseURL: serverURL,
|
||||||
rateLimiter: rate.NewLimiter(rate.Limit(100), 100),
|
rateLimiter: rate.NewLimiter(rate.Limit(100), 100),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,10 +337,12 @@ func TestSyncArtistDiscography_ContextCancellation(t *testing.T) {
|
|||||||
// Test: SyncArtistDiscography upsert is idempotent (re-sync replaces)
|
// Test: SyncArtistDiscography upsert is idempotent (re-sync replaces)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
func TestSyncArtistDiscography_IdempotentUpsert(t *testing.T) {
|
func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
|
||||||
artistMBID := "ffffffff-0000-1111-2222-333333333333"
|
artistMBID := "ffffffff-0000-1111-2222-333333333333"
|
||||||
|
|
||||||
|
callCount := 0
|
||||||
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
|
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
resp := mbReleaseGroupListResponse(
|
resp := mbReleaseGroupListResponse(
|
||||||
mbReleaseGroupXML("rg1", "Album One", "Album", "", artistMBID, "Artist", "2020-01-01")+
|
mbReleaseGroupXML("rg1", "Album One", "Album", "", artistMBID, "Artist", "2020-01-01")+
|
||||||
@@ -357,28 +359,39 @@ func TestSyncArtistDiscography_IdempotentUpsert(t *testing.T) {
|
|||||||
|
|
||||||
client := newTestClient(server.URL)
|
client := newTestClient(server.URL)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ttl := 24 * time.Hour
|
|
||||||
|
|
||||||
// First sync.
|
// First sync.
|
||||||
releases1, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
|
releases1, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first SyncArtistDiscography() error: %v", err)
|
t.Fatalf("first SyncArtistDiscography() error: %v", err)
|
||||||
}
|
}
|
||||||
if len(releases1) != 2 {
|
if len(releases1) != 2 {
|
||||||
t.Fatalf("expected 2 releases after first sync, got %d", len(releases1))
|
t.Fatalf("expected 2 releases after first sync, got %d", len(releases1))
|
||||||
}
|
}
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("expected 1 server call after first sync, got %d", callCount)
|
||||||
|
}
|
||||||
|
|
||||||
// Second sync should use cache (server would error if called again).
|
// Force cache expiry by setting cached_at to the past.
|
||||||
// To verify the cache path, we use a very short TTL so cache expires.
|
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?",
|
||||||
releases2, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
|
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistMBID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expire cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second sync should re-fetch from API (cache expired).
|
||||||
|
releases2, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second SyncArtistDiscography() error: %v", err)
|
t.Fatalf("second SyncArtistDiscography() error: %v", err)
|
||||||
}
|
}
|
||||||
if len(releases2) != 2 {
|
if len(releases2) != 2 {
|
||||||
t.Fatalf("expected 2 releases after second sync, got %d", len(releases2))
|
t.Fatalf("expected 2 releases after second sync, got %d", len(releases2))
|
||||||
}
|
}
|
||||||
|
if callCount != 2 {
|
||||||
|
t.Fatalf("expected 2 server calls after forced re-sync, got %d", callCount)
|
||||||
|
}
|
||||||
|
|
||||||
// Verify no duplicates in the database.
|
// Verify no duplicates in the database (transactional delete + insert).
|
||||||
stored, err := database.GetExternalReleasesByArtist(db, artistMBID)
|
stored, err := database.GetExternalReleasesByArtist(db, artistMBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
|
t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
|
||||||
@@ -388,118 +401,6 @@ func TestSyncArtistDiscography_IdempotentUpsert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test: SyncArtistDiscographyWithFilter applies per-artist type filter
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestSyncArtistDiscographyWithFilter_ArtistTypeFilter(t *testing.T) {
|
|
||||||
artistMBID := "11111111-2222-3333-4444-555555555555"
|
|
||||||
|
|
||||||
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
|
||||||
resp := mbReleaseGroupListResponse(
|
|
||||||
mbReleaseGroupXML("rg-album", "An Album", "Album", "", artistMBID, "Artist", "2020-01-01")+
|
|
||||||
mbReleaseGroupXML("rg-single", "A Single", "Single", "", artistMBID, "Artist", "2020-02-01")+
|
|
||||||
mbReleaseGroupXML("rg-ep", "An EP", "EP", "", artistMBID, "Artist", "2020-03-01")+
|
|
||||||
mbReleaseGroupXML("rg-comp", "A Compilation", "Compilation", "", artistMBID, "Artist", "2020-04-01"),
|
|
||||||
4,
|
|
||||||
)
|
|
||||||
w.Write([]byte(resp))
|
|
||||||
})
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
db := newTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedArtist(t, db, artistMBID, "Filter Artist")
|
|
||||||
|
|
||||||
client := newTestClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
ttl := 24 * time.Hour
|
|
||||||
|
|
||||||
// Filter that excludes Singles and Compilations.
|
|
||||||
filter := &ArtistTypeFilter{
|
|
||||||
ArtistID: artistMBID,
|
|
||||||
IncludeSingles: false,
|
|
||||||
IncludeCompilations: false,
|
|
||||||
IncludeEP: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
releases, err := SyncArtistDiscographyWithFilter(ctx, client, db, artistMBID, ttl, filter)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SyncArtistDiscographyWithFilter() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should only have Album and EP.
|
|
||||||
if len(releases) != 2 {
|
|
||||||
t.Fatalf("expected 2 releases with artist filter, got %d", len(releases))
|
|
||||||
}
|
|
||||||
|
|
||||||
rgIDs := make(map[string]bool)
|
|
||||||
for _, r := range releases {
|
|
||||||
rgIDs[r.RGID] = true
|
|
||||||
}
|
|
||||||
if !rgIDs["rg-album"] {
|
|
||||||
t.Error("expected rg-album in filtered results")
|
|
||||||
}
|
|
||||||
if !rgIDs["rg-ep"] {
|
|
||||||
t.Error("expected rg-ep in filtered results")
|
|
||||||
}
|
|
||||||
if rgIDs["rg-single"] {
|
|
||||||
t.Error("rg-single should have been filtered out")
|
|
||||||
}
|
|
||||||
if rgIDs["rg-comp"] {
|
|
||||||
t.Error("rg-comp should have been filtered out")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test: SyncArtistDiscographyWithFilter cache hit
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestSyncArtistDiscographyWithFilter_CacheHit(t *testing.T) {
|
|
||||||
artistMBID := "22222222-3333-4444-5555-666666666666"
|
|
||||||
|
|
||||||
db := newTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedArtist(t, db, artistMBID, "Cache Hit Filter Artist")
|
|
||||||
|
|
||||||
// Pre-populate cache.
|
|
||||||
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
|
|
||||||
RGID: "rg-old",
|
|
||||||
ArtistID: artistMBID,
|
|
||||||
Title: "Old Cached",
|
|
||||||
Type: "Album",
|
|
||||||
CachedAt: time.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("SaveExternalRelease() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
serverCalled := false
|
|
||||||
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
serverCalled = true
|
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
|
||||||
w.Write([]byte(mbReleaseGroupListResponse("", 0)))
|
|
||||||
})
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
client := newTestClient(server.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
ttl := 24 * time.Hour
|
|
||||||
filter := DefaultArtistTypeFilter(artistMBID)
|
|
||||||
|
|
||||||
releases, err := SyncArtistDiscographyWithFilter(ctx, client, db, artistMBID, ttl, filter)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SyncArtistDiscographyWithFilter() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if serverCalled {
|
|
||||||
t.Error("expected cache hit but server was called")
|
|
||||||
}
|
|
||||||
if len(releases) != 1 {
|
|
||||||
t.Fatalf("expected 1 cached release, got %d", len(releases))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Test: SyncArtistDiscography with empty response (no release groups)
|
// Test: SyncArtistDiscography with empty response (no release groups)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -568,47 +469,12 @@ func TestSyncArtistDiscography_APIError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Test: SyncArtistDiscographyWithFilter context cancellation
|
// Test: Verify XML parsing integration — full pipeline with realistic XML
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestSyncArtistDiscographyWithFilter_ContextCancellation(t *testing.T) {
|
|
||||||
artistMBID := "55555555-6666-7777-8888-999999999999"
|
|
||||||
|
|
||||||
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
|
||||||
w.Write([]byte(mbReleaseGroupListResponse(
|
|
||||||
mbReleaseGroupXML("rg1", "Album", "Album", "", artistMBID, "Artist", "2020-01-01"),
|
|
||||||
1,
|
|
||||||
)))
|
|
||||||
})
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
db := newTestDB(t)
|
|
||||||
defer db.Close()
|
|
||||||
seedArtist(t, db, artistMBID, "Cancel Filter Artist")
|
|
||||||
|
|
||||||
client := newTestClient(server.URL)
|
|
||||||
ttl := 24 * time.Hour
|
|
||||||
filter := DefaultArtistTypeFilter(artistMBID)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
_, err := SyncArtistDiscographyWithFilter(ctx, client, db, artistMBID, ttl, filter)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("SyncArtistDiscographyWithFilter() expected error for cancelled context, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test: Verify XML parsing integration — ensure the full pipeline works
|
|
||||||
// with real XML structure matching MusicBrainz responses.
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
|
func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
|
||||||
artistMBID := "66666666-7777-8888-9999-000000000000"
|
artistMBID := "66666666-7777-8888-9999-000000000000"
|
||||||
|
|
||||||
// Build a realistic MusicBrainz XML response.
|
|
||||||
xmlBody := `<?xml version="1.0" encoding="UTF-8"?>
|
xmlBody := `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<metadata>
|
<metadata>
|
||||||
<release-group-list count="2">
|
<release-group-list count="2">
|
||||||
@@ -660,7 +526,6 @@ func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
|
|||||||
t.Fatalf("expected 2 releases, got %d", len(releases))
|
t.Fatalf("expected 2 releases, got %d", len(releases))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the parsed data.
|
|
||||||
byID := make(map[string]database.ExternalRelease)
|
byID := make(map[string]database.ExternalRelease)
|
||||||
for _, r := range releases {
|
for _, r := range releases {
|
||||||
byID[r.RGID] = r
|
byID[r.RGID] = r
|
||||||
@@ -735,7 +600,7 @@ func TestSyncArtistDiscography_ConsistentCachedAtTimestamp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Test: Verify XML unmarshalling edge case — release-group with no type attr
|
// Test: Verify XML edge case — release-group with no type attribute
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) {
|
func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) {
|
||||||
@@ -796,3 +661,82 @@ func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) {
|
|||||||
t.Errorf("expected rg-withtype, got %s", releases[0].RGID)
|
t.Errorf("expected rg-withtype, got %s", releases[0].RGID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test: Stale releases are cleaned up on re-sync
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
|
||||||
|
artistMBID := "99999999-0000-1111-2222-333333333333"
|
||||||
|
|
||||||
|
callCount := 0
|
||||||
|
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
if callCount == 1 {
|
||||||
|
// First call: return 3 releases.
|
||||||
|
resp := mbReleaseGroupListResponse(
|
||||||
|
mbReleaseGroupXML("rg-old-1", "Old Album 1", "Album", "", artistMBID, "Artist", "2018-01-01")+
|
||||||
|
mbReleaseGroupXML("rg-old-2", "Old Album 2", "Album", "", artistMBID, "Artist", "2019-01-01")+
|
||||||
|
mbReleaseGroupXML("rg-old-3", "Old Album 3", "Album", "", artistMBID, "Artist", "2020-01-01"),
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
} else {
|
||||||
|
// Second call: return only 2 (one was removed).
|
||||||
|
resp := mbReleaseGroupListResponse(
|
||||||
|
mbReleaseGroupXML("rg-old-1", "Old Album 1", "Album", "", artistMBID, "Artist", "2018-01-01")+
|
||||||
|
mbReleaseGroupXML("rg-old-2", "Old Album 2", "Album", "", artistMBID, "Artist", "2019-01-01"),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
db := newTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
seedArtist(t, db, artistMBID, "Stale Artist")
|
||||||
|
|
||||||
|
client := newTestClient(server.URL)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First sync: 3 releases.
|
||||||
|
releases1, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first SyncArtistDiscography() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(releases1) != 3 {
|
||||||
|
t.Fatalf("expected 3 releases after first sync, got %d", len(releases1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force cache expiry by setting cached_at to the past.
|
||||||
|
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?",
|
||||||
|
time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistMBID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expire cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second sync should re-fetch from API (cache expired).
|
||||||
|
releases2, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second SyncArtistDiscography() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(releases2) != 2 {
|
||||||
|
t.Fatalf("expected 2 releases after second sync, got %d", len(releases2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify stale release was cleaned from DB.
|
||||||
|
stored, err := database.GetExternalReleasesByArtist(db, artistMBID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(stored) != 2 {
|
||||||
|
t.Errorf("expected 2 stored releases (stale cleaned), got %d", len(stored))
|
||||||
|
}
|
||||||
|
for _, r := range stored {
|
||||||
|
if r.RGID == "rg-old-3" {
|
||||||
|
t.Error("stale release rg-old-3 should have been removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user