Foundation: config, schema/migrations, auth, stable IDs, metadata probing
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user