package musicbrainz import ( "context" "database/sql" "fmt" "strings" "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 freshness. A genuine hit means the artist was synced // within the TTL — even when it has zero release groups. We must not gate on // row count, or artists with an empty MusicBrainz discography would be // re-fetched on every sync (defeating the TTL and wasting the 1 req/s budget). cachedReleases, err := database.GetExternalReleasesByArtistWithCache(db, artistID, ttl) if err != nil { return nil, fmt.Errorf("sync artist discography: cache check failed: %w", err) } fresh, err := database.ArtistCacheFresh(db, artistID, ttl) if err != nil { return nil, fmt.Errorf("sync artist discography: cache freshness check failed: %w", err) } // Step 2: If we have a fresh cache, return the cached data. 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.) // // The MusicBrainz sync path applies filtering at store-time (when caching // release groups from the API), while the scanner path applies filtering at // read-time (when retrieving cached data). This dual-path approach ensures: // 1. Storage efficiency: filtered results are stored, reducing database size // 2. Real-time responsiveness: changes to ignore_singles/ignore_compilations // take effect immediately without waiting for cache expiry // 3. Consistency: both paths use the same filtering logic via // musicbrainz.ApplyTypeToggles if fresh { 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 := ApplyTypeToggles(cachedReleases, opts) 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 } if err := rows.Err(); err != nil { rows.Close() return nil, fmt.Errorf("sync artist discography: iterate existing releases: %w", err) } rows.Close() // Build the set of RGIDs present in this sync so we can drop only the rows // that disappeared, leaving the rest (and their notification markers) intact. synced := make([]string, 0, len(filtered)) for _, rg := range filtered { synced = append(synced, rg.ID) } // Drop notification markers for releases that are gone. This runs before the // external_releases delete so the FK on notifications_sent.rgid stays valid // (we only ever delete from notifications_sent here). // Process in chunks to avoid SQLite parameter limits (default limit is 999). if len(synced) > 0 { const chunkSize = 500 for i := 0; i < len(synced); i += chunkSize { end := i + chunkSize if end > len(synced) { end = len(synced) } chunk := synced[i:end] placeholders := strings.Repeat("?,", len(chunk)) placeholders = placeholders[:len(placeholders)-1] query := fmt.Sprintf( "DELETE FROM notifications_sent WHERE rgid IN (SELECT rgid FROM external_releases WHERE artist_id = ? AND rgid NOT IN (%s))", placeholders, ) args := make([]any, 1+len(chunk)) args[0] = artistID for i, v := range chunk { args[i+1] = v } if _, err := tx.Exec(query, args...); err != nil { return nil, fmt.Errorf("sync artist discography: prune stale notifications (chunk %d-%d): %w", i, end, err) } } // Remove external_release rows that are no longer part of the discography. // Process in chunks to avoid SQLite parameter limits. for i := 0; i < len(synced); i += chunkSize { end := i + chunkSize if end > len(synced) { end = len(synced) } chunk := synced[i:end] placeholders := strings.Repeat("?,", len(chunk)) placeholders = placeholders[:len(placeholders)-1] delQuery := fmt.Sprintf( "DELETE FROM external_releases WHERE artist_id = ? AND rgid NOT IN (%s)", placeholders, ) args := make([]any, 1+len(chunk)) args[0] = artistID for i, v := range chunk { args[i+1] = v } if _, err := tx.Exec(delQuery, args...); err != nil { return nil, fmt.Errorf("sync artist discography: delete stale releases (chunk %d-%d): %w", i, end, err) } } } else { // No releases this sync: the artist may have an empty discography. Drop // everything we previously cached for them. 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 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 OR REPLACE 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) } // Mark the artist as synced (even when it has zero release groups) so the // cache TTL honours empty discographies and they are not re-fetched every // cycle. if err := database.TouchArtistSynced(tx, artistID, now); err != nil { return nil, fmt.Errorf("sync artist discography: touch last_synced: %w", err) } 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 }