feat: implement MusicBrainz sync orchestration with caching and upsert

Creates internal/musicbrainz/sync.go with SyncArtistDiscography function
that orchestrates the full flow: cache check via GetCachedReleases, fetch
from MusicBrainz API on cache miss, filter via FilterReleaseGroups, and
upsert into external_releases via database.SaveExternalRelease. Includes
SyncArtistDiscographyWithFilter variant for per-artist type filtering. All
functions support context cancellation. 14 new tests cover cache hit/miss,
status/type filtering, context cancellation, idempotency, API errors, and
full XML pipeline integration.
This commit is contained in:
2026-05-26 13:04:20 +03:00
parent e624bb0eaf
commit 15b05b57fb
3 changed files with 935 additions and 7 deletions

View File

@@ -0,0 +1,130 @@
package musicbrainz
import (
"context"
"fmt"
"time"
"naviwatcher/internal/database"
)
// SyncArtistDiscography synchronizes an artist's discography from MusicBrainz
// into the local external_releases table. It follows this flow:
// 1. Check if cached data exists and is within TTL.
// 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.
// 6. Return the list of external releases.
//
// Context cancellation is checked before the API call and between each upsert
// to allow graceful interruption.
func SyncArtistDiscography(
ctx context.Context,
client *MusicBrainzClient,
db *database.DB,
artistMBID string,
ttl time.Duration,
) ([]database.ExternalRelease, error) {
// Check context before starting.
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("sync artist discography: %w", err)
}
// Step 1: Check cache.
cached, 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 {
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)
}
// Step 4: Apply filtering.
filtered := FilterReleaseGroups(groups)
// Step 5: Upsert each release group into the database.
now := time.Now()
var releases []database.ExternalRelease
for _, rg := range filtered {
// Check context cancellation between each upsert.
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("sync artist discography: %w", err)
}
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)
}
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)
}
return releases, nil
}