- Fix artist-ID namespace mismatch in MusicBrainz provider: SyncArtistDiscography now stores the canonical Navidrome artist ID (artist_settings.id) as external_releases.artist_id instead of the MusicBrainz MBID. Previously the MBID was stored, which violated the FK to artist_settings and broke the scanner join (local_albums.artist_id is the Navidrome ID), causing every external release to be falsely reported as missing and the sync insert to fail at runtime. getArtistFilterOptions now also resolves by the Navidrome ID. - Resolve threshold in FindMissingReleases so the exported primitive honors the same zero-means-default contract as ScanArtist/ScanAll. - Remove dead maxLen==0 guard in scanner.Similarity. - Inline trivial buildPath helper; drop unused url import in client.go. - Replace hand-rolled itoa with strconv.Itoa in tests. - Rewrite SyncArtistDiscography tests to seed artist_settings with the Navidrome ID (tests previously seeded the MBID to mask the FK mismatch). - Fix TestFuzzySmoke to exercise the real dependency (fuzzy.LevenshteinDistance / scanner.Similarity) instead of an unused API. - Fix TestAppRun_ScanLogsMissingReleases to run the scan against a live context and assert the missing release is found. - Document cached_at column in Specification.md and note startup scan / required musicbrainz.user_agent in README. - Stop tracking .serena/ tooling config; add it to .gitignore.
154 lines
5.3 KiB
Go
154 lines
5.3 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.
|
|
//
|
|
// 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.
|
|
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, 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
|
|
}
|
|
|
|
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
|
|
}
|