Files
NaviWatcher/internal/musicbrainz/sync.go
Vladimir Zagainov 424be1efc4 fix: address fourth code review findings
- Fix FK constraint violation in SyncArtistDiscography: delete
  notifications_sent rows before external_releases to prevent
  constraint failure when re-syncing artists with prior notifications.
- Implement per-artist type filtering: FilterReleaseGroups now accepts
  FilterOptions with IgnoreSingles/IgnoreCompilations flags, read from
  artist_settings table via getArtistFilterOptions.
- Fix inconsistent error wrapping: GetExternalRelease now wraps errors
  with fmt.Errorf like all other functions in the package; updated test
  to use errors.Is for sql.ErrNoRows check.
- Add tests: FilterReleaseGroups ignore singles/compilations,
  SyncArtistDiscography per-artist type filtering, and FK-safe resync.
2026-05-26 17:29:05 +03:00

148 lines
5.0 KiB
Go

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.
//
// 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.
cachedReleases, 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 len(cachedReleases) > 0 {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("sync artist discography: %w", err)
}
return cachedReleases, 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, artistMBID)
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 = ?", artistMBID)
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 = ?)",
artistMBID,
); 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 = ?", artistMBID); 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()
ext.CachedAt = now
// Preserve user-set ignore flag from previous sync.
if ignored, ok := ignoredMap[ext.RGID]; ok {
ext.IsIgnored = ignored
}
cachedAtStr := ext.CachedAt.Format("2006-01-02 15:04:05")
if _, err := tx.Exec(
"INSERT INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored, cached_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
ext.RGID, ext.ArtistID, ext.Title, ext.Type, ext.ReleaseDate, ext.IsIgnored, cachedAtStr,
); 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.
func getArtistFilterOptions(db *database.DB, artistMBID string) (FilterOptions, error) {
var opts FilterOptions
err := db.Conn().QueryRow(
"SELECT COALESCE(ignore_singles, 0), COALESCE(ignore_compilations, 0) FROM artist_settings WHERE id = ?",
artistMBID,
).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
}