The song title links to its album, the artist name links to the artist page, and clicking the cover art opens a larger version in a modal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
530 lines
19 KiB
Go
530 lines
19 KiB
Go
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
|
|
}
|