Files
vibesonic/internal/database/library.go
T

448 lines
15 KiB
Go

package database
import (
"database/sql"
"errors"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
// --- Artists ---
type Artist struct {
ID string
Name string
SortName string
MusicBrainzID string
}
// 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) {
var a Artist
err := db.QueryRow(`SELECT id, name, sort_name, musicbrainz_id FROM artists WHERE id = ?`, id).Scan(&a.ID, &a.Name, &a.SortName, &a.MusicBrainzID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &a, nil
}
func ListArtists(db *sql.DB) ([]*Artist, error) {
rows, err := db.Query(`SELECT DISTINCT a.id, a.name, a.sort_name, a.musicbrainz_id 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); err != nil {
return nil, err
}
out = append(out, &a)
}
return out, rows.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
}
// 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 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
err := row.Scan(&a.ID, &a.Title, &a.SortTitle, &a.Year, &a.OriginalYear, &a.RecordLabel, &a.MusicBrainzID, &a.PrimaryAlbumArtistID, &a.CoverArtID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
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
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
if err := rows.Scan(&a.ID, &a.Title, &a.SortTitle, &a.Year, &a.OriginalYear, &a.RecordLabel, &a.MusicBrainzID, &a.PrimaryAlbumArtistID, &a.CoverArtID); err != nil {
return nil, err
}
out = append(out, &a)
}
return out, rows.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
}
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 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)
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); 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 {
_, 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)
}
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()
}
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
}