Replace compute-only run() with an immediate sync+scan followed by a ticker-driven periodic loop, add the sync.interval config field (default 6h) with defaults and validation, and add tests for the scheduling logic.
148 lines
4.9 KiB
Go
148 lines
4.9 KiB
Go
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
|
|
}
|