package musicbrainz import ( "context" "fmt" "time" "naviwatcher/internal/database" ) // MBIDResolver resolves a MusicBrainz artist ID for an artist name. // The real *MusicBrainzClient satisfies this interface. type MBIDResolver interface { ResolveArtistMBID(ctx context.Context, name string) (string, error) } // ArtistDiscographySyncer syncs one artist's MusicBrainz discography into the // external_releases table. The real implementation (musicbrainz.SyncArtistDiscography) // is wrapped by discographySyncer so the concrete *MusicBrainzClient dependency // is injectable in tests. type ArtistDiscographySyncer interface { SyncArtistDiscography(ctx context.Context, db *database.DB, artistID, artistMBID string, ttl time.Duration) ([]database.ExternalRelease, error) } // discographySyncer adapts the package-level SyncArtistDiscography function to // the ArtistDiscographySyncer interface, binding a concrete *MusicBrainzClient. type discographySyncer struct { client *MusicBrainzClient } // NewDiscographySyncer wraps a *MusicBrainzClient as an ArtistDiscographySyncer. func NewDiscographySyncer(client *MusicBrainzClient) ArtistDiscographySyncer { return &discographySyncer{client: client} } func (s *discographySyncer) SyncArtistDiscography( ctx context.Context, db *database.DB, artistID, artistMBID string, ttl time.Duration, ) ([]database.ExternalRelease, error) { return SyncArtistDiscography(ctx, s.client, db, artistID, artistMBID, ttl) } // AlbumSyncer copies each monitored artist's albums from Navidrome into the // local_albums table. The real implementation (navidrome.SyncAlbums) is wrapped // so the concrete *navidrome.NavidromeClient dependency is injectable in tests. type AlbumSyncer interface { SyncAlbums(ctx context.Context, db *database.DB) error } // albumSyncer adapts navidrome.SyncAlbums to the AlbumSyncer interface. type albumSyncer struct { syncAlbums func(ctx context.Context, db *database.DB) error } func (s *albumSyncer) SyncAlbums(ctx context.Context, db *database.DB) error { return s.syncAlbums(ctx, db) } // NewAlbumSyncer adapts the given SyncAlbums function (typically // navidrome.SyncAlbums) into an AlbumSyncer for injection into SyncAll. func NewAlbumSyncer(syncAlbums func(ctx context.Context, db *database.DB) error) AlbumSyncer { return &albumSyncer{syncAlbums: syncAlbums} } // SyncAll orchestrates the data pipeline for every monitored artist: // 1. MusicBrainz artist-ID resolution — for each artist with no cached MBID, // resolve it by name and persist it on the artist_settings row. Artists that // already have an MBID reuse it (no extra rate-limited MusicBrainz call). // 2. MusicBrainz discography sync into external_releases. // 3. Navidrome album sync into local_albums. // // Ordering matters: Navidrome's artist/album tables are populated by the caller // before SyncAll (via navidrome.SyncArtists / SyncAlbums as appropriate); here we // focus on the per-artist MBID + discography + album refresh. Unmonitored artists // are skipped. // // Resolution failures for a single artist are logged and skipped (the artist is // left for the next sync) rather than aborting the whole run; the error is still // returned so the caller can decide whether to surface it. func SyncAll( ctx context.Context, db *database.DB, resolver MBIDResolver, discography ArtistDiscographySyncer, albums AlbumSyncer, ttl time.Duration, ) error { if err := ctx.Err(); err != nil { return fmt.Errorf("sync all: %w", err) } artists, err := database.GetAllArtistSettings(db) if err != nil { return fmt.Errorf("sync all: get artists: %w", err) } var resolutionErr error for _, artist := range artists { if err := ctx.Err(); err != nil { return fmt.Errorf("sync all: %w", err) } // Skip unmonitored artists entirely. if !artist.Monitored { continue } // Ensure we have an MBID; resolve and persist if missing. mbid := artist.MBID if mbid == "" { resolved, rerr := resolver.ResolveArtistMBID(ctx, artist.Name) if rerr != nil { // Skip this artist but remember the first resolution error. if resolutionErr == nil { resolutionErr = fmt.Errorf("resolve MBID for artist %q: %w", artist.Name, rerr) } continue } mbid = resolved if perr := database.UpdateArtistSettings(db, artist.ID, map[string]interface{}{"mbid": mbid}); perr != nil { if resolutionErr == nil { resolutionErr = fmt.Errorf("persist MBID for artist %q: %w", artist.Name, perr) } continue } } // Sync the artist's MusicBrainz discography. if _, derr := discography.SyncArtistDiscography(ctx, db, artist.ID, mbid, ttl); derr != nil { if resolutionErr == nil { resolutionErr = fmt.Errorf("sync discography for artist %q: %w", artist.Name, derr) } continue } } // Album sync operates over all monitored artists in one pass. if aerr := albums.SyncAlbums(ctx, db); aerr != nil { if resolutionErr == nil { resolutionErr = fmt.Errorf("sync albums: %w", aerr) } } return resolutionErr }