package musicbrainz import ( "context" "database/sql" "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. Within a transaction: delete old entries, then upsert each filtered release group. // 6. Return the list of external releases. // // artistID is the canonical artist key from artist_settings (the Navidrome // artist ID). It is stored as external_releases.artist_id so that the foreign // key to artist_settings and the scanner's join on ArtistID resolve correctly. // artistMBID is the MusicBrainz ID used only to query the MusicBrainz API. // // 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, artistID string, 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. cachedReleases, err := GetCachedReleases(db, artistID, 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. Re-apply the per-artist type // toggles even on a cache hit so user changes to ignore_singles / // ignore_compilations take effect without waiting for cache expiry. // (Status/type inclusion was already applied when the rows were first // synced and stored, so only the toggles can change.) if len(cachedReleases) > 0 { if err := ctx.Err(); err != nil { return nil, fmt.Errorf("sync artist discography: %w", err) } opts, err := getArtistFilterOptions(db, artistID) if err != nil { return nil, fmt.Errorf("sync artist discography: read artist filter options: %w", err) } filtered := make([]database.ExternalRelease, 0, len(cachedReleases)) for _, r := range cachedReleases { if opts.IgnoreSingles && (r.Type == "Single" || hasSliceType(r.SecondaryTypes, "Single")) { continue } if opts.IgnoreCompilations && (r.Type == "Compilation" || hasSliceType(r.SecondaryTypes, "Compilation")) { continue } filtered = append(filtered, r) } return filtered, nil } // 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 for artist %s: %w", artistMBID, err) } // Step 4: Apply filtering with per-artist type preferences. opts, err := getArtistFilterOptions(db, artistID) if err != nil { return nil, fmt.Errorf("sync artist discography: read artist filter options: %w", err) } filtered := FilterReleaseGroups(groups, opts) // Step 5: Upsert within a transaction — delete old entries first, then insert new ones. now := time.Now().UTC() tx, err := db.Begin() if err != nil { return nil, fmt.Errorf("sync artist discography: begin transaction: %w", err) } defer tx.Rollback() // Read existing ignore states before deleting to preserve user-set flags. ignoredMap := map[string]bool{} rows, err := tx.Query("SELECT rgid, is_ignored FROM external_releases WHERE artist_id = ?", artistID) if err != nil { return nil, fmt.Errorf("sync artist discography: query existing releases: %w", err) } for rows.Next() { var rgid string var ignored bool if err := rows.Scan(&rgid, &ignored); err != nil { rows.Close() return nil, fmt.Errorf("sync artist discography: scan existing release: %w", err) } ignoredMap[rgid] = ignored } rows.Close() // Delete old entries for this artist to avoid stale records. // Must delete notifications_sent first to avoid FK violation since // notifications_sent.rgid references external_releases.rgid. if _, err := tx.Exec( "DELETE FROM notifications_sent WHERE rgid IN (SELECT rgid FROM external_releases WHERE artist_id = ?)", artistID, ); err != nil { return nil, fmt.Errorf("sync artist discography: delete old notifications: %w", err) } if _, err := tx.Exec("DELETE FROM external_releases WHERE artist_id = ?", artistID); 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. if err := ctx.Err(); err != nil { return nil, fmt.Errorf("sync artist discography: %w", err) } ext := rg.ToExternalRelease(artistID) ext.CachedAt = now // Preserve user-set ignore flag from previous sync. if ignored, ok := ignoredMap[ext.RGID]; ok { ext.IsIgnored = ignored } if _, err := tx.Exec( "INSERT INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored, cached_at, secondary_types) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ext.RGID, ext.ArtistID, ext.Title, ext.Type, ext.ReleaseDate, ext.IsIgnored, database.FormatCachedAt(ext.CachedAt), database.JoinSecondaryTypes(ext.SecondaryTypes), ); err != nil { return nil, fmt.Errorf("sync artist discography: insert 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 } // getArtistFilterOptions reads per-artist type filtering preferences. // Defaults to no filtering if artist_settings row doesn't exist. // artistID is the Navidrome artist ID (artist_settings.id), not the MusicBrainz ID. func getArtistFilterOptions(db *database.DB, artistID string) (FilterOptions, error) { var opts FilterOptions err := db.Conn().QueryRow( "SELECT COALESCE(ignore_singles, 0), COALESCE(ignore_compilations, 0) FROM artist_settings WHERE id = ?", artistID, ).Scan(&opts.IgnoreSingles, &opts.IgnoreCompilations) if err == sql.ErrNoRows { return opts, nil } if err != nil { return opts, fmt.Errorf("query artist filter options: %w", err) } return opts, nil }