musicbrainz-provider #2
@@ -45,7 +45,8 @@ func main() {
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
log.Printf("Received signal %v, shutting down...", sig)
|
||||
cancel()}()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
app, err := NewApp(ctx, cfg)
|
||||
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.
|
||||
func (a *App) Close() {
|
||||
if a.mbClient != nil {
|
||||
a.mbClient.Close()
|
||||
}
|
||||
if a.db != nil {
|
||||
if err := a.db.Close(); err != nil {
|
||||
log.Printf("Error closing database: %v", err)
|
||||
|
||||
@@ -9,36 +9,6 @@ import (
|
||||
"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) {
|
||||
// Integration test: write a minimal valid config and load it via config.LoadConfig,
|
||||
// verifying the full path that main() uses.
|
||||
@@ -77,10 +47,6 @@ musicbrainz:
|
||||
|
||||
func TestNewApp_CreatesMusicBrainzClient(t *testing.T) {
|
||||
// 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{
|
||||
Server: config.ServerConfig{
|
||||
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.
|
||||
func SaveExternalRelease(db *DB, release *ExternalRelease) error {
|
||||
cachedAtStr := ""
|
||||
var cachedAt interface{}
|
||||
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(
|
||||
"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 {
|
||||
return fmt.Errorf("save external release: %w", err)
|
||||
@@ -158,8 +158,8 @@ func GetExternalReleasesByArtistWithCache(db *DB, artistID string, ttl time.Dura
|
||||
var results []ExternalRelease
|
||||
for rows.Next() {
|
||||
var r ExternalRelease
|
||||
var releaseDate sql.NullString
|
||||
var releaseType sql.NullString
|
||||
var releaseDate sql.NullString
|
||||
var cachedAt sql.NullTime
|
||||
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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package musicbrainz
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -25,30 +26,6 @@ var includedTypes = map[string]bool{
|
||||
"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.
|
||||
@@ -61,7 +38,12 @@ func (c *MusicBrainzClient) GetArtistReleaseGroups(ctx context.Context, artistMB
|
||||
limit := 100 // MusicBrainz max limit per request
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch release groups for artist %s: %w", artistMBID, err)
|
||||
@@ -101,22 +83,6 @@ func FilterReleaseGroups(groups []ReleaseGroup) []ReleaseGroup {
|
||||
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]
|
||||
@@ -127,27 +93,6 @@ 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)
|
||||
@@ -212,7 +157,7 @@ func NormalizeArtistName(name string) string {
|
||||
}
|
||||
|
||||
// ToExternalRelease converts a ReleaseGroup to an ExternalRelease
|
||||
// with the current timestamp as CachedAt.
|
||||
// for database persistence.
|
||||
func (rg *ReleaseGroup) ToExternalRelease() *database.ExternalRelease {
|
||||
return &database.ExternalRelease{
|
||||
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}
|
||||
for _, rg := range result {
|
||||
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 ----------
|
||||
|
||||
func TestNormalizeString_Basic(t *testing.T) {
|
||||
@@ -474,7 +210,6 @@ func TestGetArtistReleaseGroups_Success(t *testing.T) {
|
||||
baseURL: server.URL,
|
||||
rateLimiter: rl,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
||||
if err != nil {
|
||||
@@ -504,8 +239,7 @@ func TestGetArtistReleaseGroups_Pagination(t *testing.T) {
|
||||
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
|
||||
// First page: return 100 results (full page, matching limit) to trigger pagination
|
||||
xml := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
|
||||
<release-group-list count="150">`
|
||||
@@ -556,7 +290,6 @@ func TestGetArtistReleaseGroups_Pagination(t *testing.T) {
|
||||
baseURL: server.URL,
|
||||
rateLimiter: rl,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
||||
if err != nil {
|
||||
@@ -597,7 +330,6 @@ func TestGetArtistReleaseGroups_EmptyResult(t *testing.T) {
|
||||
baseURL: server.URL,
|
||||
rateLimiter: rl,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
groups, err := client.GetArtistReleaseGroups(context.Background(), artistMBID)
|
||||
if err != nil {
|
||||
@@ -627,7 +359,6 @@ func TestGetArtistReleaseGroups_ServerError(t *testing.T) {
|
||||
baseURL: server.URL,
|
||||
rateLimiter: rl,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, err := client.GetArtistReleaseGroups(context.Background(), "artist-1")
|
||||
if err == nil {
|
||||
@@ -653,7 +384,6 @@ func TestGetArtistReleaseGroups_InvalidXML(t *testing.T) {
|
||||
baseURL: server.URL,
|
||||
rateLimiter: rl,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, err := client.GetArtistReleaseGroups(context.Background(), "artist-1")
|
||||
if err == nil {
|
||||
|
||||
@@ -7,47 +7,13 @@ import (
|
||||
"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
|
||||
// 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.
|
||||
func GetCachedReleases(db *database.DB, artistID string, ttl time.Duration) (*CacheStats, error) {
|
||||
// It returns the count of cached entries and any error encountered.
|
||||
func GetCachedReleases(db *database.DB, artistID string, ttl time.Duration) (int, error) {
|
||||
releases, err := database.GetExternalReleasesByArtistWithCache(db, artistID, ttl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get cached releases: %w", err)
|
||||
return 0, fmt.Errorf("get cached releases: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
return len(releases), nil
|
||||
}
|
||||
|
||||
@@ -45,20 +45,13 @@ func TestGetCachedReleases_CacheHit(t *testing.T) {
|
||||
}
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
||||
count, err := GetCachedReleases(db, artistID, ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||
}
|
||||
|
||||
if stats.CacheHitCount != 2 {
|
||||
t.Errorf("CacheHitCount = %d, want 2", stats.CacheHitCount)
|
||||
}
|
||||
|
||||
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")
|
||||
if count != 2 {
|
||||
t.Errorf("GetCachedReleases() = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,17 +78,13 @@ func TestGetCachedReleases_CacheMiss_Expired(t *testing.T) {
|
||||
}
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
||||
count, err := GetCachedReleases(db, artistID, ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||
}
|
||||
|
||||
if stats.CacheHitCount != 0 {
|
||||
t.Errorf("CacheHitCount = %d, want 0 (expired entry should not be cached)", stats.CacheHitCount)
|
||||
}
|
||||
|
||||
if stats.IsCached("rg-expired") {
|
||||
t.Error("expected rg-expired to NOT be cached")
|
||||
if count != 0 {
|
||||
t.Errorf("GetCachedReleases() = %d, want 0 (expired entry should not be cached)", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,13 +110,13 @@ func TestGetCachedReleases_CacheMiss_NoCachedAt(t *testing.T) {
|
||||
}
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
||||
count, err := GetCachedReleases(db, artistID, ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||
}
|
||||
|
||||
if stats.CacheHitCount != 0 {
|
||||
t.Errorf("CacheHitCount = %d, want 0 (NULL cached_at should not be cached)", stats.CacheHitCount)
|
||||
if count != 0 {
|
||||
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()
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
stats, err := GetCachedReleases(db, "nonexistent-artist", ttl)
|
||||
count, err := GetCachedReleases(db, "nonexistent-artist", ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||
}
|
||||
|
||||
if stats.CacheHitCount != 0 {
|
||||
t.Errorf("CacheHitCount = %d, want 0 for nonexistent artist", stats.CacheHitCount)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
if count != 0 {
|
||||
t.Errorf("GetCachedReleases() = %d, want 0 for nonexistent artist", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,18 +170,12 @@ func TestGetCachedReleases_MixedExpiry(t *testing.T) {
|
||||
}
|
||||
|
||||
ttl := 24 * time.Hour
|
||||
stats, err := GetCachedReleases(db, artistID, ttl)
|
||||
count, err := GetCachedReleases(db, artistID, ttl)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCachedReleases() error: %v", err)
|
||||
}
|
||||
|
||||
if stats.CacheHitCount != 1 {
|
||||
t.Errorf("CacheHitCount = %d, want 1 (only fresh entry)", stats.CacheHitCount)
|
||||
}
|
||||
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)")
|
||||
if count != 1 {
|
||||
t.Errorf("GetCachedReleases() = %d, want 1 (only fresh entry)", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
// It blocks until the rate limiter allows the request, then sets the proper
|
||||
// User-Agent header and returns the response body.
|
||||
@@ -126,18 +110,6 @@ type mbReleaseGroupList struct {
|
||||
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
|
||||
// into a ParsedReleaseGroups struct.
|
||||
func ParseReleaseGroups(data []byte) (*ParsedReleaseGroups, error) {
|
||||
@@ -163,14 +135,7 @@ func ParseReleaseGroups(data []byte) (*ParsedReleaseGroups, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ParseArtist parses a MusicBrainz artist XML response into a ParsedArtist struct.
|
||||
func ParseArtist(data []byte) (*ParsedArtist, error) {
|
||||
var artist mbArtist
|
||||
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
|
||||
// buildPath constructs a properly URL-encoded query path for the MusicBrainz API.
|
||||
func buildPath(endpoint string, params url.Values) string {
|
||||
return endpoint + "?" + params.Encode()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -17,7 +18,6 @@ func TestNewClient_ValidConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
client := NewClient(cfg)
|
||||
defer client.Close()
|
||||
|
||||
if client == nil {
|
||||
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) {
|
||||
var mu sync.Mutex
|
||||
var gotUserAgent, gotAccept string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("User-Agent") == "" {
|
||||
t.Error("doGet() request missing User-Agent header")
|
||||
}
|
||||
if r.Header.Get("Accept") != "application/xml" {
|
||||
t.Errorf("doGet() Accept header = %q, want %q", r.Header.Get("Accept"), "application/xml")
|
||||
}
|
||||
mu.Lock()
|
||||
gotUserAgent = r.Header.Get("User-Agent")
|
||||
gotAccept = r.Header.Get("Accept")
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(`<metadata><test>ok</test></metadata>`))
|
||||
}))
|
||||
@@ -83,7 +66,6 @@ func TestDoGet_Success(t *testing.T) {
|
||||
baseURL: server.URL,
|
||||
rateLimiter: rl,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
body, err := client.doGet(context.Background(), "/test")
|
||||
if err != nil {
|
||||
@@ -93,6 +75,15 @@ func TestDoGet_Success(t *testing.T) {
|
||||
if string(body) != `<metadata><test>ok</test></metadata>` {
|
||||
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) {
|
||||
@@ -113,7 +104,6 @@ func TestDoGet_Non200Status(t *testing.T) {
|
||||
baseURL: server.URL,
|
||||
rateLimiter: rl,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, err := client.doGet(context.Background(), "/test")
|
||||
if err == nil {
|
||||
@@ -136,7 +126,6 @@ func TestDoGet_ServerUnreachable(t *testing.T) {
|
||||
baseURL: server.URL,
|
||||
rateLimiter: rl,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, err := client.doGet(context.Background(), "/test")
|
||||
if err == nil {
|
||||
@@ -162,7 +151,6 @@ func TestDoGet_ContextCancellation(t *testing.T) {
|
||||
baseURL: server.URL,
|
||||
rateLimiter: rl,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
@@ -183,7 +171,7 @@ func TestRateLimiter_OnePerSecond(t *testing.T) {
|
||||
t.Fatalf("first Wait() error: %v", err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
if elapsed > 100*time.Millisecond {
|
||||
if elapsed > 200*time.Millisecond {
|
||||
t.Errorf("first Wait() took %v, expected near-instant", elapsed)
|
||||
}
|
||||
|
||||
@@ -209,7 +197,7 @@ func TestRateLimiter_BurstBehavior(t *testing.T) {
|
||||
rl.Wait(context.Background())
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 50*time.Millisecond {
|
||||
if elapsed > 200*time.Millisecond {
|
||||
t.Errorf("burst Wait() took %v, expected near-instant", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package musicbrainz
|
||||
|
||||
import "time"
|
||||
|
||||
// ReleaseGroup represents a MusicBrainz Release Group entity.
|
||||
// This is the primary data model for the provider - we work with
|
||||
// Release Groups to minimize duplicates from different releases.
|
||||
@@ -15,32 +13,9 @@ type ReleaseGroup struct {
|
||||
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
|
||||
// release-group list XML response.
|
||||
type ParsedReleaseGroups struct {
|
||||
ReleaseGroups []ReleaseGroup
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// 3. If cache miss or expired, fetch release groups from MusicBrainz API.
|
||||
// 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.
|
||||
//
|
||||
// Context cancellation is checked before the API call and between each upsert
|
||||
@@ -32,27 +32,38 @@ func SyncArtistDiscography(
|
||||
}
|
||||
|
||||
// Step 1: Check cache.
|
||||
cached, err := GetCachedReleases(db, artistMBID, ttl)
|
||||
cachedCount, err := GetCachedReleases(db, artistMBID, ttl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: cache check failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: If we have cached data, return it.
|
||||
if cached.CacheHitCount > 0 {
|
||||
if cachedCount > 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: 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.
|
||||
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()
|
||||
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
|
||||
for _, rg := range filtered {
|
||||
// Check context cancellation between each upsert.
|
||||
@@ -63,67 +74,19 @@ func SyncArtistDiscography(
|
||||
ext := rg.ToExternalRelease()
|
||||
ext.CachedAt = now
|
||||
|
||||
if err := database.SaveExternalRelease(db, ext); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: save release %s: %w", rg.ID, err)
|
||||
cachedAtStr := ext.CachedAt.Format("2006-01-02 15:04:05")
|
||||
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)
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("sync artist discography: commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
|
||||
@@ -76,7 +76,7 @@ func newTestClient(serverURL string) *MusicBrainzClient {
|
||||
return &MusicBrainzClient{
|
||||
httpClient: &http.Client{},
|
||||
userAgent: cfg.UserAgent,
|
||||
baseURL: serverURL + "/ws/2",
|
||||
baseURL: serverURL,
|
||||
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)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func TestSyncArtistDiscography_IdempotentUpsert(t *testing.T) {
|
||||
func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
|
||||
artistMBID := "ffffffff-0000-1111-2222-333333333333"
|
||||
|
||||
callCount := 0
|
||||
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
resp := mbReleaseGroupListResponse(
|
||||
mbReleaseGroupXML("rg1", "Album One", "Album", "", artistMBID, "Artist", "2020-01-01")+
|
||||
@@ -357,28 +359,39 @@ func TestSyncArtistDiscography_IdempotentUpsert(t *testing.T) {
|
||||
|
||||
client := newTestClient(server.URL)
|
||||
ctx := context.Background()
|
||||
ttl := 24 * time.Hour
|
||||
|
||||
// First sync.
|
||||
releases1, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
|
||||
releases1, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("first SyncArtistDiscography() error: %v", err)
|
||||
}
|
||||
if len(releases1) != 2 {
|
||||
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).
|
||||
// To verify the cache path, we use a very short TTL so cache expires.
|
||||
releases2, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
|
||||
// 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))
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -568,47 +469,12 @@ func TestSyncArtistDiscography_APIError(t *testing.T) {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: SyncArtistDiscographyWithFilter context cancellation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
// Test: Verify XML parsing integration — full pipeline with realistic XML
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
|
||||
artistMBID := "66666666-7777-8888-9999-000000000000"
|
||||
|
||||
// Build a realistic MusicBrainz XML response.
|
||||
xmlBody := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<release-group-list count="2">
|
||||
@@ -660,7 +526,6 @@ func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
|
||||
t.Fatalf("expected 2 releases, got %d", len(releases))
|
||||
}
|
||||
|
||||
// Verify the parsed data.
|
||||
byID := make(map[string]database.ExternalRelease)
|
||||
for _, r := range releases {
|
||||
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) {
|
||||
@@ -796,3 +661,82 @@ func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) {
|
||||
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