package database import ( "database/sql" "errors" "time" "git.noroutes.net/NoRoutes/vibetunes/internal/idgen" ) // --- Artists --- type Artist struct { ID string Name string SortName string MusicBrainzID string Bio string CoverArtID sql.NullString PhotoManual bool } const artistSelect = `SELECT id, name, sort_name, musicbrainz_id, bio, cover_art_id, photo_manual FROM artists` func scanArtist(row *sql.Row) (*Artist, error) { var a Artist err := row.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID, &a.Bio, &a.CoverArtID, &a.PhotoManual) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return &a, nil } // FindOrCreateArtist looks up an artist by (name, musicbrainz_id) and // creates it if missing, so the same artist reuses the same stable ID // across rescans. func FindOrCreateArtist(db *sql.DB, name, sortName, musicBrainzID string) (*Artist, error) { var id string err := db.QueryRow(`SELECT id FROM artists WHERE name = ? AND musicbrainz_id = ?`, name, musicBrainzID).Scan(&id) if err == nil { return GetArtist(db, id) } if !errors.Is(err, sql.ErrNoRows) { return nil, err } id = idgen.New("ar") _, err = db.Exec(`INSERT INTO artists (id, name, sort_name, musicbrainz_id) VALUES (?, ?, ?, ?)`, id, name, sortName, musicBrainzID) if err != nil { // Lost a race with another scan worker inserting the same artist concurrently. var raceID string if qerr := db.QueryRow(`SELECT id FROM artists WHERE name = ? AND musicbrainz_id = ?`, name, musicBrainzID).Scan(&raceID); qerr == nil { return GetArtist(db, raceID) } return nil, err } return GetArtist(db, id) } func GetArtist(db *sql.DB, id string) (*Artist, error) { return scanArtist(db.QueryRow(artistSelect+` WHERE id = ?`, id)) } func ListArtists(db *sql.DB) ([]*Artist, error) { rows, err := db.Query(`SELECT DISTINCT a.id, a.name, a.sort_name, a.musicbrainz_id, a.bio, a.cover_art_id, a.photo_manual FROM artists a JOIN album_artists aa ON aa.artist_id = a.id ORDER BY COALESCE(NULLIF(a.sort_name, ''), a.name) COLLATE NOCASE`) if err != nil { return nil, err } defer rows.Close() var out []*Artist for rows.Next() { var a Artist if err := rows.Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID, &a.Bio, &a.CoverArtID, &a.PhotoManual); err != nil { return nil, err } out = append(out, &a) } return out, rows.Err() } // UpdateArtist overwrites an artist's name, sort name, and bio. It never // touches the artist's photo (see SetArtistCoverArt). func UpdateArtist(db *sql.DB, id, name, sortName, bio string) error { _, err := db.Exec(`UPDATE artists SET name = ?, sort_name = ?, bio = ? WHERE id = ?`, name, sortName, bio, id) return err } // SetArtistCoverArt links (or unlinks) an artist's cover_art row. // manual=true records an admin override; manual=false is the auto-fetch // path, which is guarded to never overwrite an existing manual override. func SetArtistCoverArt(db *sql.DB, artistID string, coverArtID sql.NullString, manual bool) error { if manual { _, err := db.Exec(`UPDATE artists SET cover_art_id = ?, photo_manual = 1 WHERE id = ?`, nullStr(coverArtID), artistID) return err } _, err := db.Exec(`UPDATE artists SET cover_art_id = ?, photo_manual = 0 WHERE id = ? AND photo_manual = 0`, nullStr(coverArtID), artistID) return err } // --- Genres --- type Genre struct { ID string Name string } func FindOrCreateGenre(db *sql.DB, name string) (*Genre, error) { var id string err := db.QueryRow(`SELECT id FROM genres WHERE name = ?`, name).Scan(&id) if err == nil { return &Genre{ID: id, Name: name}, nil } if !errors.Is(err, sql.ErrNoRows) { return nil, err } id = idgen.New("ge") if _, err := db.Exec(`INSERT INTO genres (id, name) VALUES (?, ?)`, id, name); err != nil { var raceID string if qerr := db.QueryRow(`SELECT id FROM genres WHERE name = ?`, name).Scan(&raceID); qerr == nil { return &Genre{ID: raceID, Name: name}, nil } return nil, err } return &Genre{ID: id, Name: name}, nil } func ListGenres(db *sql.DB) ([]*Genre, error) { rows, err := db.Query(`SELECT id, name FROM genres ORDER BY name COLLATE NOCASE`) if err != nil { return nil, err } defer rows.Close() var out []*Genre for rows.Next() { var g Genre if err := rows.Scan(&g.ID, &g.Name); err != nil { return nil, err } out = append(out, &g) } return out, rows.Err() } // --- Albums --- type Album struct { ID string Title string SortTitle string Year sql.NullInt64 OriginalYear sql.NullInt64 RecordLabel string MusicBrainzID string PrimaryAlbumArtistID sql.NullString CoverArtID sql.NullString CreatedAt time.Time } // FindOrCreateAlbum resolves an album by (title, primary album artist), // which is the natural key that keeps an album's ID stable across // rescans as long as its tags don't change. func FindOrCreateAlbum(db *sql.DB, title string, primaryArtistID sql.NullString, sortTitle string, year, originalYear sql.NullInt64, label, mbid string) (*Album, error) { var id string var err error if primaryArtistID.Valid { err = db.QueryRow(`SELECT id FROM albums WHERE title = ? AND primary_album_artist_id = ?`, title, primaryArtistID.String).Scan(&id) } else { err = db.QueryRow(`SELECT id FROM albums WHERE title = ? AND primary_album_artist_id IS NULL`, title).Scan(&id) } if err == nil { return GetAlbum(db, id) } if !errors.Is(err, sql.ErrNoRows) { return nil, err } id = idgen.New("al") _, err = db.Exec(`INSERT INTO albums (id, title, sort_title, release_date, year, original_year, record_label, musicbrainz_id, primary_album_artist_id) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?)`, id, title, sortTitle, nullInt(year), nullInt(originalYear), label, mbid, nullStr(primaryArtistID)) if err != nil { // Race with a concurrent scan worker. var raceID string var qerr error if primaryArtistID.Valid { qerr = db.QueryRow(`SELECT id FROM albums WHERE title = ? AND primary_album_artist_id = ?`, title, primaryArtistID.String).Scan(&raceID) } else { qerr = db.QueryRow(`SELECT id FROM albums WHERE title = ? AND primary_album_artist_id IS NULL`, title).Scan(&raceID) } if qerr == nil { return GetAlbum(db, raceID) } return nil, err } return GetAlbum(db, id) } const albumSelect = `SELECT id, title, sort_title, year, original_year, record_label, musicbrainz_id, primary_album_artist_id, cover_art_id, created_at FROM albums` func GetAlbum(db *sql.DB, id string) (*Album, error) { return scanAlbum(db.QueryRow(albumSelect+` WHERE id = ?`, id)) } func scanAlbum(row *sql.Row) (*Album, error) { var a Album var createdAt string err := row.Scan(&a.ID, &a.Title, &a.SortTitle, &a.Year, &a.OriginalYear, &a.RecordLabel, &a.MusicBrainzID, &a.PrimaryAlbumArtistID, &a.CoverArtID, &createdAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } a.CreatedAt, _ = parseTime(createdAt) return &a, nil } func ListAlbums(db *sql.DB, limit, offset int) ([]*Album, error) { rows, err := db.Query(albumSelect+` ORDER BY sort_title COLLATE NOCASE, title COLLATE NOCASE LIMIT ? OFFSET ?`, limit, offset) if err != nil { return nil, err } defer rows.Close() return scanAlbums(rows) } func ListAlbumsByArtist(db *sql.DB, artistID string) ([]*Album, error) { rows, err := db.Query(`SELECT DISTINCT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at FROM albums al JOIN album_artists aa ON aa.album_id = al.id WHERE aa.artist_id = ? ORDER BY al.year, al.sort_title COLLATE NOCASE`, artistID) if err != nil { return nil, err } defer rows.Close() return scanAlbums(rows) } func scanAlbums(rows *sql.Rows) ([]*Album, error) { var out []*Album for rows.Next() { var a Album var createdAt string if err := rows.Scan(&a.ID, &a.Title, &a.SortTitle, &a.Year, &a.OriginalYear, &a.RecordLabel, &a.MusicBrainzID, &a.PrimaryAlbumArtistID, &a.CoverArtID, &createdAt); err != nil { return nil, err } a.CreatedAt, _ = parseTime(createdAt) out = append(out, &a) } return out, rows.Err() } // UpdateAlbum overwrites an album's editable metadata fields. It never // touches the album's cover art (see SetAlbumCoverArt). func UpdateAlbum(db *sql.DB, id, title, sortTitle string, year, originalYear sql.NullInt64, label string) error { _, err := db.Exec(`UPDATE albums SET title = ?, sort_title = ?, year = ?, original_year = ?, record_label = ? WHERE id = ?`, title, sortTitle, nullInt(year), nullInt(originalYear), label, id) return err } func SetAlbumCoverArt(db *sql.DB, albumID, coverArtID string) error { _, err := db.Exec(`UPDATE albums SET cover_art_id = ? WHERE id = ?`, coverArtID, albumID) return err } func LinkAlbumArtist(db *sql.DB, albumID, artistID string, position int) error { _, err := db.Exec(`INSERT OR IGNORE INTO album_artists (album_id, artist_id, position) VALUES (?, ?, ?)`, albumID, artistID, position) return err } func LinkAlbumGenre(db *sql.DB, albumID, genreID string) error { _, err := db.Exec(`INSERT OR IGNORE INTO album_genres (album_id, genre_id) VALUES (?, ?)`, albumID, genreID) return err } // --- Songs --- type Song struct { ID string MediaFileID string AlbumID sql.NullString Title string SortTitle string TrackNumber sql.NullInt64 TrackTotal sql.NullInt64 DiscNumber sql.NullInt64 DiscTotal sql.NullInt64 Composer string Conductor string ISRC string BPM sql.NullInt64 MusicBrainzTrackID string MusicBrainzRecordingID string ReplayGainTrackGainDB sql.NullFloat64 ReplayGainTrackPeak sql.NullFloat64 ReplayGainAlbumGainDB sql.NullFloat64 ReplayGainAlbumPeak sql.NullFloat64 MetadataLocked bool } const songSelect = `SELECT id, media_file_id, album_id, title, sort_title, track_number, track_total, disc_number, disc_total, composer, conductor, isrc, bpm, musicbrainz_track_id, musicbrainz_recording_id, replaygain_track_gain_db, replaygain_track_peak, replaygain_album_gain_db, replaygain_album_peak, metadata_locked FROM songs` func GetSong(db *sql.DB, id string) (*Song, error) { return scanSong(db.QueryRow(songSelect+` WHERE id = ?`, id)) } func GetSongByMediaFileID(db *sql.DB, mediaFileID string) (*Song, error) { return scanSong(db.QueryRow(songSelect+` WHERE media_file_id = ?`, mediaFileID)) } func scanSong(row *sql.Row) (*Song, error) { var s Song err := row.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal, &s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID, &s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak, &s.MetadataLocked) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return &s, nil } func ListSongsByAlbum(db *sql.DB, albumID string) ([]*Song, error) { rows, err := db.Query(songSelect+` WHERE album_id = ? ORDER BY disc_number, track_number, sort_title COLLATE NOCASE`, albumID) if err != nil { return nil, err } defer rows.Close() return scanSongs(rows) } func ListSongs(db *sql.DB, limit, offset int) ([]*Song, error) { rows, err := db.Query(songSelect+` ORDER BY sort_title COLLATE NOCASE LIMIT ? OFFSET ?`, limit, offset) if err != nil { return nil, err } defer rows.Close() return scanSongs(rows) } func scanSongs(rows *sql.Rows) ([]*Song, error) { var out []*Song for rows.Next() { var s Song if err := rows.Scan(&s.ID, &s.MediaFileID, &s.AlbumID, &s.Title, &s.SortTitle, &s.TrackNumber, &s.TrackTotal, &s.DiscNumber, &s.DiscTotal, &s.Composer, &s.Conductor, &s.ISRC, &s.BPM, &s.MusicBrainzTrackID, &s.MusicBrainzRecordingID, &s.ReplayGainTrackGainDB, &s.ReplayGainTrackPeak, &s.ReplayGainAlbumGainDB, &s.ReplayGainAlbumPeak, &s.MetadataLocked); err != nil { return nil, err } out = append(out, &s) } return out, rows.Err() } // UpsertSongInput carries the fields library.IndexFile fills in from // parsed tags before writing a songs row. type UpsertSongInput struct { MediaFileID string AlbumID sql.NullString Title string SortTitle string TrackNumber sql.NullInt64 TrackTotal sql.NullInt64 DiscNumber sql.NullInt64 DiscTotal sql.NullInt64 DiscSubtitle string Composer string Conductor string Comment string ISRC string BPM sql.NullInt64 MusicBrainzTrackID string MusicBrainzRecordingID string ReplayGainTrackGainDB sql.NullFloat64 ReplayGainTrackPeak sql.NullFloat64 ReplayGainAlbumGainDB sql.NullFloat64 ReplayGainAlbumPeak sql.NullFloat64 } // UpsertSong creates or updates the songs row for a media file, keyed by // media_file_id (which is itself stable per media_files' natural key), so // the song's own ID never changes across rescans. func UpsertSong(db *sql.DB, in UpsertSongInput) (*Song, error) { existing, err := GetSongByMediaFileID(db, in.MediaFileID) if err != nil && !errors.Is(err, ErrNotFound) { return nil, err } if existing != nil { if existing.MetadataLocked { // An admin has hand-edited title/sort_title/track_number/ // disc_number/composer/conductor for this song; leave those // alone and only refresh the fields that come straight off // the file itself and were never exposed for editing. _, err = db.Exec(`UPDATE songs SET album_id=?, track_total=?, disc_total=?, disc_subtitle=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?, replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, nullStr(in.AlbumID), nullInt(in.TrackTotal), nullInt(in.DiscTotal), in.DiscSubtitle, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID, nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak), existing.ID) } else { _, err = db.Exec(`UPDATE songs SET album_id=?, title=?, sort_title=?, track_number=?, track_total=?, disc_number=?, disc_total=?, disc_subtitle=?, composer=?, conductor=?, comment=?, isrc=?, bpm=?, musicbrainz_track_id=?, musicbrainz_recording_id=?, replaygain_track_gain_db=?, replaygain_track_peak=?, replaygain_album_gain_db=?, replaygain_album_peak=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, nullStr(in.AlbumID), in.Title, in.SortTitle, nullInt(in.TrackNumber), nullInt(in.TrackTotal), nullInt(in.DiscNumber), nullInt(in.DiscTotal), in.DiscSubtitle, in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID, nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak), existing.ID) } if err != nil { return nil, err } return GetSong(db, existing.ID) } id := idgen.New("sg") _, err = db.Exec(`INSERT INTO songs (id, media_file_id, album_id, title, sort_title, track_number, track_total, disc_number, disc_total, disc_subtitle, composer, conductor, comment, isrc, bpm, musicbrainz_track_id, musicbrainz_recording_id, replaygain_track_gain_db, replaygain_track_peak, replaygain_album_gain_db, replaygain_album_peak) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, in.MediaFileID, nullStr(in.AlbumID), in.Title, in.SortTitle, nullInt(in.TrackNumber), nullInt(in.TrackTotal), nullInt(in.DiscNumber), nullInt(in.DiscTotal), in.DiscSubtitle, in.Composer, in.Conductor, in.Comment, in.ISRC, nullInt(in.BPM), in.MusicBrainzTrackID, in.MusicBrainzRecordingID, nullFloat(in.ReplayGainTrackGainDB), nullFloat(in.ReplayGainTrackPeak), nullFloat(in.ReplayGainAlbumGainDB), nullFloat(in.ReplayGainAlbumPeak)) if err != nil { return nil, err } return GetSong(db, id) } // UpdateSongMetadata overwrites a song's editable fields. locked=true // sets metadata_locked so a later library rescan (UpsertSong) leaves // these fields alone instead of overwriting them from the file's tags. func UpdateSongMetadata(db *sql.DB, id, title, sortTitle string, trackNumber, discNumber sql.NullInt64, composer, conductor string, locked bool) error { _, err := db.Exec(`UPDATE songs SET title=?, sort_title=?, track_number=?, disc_number=?, composer=?, conductor=?, metadata_locked=? WHERE id=?`, title, sortTitle, nullInt(trackNumber), nullInt(discNumber), composer, conductor, locked, id) return err } func ClearSongArtists(db *sql.DB, songID string) error { _, err := db.Exec(`DELETE FROM song_artists WHERE song_id = ?`, songID) return err } func LinkSongArtist(db *sql.DB, songID, artistID, role string, position int) error { _, err := db.Exec(`INSERT OR IGNORE INTO song_artists (song_id, artist_id, role, position) VALUES (?, ?, ?, ?)`, songID, artistID, role, position) return err } func ClearSongGenres(db *sql.DB, songID string) error { _, err := db.Exec(`DELETE FROM song_genres WHERE song_id = ?`, songID) return err } func LinkSongGenre(db *sql.DB, songID, genreID string) error { _, err := db.Exec(`INSERT OR IGNORE INTO song_genres (song_id, genre_id) VALUES (?, ?)`, songID, genreID) return err } func ClearContributors(db *sql.DB, songID string) error { _, err := db.Exec(`DELETE FROM contributors WHERE song_id = ?`, songID) return err } func LinkContributor(db *sql.DB, songID, artistID, role string, position int) error { _, err := db.Exec(`INSERT OR IGNORE INTO contributors (song_id, artist_id, role, position) VALUES (?, ?, ?, ?)`, songID, artistID, role, position) return err } // ListSongArtistNames returns the display artist name(s) for a song, e.g. // for a "feat." credit line, in credit order. func ListSongArtistNames(db *sql.DB, songID string) ([]string, error) { rows, err := db.Query(`SELECT a.name FROM song_artists sa JOIN artists a ON a.id = sa.artist_id WHERE sa.song_id = ? ORDER BY sa.position`, songID) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } out = append(out, name) } return out, rows.Err() } // PrimarySongArtistID returns the ID of a song's first-credited artist // (position 0), or "" if the song has no linked artist — used to link // the now-playing bar's artist name to that artist's page. func PrimarySongArtistID(db *sql.DB, songID string) (string, error) { var id string err := db.QueryRow(`SELECT a.id FROM song_artists sa JOIN artists a ON a.id = sa.artist_id WHERE sa.song_id = ? ORDER BY sa.position LIMIT 1`, songID).Scan(&id) if errors.Is(err, sql.ErrNoRows) { return "", nil } if err != nil { return "", err } return id, nil } func nullInt(n sql.NullInt64) any { if !n.Valid { return nil } return n.Int64 } func nullFloat(f sql.NullFloat64) any { if !f.Valid { return nil } return f.Float64 } func nullStr(s sql.NullString) any { if !s.Valid { return nil } return s.String }