Foundation: config, schema/migrations, auth, stable IDs, metadata probing

This commit is contained in:
2026-08-22 23:29:16 -05:00
commit d4ea7a4ca9
40 changed files with 3874 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
package database
import (
"database/sql"
"errors"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
// --- Stars (favorites) ---
func Star(db *sql.DB, userID, itemType, itemID string) error {
_, err := db.Exec(`INSERT OR IGNORE INTO stars (id, user_id, item_type, item_id) VALUES (?, ?, ?, ?)`, idgen.New("st"), userID, itemType, itemID)
return err
}
func Unstar(db *sql.DB, userID, itemType, itemID string) error {
_, err := db.Exec(`DELETE FROM stars WHERE user_id = ? AND item_type = ? AND item_id = ?`, userID, itemType, itemID)
return err
}
func IsStarred(db *sql.DB, userID, itemType, itemID string) (bool, error) {
var n int
err := db.QueryRow(`SELECT COUNT(*) FROM stars WHERE user_id = ? AND item_type = ? AND item_id = ?`, userID, itemType, itemID).Scan(&n)
return n > 0, err
}
func ListStarredIDs(db *sql.DB, userID, itemType string) ([]string, error) {
rows, err := db.Query(`SELECT item_id FROM stars WHERE user_id = ? AND item_type = ? ORDER BY created_at DESC`, userID, itemType)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// --- Ratings ---
func SetRating(db *sql.DB, userID, itemType, itemID string, rating int) error {
if rating <= 0 {
_, err := db.Exec(`DELETE FROM ratings WHERE user_id = ? AND item_type = ? AND item_id = ?`, userID, itemType, itemID)
return err
}
_, err := db.Exec(`INSERT INTO ratings (id, user_id, item_type, item_id, rating) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, item_type, item_id) DO UPDATE SET rating = excluded.rating`,
idgen.New("rt"), userID, itemType, itemID, rating)
return err
}
// --- Scrobbles & playback history ---
func RecordScrobble(db *sql.DB, userID, songID, client string, submittedToLastfm bool) error {
_, err := db.Exec(`INSERT INTO scrobbles (id, user_id, song_id, client, submitted_to_lastfm) VALUES (?, ?, ?, ?, ?)`,
idgen.New("sb"), userID, songID, client, boolInt(submittedToLastfm))
return err
}
func RecordPlayback(db *sql.DB, userID, songID string, playDurationSeconds float64) error {
_, err := db.Exec(`INSERT INTO playback_history (id, user_id, song_id, play_duration_seconds) VALUES (?, ?, ?, ?)`,
idgen.New("ph"), userID, songID, playDurationSeconds)
return err
}
type PlaybackHistoryEntry struct {
SongID string
PlayedAt time.Time
}
func ListRecentlyPlayed(db *sql.DB, userID string, limit int) ([]PlaybackHistoryEntry, error) {
rows, err := db.Query(`SELECT song_id, played_at FROM playback_history WHERE user_id = ? ORDER BY played_at DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PlaybackHistoryEntry
for rows.Next() {
var e PlaybackHistoryEntry
var playedAt string
if err := rows.Scan(&e.SongID, &playedAt); err != nil {
return nil, err
}
e.PlayedAt, _ = parseTime(playedAt)
out = append(out, e)
}
return out, rows.Err()
}
// --- Now playing ---
func SetNowPlaying(db *sql.DB, userID, songID, client string, positionSeconds float64) error {
_, err := db.Exec(`INSERT INTO now_playing (user_id, song_id, client, position_seconds) VALUES (?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET song_id=excluded.song_id, started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), client=excluded.client, position_seconds=excluded.position_seconds`,
userID, songID, client, positionSeconds)
return err
}
func ClearNowPlaying(db *sql.DB, userID string) error {
_, err := db.Exec(`DELETE FROM now_playing WHERE user_id = ?`, userID)
return err
}
type NowPlayingEntry struct {
UserID string
Username string
SongID string
StartedAt time.Time
Client string
PositionSeconds float64
}
func ListNowPlaying(db *sql.DB) ([]NowPlayingEntry, error) {
rows, err := db.Query(`SELECT np.user_id, u.username, np.song_id, np.started_at, np.client, np.position_seconds
FROM now_playing np JOIN users u ON u.id = np.user_id ORDER BY np.started_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NowPlayingEntry
for rows.Next() {
var e NowPlayingEntry
var startedAt string
if err := rows.Scan(&e.UserID, &e.Username, &e.SongID, &startedAt, &e.Client, &e.PositionSeconds); err != nil {
return nil, err
}
e.StartedAt, _ = parseTime(startedAt)
out = append(out, e)
}
return out, rows.Err()
}
// --- Bookmarks ---
type Bookmark struct {
ID string
SongID string
PositionMs int
Comment string
}
func SetBookmark(db *sql.DB, userID, songID string, positionMs int, comment string) error {
_, err := db.Exec(`INSERT INTO bookmarks (id, user_id, song_id, position_ms, comment) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, song_id) DO UPDATE SET position_ms=excluded.position_ms, comment=excluded.comment, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
idgen.New("bk"), userID, songID, positionMs, comment)
return err
}
func DeleteBookmark(db *sql.DB, userID, songID string) error {
_, err := db.Exec(`DELETE FROM bookmarks WHERE user_id = ? AND song_id = ?`, userID, songID)
return err
}
func ListBookmarks(db *sql.DB, userID string) ([]*Bookmark, error) {
rows, err := db.Query(`SELECT id, song_id, position_ms, comment FROM bookmarks WHERE user_id = ? ORDER BY updated_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Bookmark
for rows.Next() {
var b Bookmark
if err := rows.Scan(&b.ID, &b.SongID, &b.PositionMs, &b.Comment); err != nil {
return nil, err
}
out = append(out, &b)
}
return out, rows.Err()
}
// --- Play queue ---
func SavePlayQueue(db *sql.DB, userID, songIDsJSON string, currentIndex, positionMs int, client string) error {
_, err := db.Exec(`INSERT INTO play_queues (user_id, song_ids_json, current_index, position_ms, client) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET song_ids_json=excluded.song_ids_json, current_index=excluded.current_index, position_ms=excluded.position_ms, client=excluded.client, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
userID, songIDsJSON, currentIndex, positionMs, client)
return err
}
type PlayQueue struct {
SongIDsJSON string
CurrentIndex int
PositionMs int
Client string
UpdatedAt time.Time
}
func GetPlayQueue(db *sql.DB, userID string) (*PlayQueue, error) {
var q PlayQueue
var updatedAt string
err := db.QueryRow(`SELECT song_ids_json, current_index, position_ms, client, updated_at FROM play_queues WHERE user_id = ?`, userID).
Scan(&q.SongIDsJSON, &q.CurrentIndex, &q.PositionMs, &q.Client, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
q.UpdatedAt, _ = parseTime(updatedAt)
return &q, nil
}
+101
View File
@@ -0,0 +1,101 @@
package database
import (
"database/sql"
"errors"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
type APIKey struct {
ID string
UserID string
Name string
KeyHash string
CreatedAt time.Time
LastUsedAt sql.NullTime
RevokedAt sql.NullTime
}
func CreateAPIKey(db *sql.DB, userID, name, keyHash string) (*APIKey, error) {
id := idgen.New("ak")
_, err := db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash) VALUES (?, ?, ?, ?)`, id, userID, name, keyHash)
if err != nil {
return nil, err
}
return GetAPIKey(db, id)
}
func GetAPIKey(db *sql.DB, id string) (*APIKey, error) {
return scanAPIKey(db.QueryRow(`SELECT id, user_id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys WHERE id = ?`, id))
}
func GetAPIKeyByHash(db *sql.DB, keyHash string) (*APIKey, error) {
return scanAPIKey(db.QueryRow(`SELECT id, user_id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL`, keyHash))
}
func scanAPIKey(row *sql.Row) (*APIKey, error) {
var k APIKey
var createdAt string
var lastUsed, revoked sql.NullString
err := row.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &createdAt, &lastUsed, &revoked)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
k.CreatedAt, _ = parseTime(createdAt)
if lastUsed.Valid {
if t, err := parseTime(lastUsed.String); err == nil {
k.LastUsedAt = sql.NullTime{Time: t, Valid: true}
}
}
if revoked.Valid {
if t, err := parseTime(revoked.String); err == nil {
k.RevokedAt = sql.NullTime{Time: t, Valid: true}
}
}
return &k, nil
}
func ListAPIKeysForUser(db *sql.DB, userID string) ([]*APIKey, error) {
rows, err := db.Query(`SELECT id, user_id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*APIKey
for rows.Next() {
var k APIKey
var createdAt string
var lastUsed, revoked sql.NullString
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &createdAt, &lastUsed, &revoked); err != nil {
return nil, err
}
k.CreatedAt, _ = parseTime(createdAt)
if lastUsed.Valid {
if t, err := parseTime(lastUsed.String); err == nil {
k.LastUsedAt = sql.NullTime{Time: t, Valid: true}
}
}
if revoked.Valid {
if t, err := parseTime(revoked.String); err == nil {
k.RevokedAt = sql.NullTime{Time: t, Valid: true}
}
}
out = append(out, &k)
}
return out, rows.Err()
}
func TouchAPIKey(db *sql.DB, id string) error {
_, err := db.Exec(`UPDATE api_keys SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, id)
return err
}
func RevokeAPIKey(db *sql.DB, id string) error {
_, err := db.Exec(`UPDATE api_keys SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, id)
return err
}
+70
View File
@@ -0,0 +1,70 @@
package database
import (
"database/sql"
"errors"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
type CoverArt struct {
ID string
AlbumID sql.NullString
MediaFileID sql.NullString
Source string
SourcePath string
MimeType string
ContentHash string
Width int
Height int
}
const coverArtSelect = `SELECT id, album_id, media_file_id, source, source_path, mime_type, content_hash, width, height FROM cover_art`
func GetCoverArt(db *sql.DB, id string) (*CoverArt, error) {
return scanCoverArt(db.QueryRow(coverArtSelect+` WHERE id = ?`, id))
}
func GetCoverArtForAlbum(db *sql.DB, albumID string) (*CoverArt, error) {
return scanCoverArt(db.QueryRow(coverArtSelect+` WHERE album_id = ? ORDER BY updated_at DESC LIMIT 1`, albumID))
}
func scanCoverArt(row *sql.Row) (*CoverArt, error) {
var c CoverArt
err := row.Scan(&c.ID, &c.AlbumID, &c.MediaFileID, &c.Source, &c.SourcePath, &c.MimeType, &c.ContentHash, &c.Width, &c.Height)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &c, nil
}
// UpsertAlbumCoverArt records (or replaces) the discovered cover art
// source for an album and links it as the album's cover.
func UpsertAlbumCoverArt(db *sql.DB, albumID, source, sourcePath, mimeType, contentHash string, width, height int) (*CoverArt, error) {
var id string
err := db.QueryRow(`SELECT id FROM cover_art WHERE album_id = ?`, albumID).Scan(&id)
switch {
case err == nil:
_, err = db.Exec(`UPDATE cover_art SET source=?, source_path=?, mime_type=?, content_hash=?, width=?, height=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
source, sourcePath, mimeType, contentHash, width, height, id)
if err != nil {
return nil, err
}
case errors.Is(err, sql.ErrNoRows):
id = idgen.New("ca")
_, err = db.Exec(`INSERT INTO cover_art (id, album_id, source, source_path, mime_type, content_hash, width, height) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, albumID, source, sourcePath, mimeType, contentHash, width, height)
if err != nil {
return nil, err
}
default:
return nil, err
}
if err := SetAlbumCoverArt(db, albumID, id); err != nil {
return nil, err
}
return GetCoverArt(db, id)
}
+102
View File
@@ -0,0 +1,102 @@
// Package database owns the SQLite connection, pragmas, and migrations,
// plus query helpers ("stores") for every table in the schema. It is the
// only package that talks to SQLite directly.
package database
import (
"database/sql"
"fmt"
"io/fs"
"sort"
"strings"
_ "modernc.org/sqlite"
"git.noroutes.net/NoRoutes/vibetunes/migrations"
)
// Open opens (creating if needed) the SQLite database at path, applies
// pragmas suited for a long-running server (WAL, foreign keys, busy
// timeout), and runs any pending migrations.
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// SQLite only tolerates a single writer; keep the pool modest so
// writers serialize instead of colliding on SQLITE_BUSY.
db.SetMaxOpenConns(8)
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA foreign_keys=ON",
"PRAGMA busy_timeout=5000",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
db.Close()
return nil, fmt.Errorf("pragma %q: %w", p, err)
}
}
if err := Migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
// Migrate applies any pending migrations from the embedded migrations FS,
// tracked in a schema_migrations table. It is safe to call repeatedly.
func Migrate(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)`); err != nil {
return err
}
entries, err := fs.ReadDir(migrations.FS, ".")
if err != nil {
return err
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
var applied int
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, name).Scan(&applied); err != nil {
return err
}
if applied > 0 {
continue
}
content, err := migrations.FS.ReadFile(name)
if err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
return fmt.Errorf("applying %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package database
import (
"database/sql"
"path/filepath"
"testing"
)
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
db, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestOpenAppliesPragmas(t *testing.T) {
db := openTestDB(t)
var mode string
if err := db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
t.Fatalf("journal_mode: %v", err)
}
if mode != "wal" {
t.Errorf("journal_mode = %q, want wal", mode)
}
var fk int
if err := db.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil {
t.Fatalf("foreign_keys: %v", err)
}
if fk != 1 {
t.Errorf("foreign_keys = %d, want 1", fk)
}
}
func TestOpenAppliesMigrations(t *testing.T) {
db := openTestDB(t)
tables := []string{
"users", "sessions", "api_keys",
"music_folders", "media_files", "scan_state",
"artists", "genres", "albums", "album_artists", "album_genres",
"songs", "song_artists", "contributors", "song_genres",
"cover_art", "lyrics", "lyrics_lines", "lyrics_cues",
"playlists", "playlist_entries",
"stars", "ratings", "scrobbles", "playback_history", "now_playing",
"bookmarks", "play_queues",
"shares", "share_items",
"podcast_channels", "podcast_episodes",
"internet_radio_stations", "chat_messages",
"transcode_profiles", "audio_features", "settings",
}
for _, name := range tables {
var got string
err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&got)
if err != nil {
t.Errorf("table %q missing: %v", name, err)
}
}
}
func TestMigrateIsIdempotent(t *testing.T) {
db := openTestDB(t)
if err := Migrate(db); err != nil {
t.Fatalf("second Migrate call failed: %v", err)
}
}
func TestForeignKeysEnforced(t *testing.T) {
db := openTestDB(t)
_, err := db.Exec(`INSERT INTO sessions (id, user_id, expires_at) VALUES ('s1', 'no-such-user', '2999-01-01T00:00:00Z')`)
if err == nil {
t.Fatal("expected foreign key violation inserting session with unknown user_id")
}
}
+81
View File
@@ -0,0 +1,81 @@
package database
import (
"database/sql"
"errors"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
type MusicFolder struct {
ID string
Name string
Path string
Kind string
Enabled bool
CreatedAt time.Time
}
func CreateMusicFolder(db *sql.DB, name, path, kind string) (*MusicFolder, error) {
id := idgen.New("mf")
_, err := db.Exec(`INSERT INTO music_folders (id, name, path, kind) VALUES (?, ?, ?, ?)`, id, name, path, kind)
if err != nil {
return nil, err
}
if _, err := db.Exec(`INSERT INTO scan_state (id, music_folder_id) VALUES (?, ?)`, idgen.New("sc"), id); err != nil {
return nil, err
}
return GetMusicFolder(db, id)
}
func GetMusicFolder(db *sql.DB, id string) (*MusicFolder, error) {
return scanFolder(db.QueryRow(`SELECT id, name, path, kind, enabled, created_at FROM music_folders WHERE id = ?`, id))
}
func scanFolder(row *sql.Row) (*MusicFolder, error) {
var f MusicFolder
var enabled int
var createdAt string
err := row.Scan(&f.ID, &f.Name, &f.Path, &f.Kind, &enabled, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
f.Enabled = enabled != 0
f.CreatedAt, _ = parseTime(createdAt)
return &f, nil
}
func ListMusicFolders(db *sql.DB) ([]*MusicFolder, error) {
rows, err := db.Query(`SELECT id, name, path, kind, enabled, created_at FROM music_folders ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*MusicFolder
for rows.Next() {
var f MusicFolder
var enabled int
var createdAt string
if err := rows.Scan(&f.ID, &f.Name, &f.Path, &f.Kind, &enabled, &createdAt); err != nil {
return nil, err
}
f.Enabled = enabled != 0
f.CreatedAt, _ = parseTime(createdAt)
out = append(out, &f)
}
return out, rows.Err()
}
func SetMusicFolderEnabled(db *sql.DB, id string, enabled bool) error {
_, err := db.Exec(`UPDATE music_folders SET enabled = ? WHERE id = ?`, boolInt(enabled), id)
return err
}
func DeleteMusicFolder(db *sql.DB, id string) error {
_, err := db.Exec(`DELETE FROM music_folders WHERE id = ?`, id)
return err
}
+447
View File
@@ -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
}
+138
View File
@@ -0,0 +1,138 @@
package database
import (
"database/sql"
"errors"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
type MediaFile struct {
ID string
MusicFolderID string
RelativePath string
SizeBytes int64
MtimeUnix int64
ContentHash string
Format string
Kind string
DurationSeconds float64
BitrateKbps int
SampleRateHz int
Channels int
Missing bool
FirstSeenAt time.Time
LastScannedAt time.Time
}
// FindMediaFileByPath looks up the stable natural key (folder, relative
// path) used to detect unchanged/changed files across rescans.
func FindMediaFileByPath(db *sql.DB, folderID, relativePath string) (*MediaFile, error) {
return scanMediaFile(db.QueryRow(mediaFileSelect+` WHERE music_folder_id = ? AND relative_path = ?`, folderID, relativePath))
}
// FindMediaFileByHash looks up a file by content fingerprint within a
// folder, used for rename detection: same hash, different path.
func FindMediaFileByHash(db *sql.DB, folderID, contentHash string) (*MediaFile, error) {
return scanMediaFile(db.QueryRow(mediaFileSelect+` WHERE music_folder_id = ? AND content_hash = ? AND content_hash != '' ORDER BY last_scanned_at DESC LIMIT 1`, folderID, contentHash))
}
func GetMediaFile(db *sql.DB, id string) (*MediaFile, error) {
return scanMediaFile(db.QueryRow(mediaFileSelect+` WHERE id = ?`, id))
}
const mediaFileSelect = `SELECT id, music_folder_id, relative_path, size_bytes, mtime_unix, content_hash, format, kind, duration_seconds, bitrate_kbps, sample_rate_hz, channels, missing, first_seen_at, last_scanned_at FROM media_files`
func scanMediaFile(row *sql.Row) (*MediaFile, error) {
var m MediaFile
var missing int
var firstSeen, lastScanned string
err := row.Scan(&m.ID, &m.MusicFolderID, &m.RelativePath, &m.SizeBytes, &m.MtimeUnix, &m.ContentHash, &m.Format, &m.Kind,
&m.DurationSeconds, &m.BitrateKbps, &m.SampleRateHz, &m.Channels, &missing, &firstSeen, &lastScanned)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
m.Missing = missing != 0
m.FirstSeenAt, _ = parseTime(firstSeen)
m.LastScannedAt, _ = parseTime(lastScanned)
return &m, nil
}
// CreateMediaFile inserts a new media file row for a path never seen
// before in this folder.
func CreateMediaFile(db *sql.DB, folderID, relativePath string, size, mtime int64, hash, format, kind string, duration float64, bitrate, sampleRate, channels int) (*MediaFile, error) {
id := idgen.New("mfl")
_, err := db.Exec(`INSERT INTO media_files (id, music_folder_id, relative_path, size_bytes, mtime_unix, content_hash, format, kind, duration_seconds, bitrate_kbps, sample_rate_hz, channels)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, folderID, relativePath, size, mtime, hash, format, kind, duration, bitrate, sampleRate, channels)
if err != nil {
return nil, err
}
return GetMediaFile(db, id)
}
// UpdateMediaFileScanned refreshes a media file's disk-derived fields
// after a rescan finds it changed, and clears any missing flag.
func UpdateMediaFileScanned(db *sql.DB, id string, size, mtime int64, hash, format, kind string, duration float64, bitrate, sampleRate, channels int) error {
_, err := db.Exec(`UPDATE media_files SET size_bytes=?, mtime_unix=?, content_hash=?, format=?, kind=?, duration_seconds=?, bitrate_kbps=?, sample_rate_hz=?, channels=?, missing=0, last_scanned_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
size, mtime, hash, format, kind, duration, bitrate, sampleRate, channels, id)
return err
}
// TouchMediaFileScanned marks a file as seen in the current scan pass
// without changing its metadata (fast path for unchanged files).
func TouchMediaFileScanned(db *sql.DB, id string) error {
_, err := db.Exec(`UPDATE media_files SET missing=0, last_scanned_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, id)
return err
}
// RenameMediaFile updates the relative path of a file matched by content
// hash to a new location (rename/move detection).
func RenameMediaFile(db *sql.DB, id, newRelativePath string) error {
_, err := db.Exec(`UPDATE media_files SET relative_path=?, missing=0, last_scanned_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, newRelativePath, id)
return err
}
// MarkFolderFilesMissingExcept flags every media file in a folder that
// was not touched (last_scanned_at older than the given scan start) as
// missing, without deleting it — playlists, stars, and history stay
// intact if the file reappears later.
func MarkFolderFilesMissingExcept(db *sql.DB, folderID string, scanStartedAt time.Time) (int64, error) {
res, err := db.Exec(`UPDATE media_files SET missing=1 WHERE music_folder_id=? AND missing=0 AND last_scanned_at < ?`,
folderID, scanStartedAt.UTC().Format(timeLayout))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func ListMediaFilesForFolder(db *sql.DB, folderID string) ([]*MediaFile, error) {
rows, err := db.Query(mediaFileSelect+` WHERE music_folder_id = ? ORDER BY relative_path`, folderID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMediaFiles(rows)
}
func scanMediaFiles(rows *sql.Rows) ([]*MediaFile, error) {
var out []*MediaFile
for rows.Next() {
var m MediaFile
var missing int
var firstSeen, lastScanned string
if err := rows.Scan(&m.ID, &m.MusicFolderID, &m.RelativePath, &m.SizeBytes, &m.MtimeUnix, &m.ContentHash, &m.Format, &m.Kind,
&m.DurationSeconds, &m.BitrateKbps, &m.SampleRateHz, &m.Channels, &missing, &firstSeen, &lastScanned); err != nil {
return nil, err
}
m.Missing = missing != 0
m.FirstSeenAt, _ = parseTime(firstSeen)
m.LastScannedAt, _ = parseTime(lastScanned)
out = append(out, &m)
}
return out, rows.Err()
}
+125
View File
@@ -0,0 +1,125 @@
package database
import (
"database/sql"
"errors"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
type Playlist struct {
ID string
OwnerID string
Name string
Description string
IsPublic bool
CreatedAt time.Time
UpdatedAt time.Time
}
func CreatePlaylist(db *sql.DB, ownerID, name, description string, isPublic bool) (*Playlist, error) {
id := idgen.New("pl")
_, err := db.Exec(`INSERT INTO playlists (id, owner_id, name, description, is_public) VALUES (?, ?, ?, ?, ?)`,
id, ownerID, name, description, boolInt(isPublic))
if err != nil {
return nil, err
}
return GetPlaylist(db, id)
}
const playlistSelect = `SELECT id, owner_id, name, description, is_public, created_at, updated_at FROM playlists`
func GetPlaylist(db *sql.DB, id string) (*Playlist, error) {
return scanPlaylist(db.QueryRow(playlistSelect+` WHERE id = ?`, id))
}
func scanPlaylist(row *sql.Row) (*Playlist, error) {
var p Playlist
var isPublic int
var createdAt, updatedAt string
err := row.Scan(&p.ID, &p.OwnerID, &p.Name, &p.Description, &isPublic, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
p.IsPublic = isPublic != 0
p.CreatedAt, _ = parseTime(createdAt)
p.UpdatedAt, _ = parseTime(updatedAt)
return &p, nil
}
// ListPlaylistsVisibleToUser returns playlists owned by the user plus any
// public playlists owned by others.
func ListPlaylistsVisibleToUser(db *sql.DB, userID string) ([]*Playlist, error) {
rows, err := db.Query(playlistSelect+` WHERE owner_id = ? OR is_public = 1 ORDER BY name COLLATE NOCASE`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Playlist
for rows.Next() {
var p Playlist
var isPublic int
var createdAt, updatedAt string
if err := rows.Scan(&p.ID, &p.OwnerID, &p.Name, &p.Description, &isPublic, &createdAt, &updatedAt); err != nil {
return nil, err
}
p.IsPublic = isPublic != 0
p.CreatedAt, _ = parseTime(createdAt)
p.UpdatedAt, _ = parseTime(updatedAt)
out = append(out, &p)
}
return out, rows.Err()
}
func DeletePlaylist(db *sql.DB, id string) error {
_, err := db.Exec(`DELETE FROM playlists WHERE id = ?`, id)
return err
}
func RenamePlaylist(db *sql.DB, id, name, description string, isPublic bool) error {
_, err := db.Exec(`UPDATE playlists SET name=?, description=?, is_public=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`,
name, description, boolInt(isPublic), id)
return err
}
type PlaylistEntry struct {
ID string
SongID string
Position int
}
func ListPlaylistEntries(db *sql.DB, playlistID string) ([]*PlaylistEntry, error) {
rows, err := db.Query(`SELECT id, song_id, position FROM playlist_entries WHERE playlist_id = ? ORDER BY position`, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PlaylistEntry
for rows.Next() {
var e PlaylistEntry
if err := rows.Scan(&e.ID, &e.SongID, &e.Position); err != nil {
return nil, err
}
out = append(out, &e)
}
return out, rows.Err()
}
// AppendPlaylistEntry adds a song at the end of the playlist.
func AppendPlaylistEntry(db *sql.DB, playlistID, songID string) error {
var next int
if err := db.QueryRow(`SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_entries WHERE playlist_id = ?`, playlistID).Scan(&next); err != nil {
return err
}
_, err := db.Exec(`INSERT INTO playlist_entries (id, playlist_id, song_id, position) VALUES (?, ?, ?, ?)`, idgen.New("pe"), playlistID, songID, next)
return err
}
func RemovePlaylistEntry(db *sql.DB, entryID string) error {
_, err := db.Exec(`DELETE FROM playlist_entries WHERE id = ?`, entryID)
return err
}
+86
View File
@@ -0,0 +1,86 @@
package database
import (
"database/sql"
"errors"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
type Session struct {
ID string
UserID string
CreatedAt time.Time
ExpiresAt time.Time
UserAgent string
IPAddress string
}
func CreateSession(db *sql.DB, userID string, ttl time.Duration, userAgent, ip string) (*Session, error) {
id := idgen.New("se")
expires := time.Now().Add(ttl).UTC()
_, err := db.Exec(`INSERT INTO sessions (id, user_id, expires_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?)`,
id, userID, expires.Format(timeLayout), userAgent, ip)
if err != nil {
return nil, err
}
return GetSession(db, id)
}
const timeLayout = "2006-01-02T15:04:05.000Z"
func GetSession(db *sql.DB, id string) (*Session, error) {
var s Session
var createdAt, expiresAt string
err := db.QueryRow(`SELECT id, user_id, created_at, expires_at, user_agent, ip_address FROM sessions WHERE id = ?`, id).
Scan(&s.ID, &s.UserID, &createdAt, &expiresAt, &s.UserAgent, &s.IPAddress)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
s.CreatedAt, _ = parseTime(createdAt)
s.ExpiresAt, _ = parseTime(expiresAt)
return &s, nil
}
func DeleteSession(db *sql.DB, id string) error {
_, err := db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
return err
}
// DeleteUserSessions revokes every session for a user (e.g. on password
// change or "log out everywhere").
func DeleteUserSessions(db *sql.DB, userID string) error {
_, err := db.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID)
return err
}
func ListUserSessions(db *sql.DB, userID string) ([]*Session, error) {
rows, err := db.Query(`SELECT id, user_id, created_at, expires_at, user_agent, ip_address FROM sessions WHERE user_id = ? ORDER BY created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Session
for rows.Next() {
var s Session
var createdAt, expiresAt string
if err := rows.Scan(&s.ID, &s.UserID, &createdAt, &expiresAt, &s.UserAgent, &s.IPAddress); err != nil {
return nil, err
}
s.CreatedAt, _ = parseTime(createdAt)
s.ExpiresAt, _ = parseTime(expiresAt)
out = append(out, &s)
}
return out, rows.Err()
}
// DeleteExpiredSessions purges sessions past their expiry, keeping the
// table small over a long-running server's lifetime.
func DeleteExpiredSessions(db *sql.DB) error {
_, err := db.Exec(`DELETE FROM sessions WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ','now')`)
return err
}
+95
View File
@@ -0,0 +1,95 @@
package database
import (
"database/sql"
"errors"
)
// GetSetting reads a key from the settings table, returning def if unset.
func GetSetting(db *sql.DB, key, def string) string {
var v string
err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if err != nil {
return def
}
return v
}
func SetSetting(db *sql.DB, key, value string) error {
_, err := db.Exec(`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT (key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, key, value)
return err
}
// --- Scan state ---
type ScanState struct {
ID string
MusicFolderID string
Status string
StartedAt sql.NullString
FinishedAt sql.NullString
FilesSeen int
FilesTotalEstimate int
FilesAdded int
FilesUpdated int
FilesRemoved int
ErrorMessage string
LastFullScanAt sql.NullString
}
const scanStateSelect = `SELECT id, music_folder_id, status, started_at, finished_at, files_seen, files_total_estimate, files_added, files_updated, files_removed, error_message, last_full_scan_at FROM scan_state`
func GetScanState(db *sql.DB, folderID string) (*ScanState, error) {
var s ScanState
err := db.QueryRow(scanStateSelect+` WHERE music_folder_id = ?`, folderID).Scan(
&s.ID, &s.MusicFolderID, &s.Status, &s.StartedAt, &s.FinishedAt, &s.FilesSeen, &s.FilesTotalEstimate,
&s.FilesAdded, &s.FilesUpdated, &s.FilesRemoved, &s.ErrorMessage, &s.LastFullScanAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &s, nil
}
func ListScanStates(db *sql.DB) ([]*ScanState, error) {
rows, err := db.Query(scanStateSelect)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*ScanState
for rows.Next() {
var s ScanState
if err := rows.Scan(&s.ID, &s.MusicFolderID, &s.Status, &s.StartedAt, &s.FinishedAt, &s.FilesSeen, &s.FilesTotalEstimate,
&s.FilesAdded, &s.FilesUpdated, &s.FilesRemoved, &s.ErrorMessage, &s.LastFullScanAt); err != nil {
return nil, err
}
out = append(out, &s)
}
return out, rows.Err()
}
func StartScan(db *sql.DB, folderID string) error {
_, err := db.Exec(`UPDATE scan_state SET status='scanning', started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), finished_at=NULL,
files_seen=0, files_added=0, files_updated=0, files_removed=0, error_message='' WHERE music_folder_id=?`, folderID)
return err
}
func UpdateScanProgress(db *sql.DB, folderID string, filesSeen, filesAdded, filesUpdated int) error {
_, err := db.Exec(`UPDATE scan_state SET files_seen=?, files_added=?, files_updated=? WHERE music_folder_id=?`,
filesSeen, filesAdded, filesUpdated, folderID)
return err
}
func FinishScan(db *sql.DB, folderID string, filesRemoved int, errMsg string) error {
status := "idle"
if errMsg != "" {
status = "error"
}
_, err := db.Exec(`UPDATE scan_state SET status=?, finished_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), files_removed=?, error_message=?,
last_full_scan_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE music_folder_id=?`, status, filesRemoved, errMsg, folderID)
return err
}
+41
View File
@@ -0,0 +1,41 @@
package database
import (
"database/sql"
"errors"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
// Shares are a schema foundation for Phase 2's Subsonic share endpoints;
// Phase 1 doesn't yet expose a sharing UI.
type Share struct {
ID string
OwnerID string
Token string
Description string
ExpiresAt sql.NullString
}
func CreateShare(db *sql.DB, ownerID, token, description string) (*Share, error) {
id := idgen.New("sh")
_, err := db.Exec(`INSERT INTO shares (id, owner_id, token, description) VALUES (?, ?, ?, ?)`, id, ownerID, token, description)
if err != nil {
return nil, err
}
return GetShareByToken(db, token)
}
func GetShareByToken(db *sql.DB, token string) (*Share, error) {
var s Share
err := db.QueryRow(`SELECT id, owner_id, token, description, expires_at FROM shares WHERE token = ?`, token).
Scan(&s.ID, &s.OwnerID, &s.Token, &s.Description, &s.ExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &s, nil
}
+160
View File
@@ -0,0 +1,160 @@
package database
import (
"database/sql"
"errors"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
type User struct {
ID string
Username string
Email string
PasswordHash sql.NullString
IsAdmin bool
Disabled bool
ExternalAuth bool
CreatedAt time.Time
LastLoginAt sql.NullTime
}
var ErrNotFound = errors.New("not found")
func CountUsers(db *sql.DB) (int, error) {
var n int
err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// CreateUser inserts a new user. passwordHash may be empty for
// external-auth-only accounts.
func CreateUser(db *sql.DB, username, email, passwordHash string, isAdmin bool) (*User, error) {
id := idgen.New("us")
var hash sql.NullString
if passwordHash != "" {
hash = sql.NullString{String: passwordHash, Valid: true}
}
_, err := db.Exec(`INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?)`,
id, username, email, nullStringVal(hash), boolInt(isAdmin))
if err != nil {
return nil, err
}
return GetUserByID(db, id)
}
// CreateExternalUser inserts a user with no local password, created on
// first login via a trusted reverse-proxy identity header.
func CreateExternalUser(db *sql.DB, username string) (*User, error) {
id := idgen.New("us")
_, err := db.Exec(`INSERT INTO users (id, username, external_auth) VALUES (?, ?, 1)`, id, username)
if err != nil {
return nil, err
}
return GetUserByID(db, id)
}
func GetUserByID(db *sql.DB, id string) (*User, error) {
return scanUser(db.QueryRow(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users WHERE id = ?`, id))
}
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
return scanUser(db.QueryRow(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users WHERE username = ?`, username))
}
func scanUser(row *sql.Row) (*User, error) {
var u User
var isAdmin, disabled, external int
var createdAt string
var lastLogin sql.NullString
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external, &createdAt, &lastLogin)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
u.IsAdmin = isAdmin != 0
u.Disabled = disabled != 0
u.ExternalAuth = external != 0
u.CreatedAt, _ = parseTime(createdAt)
if lastLogin.Valid {
if t, err := parseTime(lastLogin.String); err == nil {
u.LastLoginAt = sql.NullTime{Time: t, Valid: true}
}
}
return &u, nil
}
func ListUsers(db *sql.DB) ([]*User, error) {
rows, err := db.Query(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users ORDER BY username COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*User
for rows.Next() {
var u User
var isAdmin, disabled, external int
var createdAt string
var lastLogin sql.NullString
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external, &createdAt, &lastLogin); err != nil {
return nil, err
}
u.IsAdmin = isAdmin != 0
u.Disabled = disabled != 0
u.ExternalAuth = external != 0
u.CreatedAt, _ = parseTime(createdAt)
if lastLogin.Valid {
if t, err := parseTime(lastLogin.String); err == nil {
u.LastLoginAt = sql.NullTime{Time: t, Valid: true}
}
}
out = append(out, &u)
}
return out, rows.Err()
}
func SetUserPassword(db *sql.DB, userID, passwordHash string) error {
_, err := db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
return err
}
func SetUserDisabled(db *sql.DB, userID string, disabled bool) error {
_, err := db.Exec(`UPDATE users SET disabled = ? WHERE id = ?`, boolInt(disabled), userID)
return err
}
func SetUserAdmin(db *sql.DB, userID string, isAdmin bool) error {
_, err := db.Exec(`UPDATE users SET is_admin = ? WHERE id = ?`, boolInt(isAdmin), userID)
return err
}
func DeleteUser(db *sql.DB, userID string) error {
_, err := db.Exec(`DELETE FROM users WHERE id = ?`, userID)
return err
}
func TouchLastLogin(db *sql.DB, userID string) error {
_, err := db.Exec(`UPDATE users SET last_login_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, userID)
return err
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
func nullStringVal(s sql.NullString) any {
if !s.Valid {
return nil
}
return s.String
}
func parseTime(s string) (time.Time, error) {
return time.Parse("2006-01-02T15:04:05.999999999Z", s)
}