package database import ( "database/sql" "errors" "fmt" "time" ) // DBer is the minimal query interface satisfied by both *sql.DB and *sql.Tx, // so callers can run statements inside or outside a transaction. type DBer interface { Exec(query string, args ...interface{}) (sql.Result, error) } // GetArtistSettings retrieves an artist_settings row by ID. // Returns sql.ErrNoRows if the artist is not found. func GetArtistSettings(db *DB, id string) (*ArtistSettings, error) { var ( s ArtistSettings mbid sql.NullString lastSynced sql.NullTime ) err := db.Conn().QueryRow( "SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced FROM artist_settings WHERE id = ?", id, ).Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored, &lastSynced) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrArtistNotFound } return nil, err } s.MBID = mbid.String if lastSynced.Valid { s.LastSynced = lastSynced.Time } return &s, nil } // TouchArtistSynced records that the artist was synced at the given time. It // is used by the MusicBrainz pipeline to mark a successful sync (even one that // found zero release groups) so the cache TTL is honoured. func TouchArtistSynced(db DBer, artistID string, syncedAt time.Time) error { _, err := db.Exec( "UPDATE artist_settings SET last_synced = ? WHERE id = ?", FormatCachedAt(syncedAt), artistID, ) if err != nil { return fmt.Errorf("touch artist synced: %w", err) } return nil } // SaveArtistSettings inserts or updates an artist_settings row. Columns not // present in the struct's intended set are preserved on conflict rather than // reset to their zero value: mbid and last_synced are carried over from the // existing row when the caller does not supply new values. This protects the // MusicBrainz-resolution cache and the sync TTL markers from being wiped on // every periodic artist sync. func SaveArtistSettings(db *DB, settings *ArtistSettings) error { _, err := db.Conn().Exec(` INSERT INTO artist_settings (id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced) VALUES (?, ?, ?, ?, ?, ?, (SELECT last_synced FROM artist_settings WHERE id = ?)) ON CONFLICT(id) DO UPDATE SET name = excluded.name, mbid = COALESCE(excluded.mbid, artist_settings.mbid), ignore_singles = excluded.ignore_singles, ignore_compilations = excluded.ignore_compilations, monitored = excluded.monitored `, settings.ID, settings.Name, nullIfEmpty(settings.MBID), settings.IgnoreSingles, settings.IgnoreCompilations, settings.Monitored, settings.ID, ) if err != nil { return fmt.Errorf("save artist settings: %w", err) } return nil } // nullIfEmpty returns nil for an empty string so COALESCE-preserving columns // (e.g. mbid) keep their existing value when the caller supplies no new one. func nullIfEmpty(s string) interface{} { if s == "" { return nil } return s } // GetAllArtistSettings returns all rows from artist_settings. func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) { rows, err := db.Conn().Query( "SELECT id, name, mbid, ignore_singles, ignore_compilations, monitored, last_synced FROM artist_settings", ) if err != nil { return nil, fmt.Errorf("query all artist settings: %w", err) } defer rows.Close() var results []ArtistSettings for rows.Next() { var s ArtistSettings var mbid sql.NullString var lastSynced sql.NullTime if err := rows.Scan(&s.ID, &s.Name, &mbid, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored, &lastSynced); err != nil { return nil, fmt.Errorf("scan artist settings: %w", err) } s.MBID = mbid.String if lastSynced.Valid { s.LastSynced = lastSynced.Time } results = append(results, s) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate artist settings: %w", err) } return results, nil } // UpdateArtistSettings updates specific fields of an artist_settings row by ID. // The updates map keys must match column names: "name", "ignore_singles", "ignore_compilations", "monitored". func UpdateArtistSettings(db *DB, id string, updates map[string]interface{}) error { if len(updates) == 0 { return fmt.Errorf("no updates provided") } // Build the query using a fixed set of allowed columns to avoid dynamic SQL. const baseQuery = "UPDATE artist_settings SET" var args []interface{} setClause := "" for col, val := range updates { switch col { case "name": if setClause != "" { setClause += ", " } setClause += "name = ?" args = append(args, val) case "mbid": if setClause != "" { setClause += ", " } setClause += "mbid = ?" args = append(args, val) case "ignore_singles": if setClause != "" { setClause += ", " } setClause += "ignore_singles = ?" args = append(args, val) case "ignore_compilations": if setClause != "" { setClause += ", " } setClause += "ignore_compilations = ?" args = append(args, val) case "monitored": if setClause != "" { setClause += ", " } setClause += "monitored = ?" args = append(args, val) default: return fmt.Errorf("unknown column: %s", col) } } args = append(args, id) query := fmt.Sprintf("%s %s WHERE id = ?", baseQuery, setClause) result, err := db.Conn().Exec(query, args...) if err != nil { return fmt.Errorf("update artist settings: %w", err) } rowsAffected, err := result.RowsAffected() if err != nil { return fmt.Errorf("rows affected: %w", err) } if rowsAffected == 0 { return fmt.Errorf("artist not found: %s", id) } return nil }