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