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
+5
View File
@@ -0,0 +1,5 @@
/vibesonic
/data/
*.db
*.db-shm
*.db-wal
+17
View File
@@ -0,0 +1,17 @@
module git.noroutes.net/NoRoutes/vibetunes
go 1.25.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.57.0 // indirect
)
+22
View File
@@ -0,0 +1,22 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
+14
View File
@@ -0,0 +1,14 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
)
// HashAPIKey returns a stable, non-reversible fingerprint of a raw API
// key for storage and lookup (API keys are high-entropy random tokens,
// not user-chosen passwords, so a plain fast hash is sufficient here).
func HashAPIKey(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
+59
View File
@@ -0,0 +1,59 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
)
const csrfCookieName = "vibesonic_csrf"
// CSRF implements the double-submit-cookie pattern: a random token is set
// in a cookie readable by JS-free forms via a template field, and checked
// against the same value submitted in the form body on state-changing
// requests.
type CSRF struct {
CookieSecure bool
}
// Token returns the CSRF token for this request, setting a fresh cookie
// if none exists yet.
func (c *CSRF) Token(w http.ResponseWriter, r *http.Request) string {
if ck, err := r.Cookie(csrfCookieName); err == nil && ck.Value != "" {
return ck.Value
}
b := make([]byte, 32)
_, _ = rand.Read(b)
token := base64.RawURLEncoding.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: false, // must be readable by the template render, not JS; kept simple since it never leaves same-origin form submits
Secure: c.CookieSecure,
SameSite: http.SameSiteLaxMode,
})
return token
}
// Protect wraps a handler, rejecting POST/PUT/PATCH/DELETE requests whose
// csrf_token form value doesn't match the csrf cookie.
func (c *CSRF) Protect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
ck, err := r.Cookie(csrfCookieName)
if err != nil || ck.Value == "" {
http.Error(w, "403 Forbidden: missing CSRF cookie", http.StatusForbidden)
return
}
submitted := r.FormValue("csrf_token")
if subtle.ConstantTimeCompare([]byte(submitted), []byte(ck.Value)) != 1 {
http.Error(w, "403 Forbidden: invalid CSRF token", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
+75
View File
@@ -0,0 +1,75 @@
package auth
import (
"database/sql"
"net"
"net/http"
"net/netip"
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// ExtAuth resolves users from a trusted reverse proxy's identity header,
// Navidrome-style. It never talks to an identity provider itself: the
// proxy is assumed to have already authenticated the browser user.
//
// Security-critical: the identity header is only trusted when the actual
// TCP peer address (r.RemoteAddr) matches a configured trusted CIDR.
// Headers like X-Forwarded-For/X-Real-IP/Forwarded are never consulted
// here, because they are attacker-controlled unless a dedicated trusted-
// proxy chain abstraction validates them — an arbitrary client could
// otherwise set the identity header directly and spoof any user.
type ExtAuth struct {
DB *sql.DB
Config config.ExtAuthConfig
}
// IdentityFromRequest returns the trusted username asserted by the
// reverse proxy for this request, or "" if external auth is disabled, no
// header was sent, or the connection did not originate from a trusted
// peer.
func (e *ExtAuth) IdentityFromRequest(r *http.Request) string {
if !e.Config.Enabled {
return ""
}
if !e.peerTrusted(r) {
return ""
}
return r.Header.Get(e.Config.UserHeader)
}
func (e *ExtAuth) peerTrusted(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return false
}
for _, prefix := range e.Config.TrustedSources {
if prefix.Contains(addr) {
return true
}
}
return false
}
// ResolveUser returns the VibeSonic user for a trusted external identity,
// creating one if AutoCreateUsers is set and no such user exists yet. It
// never creates a second, separate "external" user type: external users
// are ordinary users with ExternalAuth set.
func (e *ExtAuth) ResolveUser(username string) (*database.User, error) {
user, err := database.GetUserByUsername(e.DB, username)
if err == nil {
return user, nil
}
if err != database.ErrNotFound {
return nil, err
}
if !e.Config.AutoCreateUsers {
return nil, database.ErrNotFound
}
return database.CreateExternalUser(e.DB, username)
}
+126
View File
@@ -0,0 +1,126 @@
package auth
import (
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
)
func mustPrefix(t *testing.T, s string) netip.Prefix {
t.Helper()
p, err := netip.ParsePrefix(s)
if err != nil {
t.Fatalf("ParsePrefix(%q): %v", s, err)
}
return p
}
func TestExtAuthTrustsConfiguredPeer(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{
Enabled: true,
UserHeader: "Remote-User",
TrustedSources: []netip.Prefix{mustPrefix(t, "127.0.0.1/32")},
}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Header.Set("Remote-User", "alice")
if got := e.IdentityFromRequest(req); got != "alice" {
t.Fatalf("IdentityFromRequest = %q, want alice", got)
}
}
func TestExtAuthRejectsUntrustedPeer(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{
Enabled: true,
UserHeader: "Remote-User",
TrustedSources: []netip.Prefix{mustPrefix(t, "127.0.0.1/32")},
}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.7:54321" // arbitrary internet client, not the trusted proxy
req.Header.Set("Remote-User", "alice")
if got := e.IdentityFromRequest(req); got != "" {
t.Fatalf("IdentityFromRequest = %q, want empty for untrusted peer", got)
}
}
// TestExtAuthIgnoresForwardedHeadersFromPeer is the core anti-spoofing
// guarantee: an arbitrary internet client cannot impersonate a trusted
// proxy by setting X-Forwarded-For/X-Real-IP/Forwarded, because those
// headers are never consulted to decide whether the peer is trusted —
// only the real TCP RemoteAddr is.
func TestExtAuthIgnoresForwardedHeadersFromPeer(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{
Enabled: true,
UserHeader: "Remote-User",
TrustedSources: []netip.Prefix{mustPrefix(t, "127.0.0.1/32")},
}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.7:54321" // real connection is from an untrusted internet host
req.Header.Set("X-Forwarded-For", "127.0.0.1")
req.Header.Set("X-Real-IP", "127.0.0.1")
req.Header.Set("Forwarded", "for=127.0.0.1")
req.Header.Set("Remote-User", "admin")
if got := e.IdentityFromRequest(req); got != "" {
t.Fatalf("IdentityFromRequest = %q, want empty: forwarded headers must not establish trust", got)
}
}
func TestExtAuthDisabledNeverTrusts(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{
Enabled: false,
UserHeader: "Remote-User",
TrustedSources: []netip.Prefix{mustPrefix(t, "0.0.0.0/0")},
}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:1"
req.Header.Set("Remote-User", "alice")
if got := e.IdentityFromRequest(req); got != "" {
t.Fatalf("IdentityFromRequest = %q, want empty when ExtAuth disabled", got)
}
}
func TestExtAuthNoTrustedSourcesNeverTrusts(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{Enabled: true, UserHeader: "Remote-User"}}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "127.0.0.1:1"
req.Header.Set("Remote-User", "alice")
if got := e.IdentityFromRequest(req); got != "" {
t.Fatalf("IdentityFromRequest = %q, want empty with no trusted sources configured", got)
}
}
func TestExtAuthResolveUserAutoCreates(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{AutoCreateUsers: true}}
u, err := e.ResolveUser("newperson")
if err != nil {
t.Fatalf("ResolveUser: %v", err)
}
if !u.ExternalAuth {
t.Fatal("auto-created user should have ExternalAuth set")
}
if u.PasswordHash.Valid {
t.Fatal("auto-created external user should have no password hash")
}
}
func TestExtAuthResolveUserNoAutoCreate(t *testing.T) {
db := openTestDB(t)
e := &ExtAuth{DB: db, Config: config.ExtAuthConfig{AutoCreateUsers: false}}
if _, err := e.ResolveUser("ghost"); err == nil {
t.Fatal("expected error when auto-create is disabled and user doesn't exist")
}
}
+66
View File
@@ -0,0 +1,66 @@
package auth
import (
"net/http"
"net/url"
)
// Middleware resolves the current user for each request, from a session
// cookie first and falling back to trusted external auth, then makes it
// available via UserFromContext.
type Middleware struct {
Sessions *Sessions
ExtAuth *ExtAuth
}
// Authenticate resolves the current user (session cookie, then trusted
// external auth) and stores it in the request context, without requiring
// one to be present. Handlers that need to know whether someone is
// signed in (e.g. the login page, the home page) should use this.
func (m *Middleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := m.Sessions.CurrentUser(r)
if user == nil && m.ExtAuth != nil {
if username := m.ExtAuth.IdentityFromRequest(r); username != "" {
if u, err := m.ExtAuth.ResolveUser(username); err == nil && !u.Disabled {
user = u
_ = m.Sessions.Issue(w, r, u.ID)
}
}
}
if user != nil {
r = r.WithContext(WithUser(r.Context(), user))
}
next.ServeHTTP(w, r)
})
}
// RequireUser redirects unauthenticated requests to /login, preserving
// the original destination.
func (m *Middleware) RequireUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if UserFromContext(r.Context()) == nil {
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
// RequireAdmin is like RequireUser but also rejects non-admin users with
// 403. Authorization is enforced here, on the server, not merely by
// hiding admin links in the UI.
func (m *Middleware) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil {
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
return
}
if !user.IsAdmin {
http.Error(w, "403 Forbidden: administrator access required", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
+87
View File
@@ -0,0 +1,87 @@
// Package auth handles password hashing, cookie sessions, CSRF
// protection, and optional trusted-reverse-proxy external authentication.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters, tuned for a self-hosted single/small-household
// server rather than a high-throughput multi-tenant service.
const (
argonTime = 1
argonMemory = 64 * 1024 // KiB
argonThreads = 4
argonKeyLen = 32
saltLen = 16
)
var ErrInvalidHash = errors.New("invalid password hash format")
// HashPassword returns an encoded argon2id hash in the standard
// $argon2id$v=19$m=...,t=...,p=...$salt$hash format.
func HashPassword(password string) (string, error) {
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash), nil
}
// VerifyPassword checks password against an encoded hash produced by
// HashPassword, using constant-time comparison.
func VerifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, ErrInvalidHash
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, ErrInvalidHash
}
var memory, timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, ErrInvalidHash
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, ErrInvalidHash
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, ErrInvalidHash
}
got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// GenerateRandomPassword returns a strong, human-typeable temporary
// password for the first-run administrator account.
func GenerateRandomPassword() (string, error) {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// GenerateAPIKey returns a random API key and its SHA-256 hash for
// storage (the raw key is shown to the user only once).
func GenerateAPIKey() (raw string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "vs_" + base64.RawURLEncoding.EncodeToString(b), nil
}
+71
View File
@@ -0,0 +1,71 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("correct horse battery staple", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if !ok {
t.Fatal("VerifyPassword returned false for the correct password")
}
}
func TestVerifyPasswordRejectsWrongPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("wrong password", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if ok {
t.Fatal("VerifyPassword returned true for the wrong password")
}
}
func TestVerifyPasswordRejectsMalformedHash(t *testing.T) {
if _, err := VerifyPassword("anything", "not-a-hash"); err != ErrInvalidHash {
t.Fatalf("VerifyPassword error = %v, want ErrInvalidHash", err)
}
}
func TestHashPasswordProducesUniqueSalts(t *testing.T) {
h1, err := HashPassword("same password")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
h2, err := HashPassword("same password")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if h1 == h2 {
t.Fatal("two hashes of the same password should differ due to random salts")
}
}
func TestGenerateAPIKeyAndHash(t *testing.T) {
k1, err := GenerateAPIKey()
if err != nil {
t.Fatalf("GenerateAPIKey: %v", err)
}
k2, err := GenerateAPIKey()
if err != nil {
t.Fatalf("GenerateAPIKey: %v", err)
}
if k1 == k2 {
t.Fatal("two generated API keys should differ")
}
if HashAPIKey(k1) == HashAPIKey(k2) {
t.Fatal("hashes of two different keys should differ")
}
if HashAPIKey(k1) != HashAPIKey(k1) {
t.Fatal("hashing the same key twice should be stable")
}
}
+97
View File
@@ -0,0 +1,97 @@
package auth
import (
"context"
"database/sql"
"net"
"net/http"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
const (
SessionCookieName = "vibesonic_session"
SessionTTL = 30 * 24 * time.Hour
)
// Sessions issues and validates cookie sessions backed by the sessions
// table, so they can be listed and revoked server-side.
type Sessions struct {
DB *sql.DB
CookieSecure bool
}
func (s *Sessions) Issue(w http.ResponseWriter, r *http.Request, userID string) error {
sess, err := database.CreateSession(s.DB, userID, SessionTTL, r.UserAgent(), clientIP(r))
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sess.ID,
Path: "/",
HttpOnly: true,
Secure: s.CookieSecure,
SameSite: http.SameSiteLaxMode,
Expires: sess.ExpiresAt,
})
return nil
}
func (s *Sessions) Clear(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(SessionCookieName); err == nil {
_ = database.DeleteSession(s.DB, c.Value)
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.CookieSecure,
})
}
// CurrentUser resolves the authenticated user from the session cookie, or
// nil if there is none / it is invalid / expired / the user is disabled.
func (s *Sessions) CurrentUser(r *http.Request) *database.User {
c, err := r.Cookie(SessionCookieName)
if err != nil {
return nil
}
sess, err := database.GetSession(s.DB, c.Value)
if err != nil {
return nil
}
if time.Now().After(sess.ExpiresAt) {
_ = database.DeleteSession(s.DB, sess.ID)
return nil
}
user, err := database.GetUserByID(s.DB, sess.UserID)
if err != nil || user.Disabled {
return nil
}
return user
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
type contextKey string
const userContextKey contextKey = "vibesonic_user"
func WithUser(ctx context.Context, u *database.User) context.Context {
return context.WithValue(ctx, userContextKey, u)
}
func UserFromContext(ctx context.Context) *database.User {
u, _ := ctx.Value(userContextKey).(*database.User)
return u
}
+112
View File
@@ -0,0 +1,112 @@
package auth
import (
"database/sql"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := database.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("database.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestSessionIssueAndCurrentUser(t *testing.T) {
db := openTestDB(t)
user, err := database.CreateUser(db, "alice", "alice@example.com", "", false)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
sessions := &Sessions{DB: db, CookieSecure: false}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
if err := sessions.Issue(rec, req, user.ID); err != nil {
t.Fatalf("Issue: %v", err)
}
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req2.AddCookie(c)
}
got := sessions.CurrentUser(req2)
if got == nil || got.ID != user.ID {
t.Fatalf("CurrentUser = %v, want %s", got, user.ID)
}
}
func TestSessionClearRevokesCookie(t *testing.T) {
db := openTestDB(t)
user, err := database.CreateUser(db, "bob", "", "", false)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
sessions := &Sessions{DB: db, CookieSecure: false}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
if err := sessions.Issue(rec, req, user.ID); err != nil {
t.Fatalf("Issue: %v", err)
}
var cookies []*http.Cookie
for _, c := range rec.Result().Cookies() {
cookies = append(cookies, c)
}
logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil)
for _, c := range cookies {
logoutReq.AddCookie(c)
}
logoutRec := httptest.NewRecorder()
sessions.Clear(logoutRec, logoutReq)
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range cookies {
req2.AddCookie(c)
}
if got := sessions.CurrentUser(req2); got != nil {
t.Fatalf("CurrentUser after Clear = %v, want nil", got)
}
}
func TestSessionRejectsUnknownCookie(t *testing.T) {
db := openTestDB(t)
sessions := &Sessions{DB: db, CookieSecure: false}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "does-not-exist"})
if got := sessions.CurrentUser(req); got != nil {
t.Fatalf("CurrentUser = %v, want nil for unknown session", got)
}
}
func TestSessionRejectsDisabledUser(t *testing.T) {
db := openTestDB(t)
user, err := database.CreateUser(db, "carol", "", "", false)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := database.SetUserDisabled(db, user.ID, true); err != nil {
t.Fatalf("SetUserDisabled: %v", err)
}
sessions := &Sessions{DB: db, CookieSecure: false}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
if err := sessions.Issue(rec, req, user.ID); err != nil {
t.Fatalf("Issue: %v", err)
}
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req2.AddCookie(c)
}
if got := sessions.CurrentUser(req2); got != nil {
t.Fatalf("CurrentUser = %v, want nil for disabled user", got)
}
}
+137
View File
@@ -0,0 +1,137 @@
// Package config loads VibeSonic's process configuration from
// VIBESONIC_* environment variables. There is no config file: everything
// needed to run the server is available via the environment so the same
// binary works under systemd or Docker without a wrapper script.
package config
import (
"fmt"
"net/netip"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
ListenAddr string
DataDir string
DBPath string
MusicDirs []string
FFmpegPath string
FFprobePath string
ScanInterval time.Duration
SessionSecret string
CookieSecure bool
ExtAuth ExtAuthConfig
}
// ExtAuthConfig configures optional Navidrome-style externalized
// authentication: a trusted reverse proxy authenticates the browser user
// and passes their identity in a header. VibeSonic never talks to an
// identity provider itself.
type ExtAuthConfig struct {
Enabled bool
TrustedSources []netip.Prefix
UserHeader string
LogoutURL string
AutoCreateUsers bool
}
// Load reads configuration from the environment, applying defaults for
// anything unset. It returns an error only for values that are set but
// invalid (e.g. an unparsable duration or CIDR).
func Load() (Config, error) {
cfg := Config{
ListenAddr: envOr("VIBESONIC_LISTEN_ADDR", ":8080"),
DataDir: envOr("VIBESONIC_DATA_DIR", "/var/lib/vibesonic"),
FFmpegPath: envOr("VIBESONIC_FFMPEG_PATH", "ffmpeg"),
FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"),
SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"),
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true),
}
cfg.DBPath = envOr("VIBESONIC_DB", filepath.Join(cfg.DataDir, "vibesonic.db"))
if dirs := os.Getenv("VIBESONIC_MUSIC_DIRS"); dirs != "" {
for _, d := range strings.Split(dirs, ":") {
d = strings.TrimSpace(d)
if d != "" {
cfg.MusicDirs = append(cfg.MusicDirs, d)
}
}
}
interval := envOr("VIBESONIC_SCAN_INTERVAL", "1h")
d, err := time.ParseDuration(interval)
if err != nil {
return cfg, fmt.Errorf("VIBESONIC_SCAN_INTERVAL: %w", err)
}
cfg.ScanInterval = d
extauth, err := loadExtAuth()
if err != nil {
return cfg, err
}
cfg.ExtAuth = extauth
return cfg, nil
}
func loadExtAuth() (ExtAuthConfig, error) {
e := ExtAuthConfig{
Enabled: envBool("VIBESONIC_EXTAUTH_ENABLED", false),
UserHeader: envOr("VIBESONIC_EXTAUTH_USER_HEADER", "Remote-User"),
LogoutURL: os.Getenv("VIBESONIC_EXTAUTH_LOGOUT_URL"),
AutoCreateUsers: envBool("VIBESONIC_EXTAUTH_AUTO_CREATE_USERS", true),
}
raw := os.Getenv("VIBESONIC_EXTAUTH_TRUSTED_SOURCES")
if raw == "" {
// No default trusted network: external auth must be explicitly
// scoped to a real network before it can trust anything.
return e, nil
}
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
prefix, err := parseCIDROrIP(part)
if err != nil {
return e, fmt.Errorf("VIBESONIC_EXTAUTH_TRUSTED_SOURCES: %w", err)
}
e.TrustedSources = append(e.TrustedSources, prefix)
}
return e, nil
}
func parseCIDROrIP(s string) (netip.Prefix, error) {
if p, err := netip.ParsePrefix(s); err == nil {
return p, nil
}
addr, err := netip.ParseAddr(s)
if err != nil {
return netip.Prefix{}, fmt.Errorf("invalid CIDR or IP %q: %w", s, err)
}
return netip.PrefixFrom(addr, addr.BitLen()), nil
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envBool(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
+95
View File
@@ -0,0 +1,95 @@
package config
import (
"testing"
)
func withEnv(t *testing.T, kv map[string]string) {
t.Helper()
for k, v := range kv {
t.Setenv(k, v)
}
}
func TestLoadDefaults(t *testing.T) {
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ListenAddr != ":8080" {
t.Errorf("ListenAddr = %q, want :8080", cfg.ListenAddr)
}
if cfg.ScanInterval.String() != "1h0m0s" {
t.Errorf("ScanInterval = %v, want 1h", cfg.ScanInterval)
}
if cfg.DBPath != "/var/lib/vibesonic/vibesonic.db" {
t.Errorf("DBPath = %q, want default under DataDir", cfg.DBPath)
}
if cfg.ExtAuth.Enabled {
t.Errorf("ExtAuth.Enabled = true, want false by default")
}
if len(cfg.ExtAuth.TrustedSources) != 0 {
t.Errorf("ExtAuth.TrustedSources should be empty by default, got %v", cfg.ExtAuth.TrustedSources)
}
}
func TestLoadMusicDirs(t *testing.T) {
withEnv(t, map[string]string{"VIBESONIC_MUSIC_DIRS": "/music:/music-lossless: /audiobooks "})
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"/music", "/music-lossless", "/audiobooks"}
if len(cfg.MusicDirs) != len(want) {
t.Fatalf("MusicDirs = %v, want %v", cfg.MusicDirs, want)
}
for i, w := range want {
if cfg.MusicDirs[i] != w {
t.Errorf("MusicDirs[%d] = %q, want %q", i, cfg.MusicDirs[i], w)
}
}
}
func TestLoadInvalidScanInterval(t *testing.T) {
withEnv(t, map[string]string{"VIBESONIC_SCAN_INTERVAL": "not-a-duration"})
if _, err := Load(); err == nil {
t.Fatal("expected error for invalid VIBESONIC_SCAN_INTERVAL")
}
}
func TestLoadExtAuthTrustedSources(t *testing.T) {
withEnv(t, map[string]string{
"VIBESONIC_EXTAUTH_ENABLED": "true",
"VIBESONIC_EXTAUTH_TRUSTED_SOURCES": "127.0.0.1/32, 10.0.0.0/8, ::1",
})
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.ExtAuth.Enabled {
t.Fatal("ExtAuth.Enabled should be true")
}
if len(cfg.ExtAuth.TrustedSources) != 3 {
t.Fatalf("TrustedSources = %v, want 3 entries", cfg.ExtAuth.TrustedSources)
}
}
func TestLoadExtAuthInvalidCIDR(t *testing.T) {
withEnv(t, map[string]string{"VIBESONIC_EXTAUTH_TRUSTED_SOURCES": "not-a-cidr"})
if _, err := Load(); err == nil {
t.Fatal("expected error for invalid VIBESONIC_EXTAUTH_TRUSTED_SOURCES")
}
}
func TestLoadExtAuthNoDefaultTrust(t *testing.T) {
// Regression guard: external auth must never trust anything unless a
// network is explicitly configured. In particular it must not default
// to 0.0.0.0/0 or RFC1918 ranges.
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.ExtAuth.TrustedSources) != 0 {
t.Fatalf("expected no default trusted sources, got %v", cfg.ExtAuth.TrustedSources)
}
}
+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)
}
+34
View File
@@ -0,0 +1,34 @@
// Package idgen generates stable, opaque, URL-safe identifiers for domain
// entities (users, folders, artists, albums, songs, ...).
//
// IDs are random and assigned once, then persisted. They are never derived
// from filesystem position or recomputed on rescan: the scanner looks up
// existing rows by natural key (e.g. a song's path within its music
// folder) and only calls NewID for rows it has never seen before, so IDs
// stay stable across restarts and normal rescans.
package idgen
import (
"crypto/rand"
"encoding/base32"
"strings"
)
// enc is unpadded base32 using Crockford's alphabet-like lowercase set,
// which is URL-safe and avoids ambiguous characters better than base64url
// for anything a human might need to read back (log lines, debugging).
var enc = base32.NewEncoding("0123456789abcdefghjkmnpqrstvwxyz").WithPadding(base32.NoPadding)
// New returns a new opaque ID with the given short type prefix, e.g.
// New("us") -> "us_1a2b3c4d5e6f7g8h9j0k1m2n".
func New(prefix string) string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err) // crypto/rand failing means the system is broken
}
id := strings.ToLower(enc.EncodeToString(b))
if prefix == "" {
return id
}
return prefix + "_" + id
}
+39
View File
@@ -0,0 +1,39 @@
package idgen
import (
"net/url"
"strings"
"testing"
)
func TestNewHasPrefix(t *testing.T) {
id := New("sg")
if !strings.HasPrefix(id, "sg_") {
t.Fatalf("New(%q) = %q, want prefix sg_", "sg", id)
}
}
func TestNewIsUnique(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 1000; i++ {
id := New("sg")
if seen[id] {
t.Fatalf("duplicate ID generated: %s", id)
}
seen[id] = true
}
}
func TestNewIsURLSafe(t *testing.T) {
id := New("al")
if url.PathEscape(id) != id {
t.Fatalf("New() = %q is not URL-safe when path-escaped: %q", id, url.PathEscape(id))
}
}
func TestNewNoPrefix(t *testing.T) {
id := New("")
if strings.Contains(id, "_") {
t.Fatalf("New(\"\") = %q should not contain an underscore", id)
}
}
+69
View File
@@ -0,0 +1,69 @@
package metadata
import (
"context"
"encoding/json"
"fmt"
"os/exec"
)
type ffprobeOutput struct {
Format ffprobeFormat `json:"format"`
Streams []ffprobeStream `json:"streams"`
}
type ffprobeFormat struct {
FormatName string `json:"format_name"`
Duration string `json:"duration"`
BitRate string `json:"bit_rate"`
Tags map[string]string `json:"tags"`
}
type ffprobeStream struct {
Index int `json:"index"`
CodecType string `json:"codec_type"` // "audio", "video", "subtitle", ...
CodecName string `json:"codec_name"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
Duration string `json:"duration"`
BitRate string `json:"bit_rate"`
Disposition map[string]int `json:"disposition"`
Tags map[string]string `json:"tags"`
}
// Prober runs ffprobe to extract tags and technical properties from a
// media file.
type Prober struct {
FFprobePath string
}
// Probe reads metadata from the file at path. path must already be a
// validated, absolute filesystem path within a configured music
// folder — Probe does not perform any authorization or traversal checks
// itself.
func (p *Prober) Probe(ctx context.Context, path string) (*Tags, error) {
ffprobePath := p.FFprobePath
if ffprobePath == "" {
ffprobePath = "ffprobe"
}
cmd := exec.CommandContext(ctx, ffprobePath,
"-v", "error",
"-print_format", "json",
"-show_format",
"-show_streams",
"--", path,
)
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("ffprobe: %w: %s", err, string(ee.Stderr))
}
return nil, fmt.Errorf("ffprobe: %w", err)
}
var raw ffprobeOutput
if err := json.Unmarshal(out, &raw); err != nil {
return nil, fmt.Errorf("parsing ffprobe output: %w", err)
}
return parseFFprobeOutput(raw), nil
}
@@ -0,0 +1,75 @@
package metadata
import (
"context"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestProbeRealFile(t *testing.T) {
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not installed, skipping integration test")
}
if _, err := exec.LookPath("ffprobe"); err != nil {
t.Skip("ffprobe not installed, skipping integration test")
}
dir := t.TempDir()
out := filepath.Join(dir, "test.mp3")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, ffmpegPath,
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
"-metadata", "title=Integration Title",
"-metadata", "artist=Integration Artist",
"-metadata", "album=Integration Album",
"-metadata", "track=5/10",
"-codec:a", "libmp3lame", "-b:a", "64k",
out,
)
if combined, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("ffmpeg failed: %v\n%s", err, combined)
}
p := &Prober{}
tags, err := p.Probe(ctx, out)
if err != nil {
t.Fatalf("Probe: %v", err)
}
if tags.Title != "Integration Title" {
t.Errorf("Title = %q, want %q", tags.Title, "Integration Title")
}
if len(tags.Artists) != 1 || tags.Artists[0] != "Integration Artist" {
t.Errorf("Artists = %v", tags.Artists)
}
if tags.Album != "Integration Album" {
t.Errorf("Album = %q", tags.Album)
}
if tags.TrackNumber != 5 || tags.TrackTotal != 10 {
t.Errorf("Track = %d/%d, want 5/10", tags.TrackNumber, tags.TrackTotal)
}
if tags.DurationSeconds < 0.9 || tags.DurationSeconds > 1.5 {
t.Errorf("DurationSeconds = %v, want ~1", tags.DurationSeconds)
}
if tags.SampleRateHz == 0 {
t.Error("SampleRateHz = 0, want > 0")
}
if tags.Kind != "song" {
t.Errorf("Kind = %q, want song", tags.Kind)
}
}
func TestProbeRejectsMissingFile(t *testing.T) {
if _, err := exec.LookPath("ffprobe"); err != nil {
t.Skip("ffprobe not installed, skipping integration test")
}
p := &Prober{}
if _, err := p.Probe(context.Background(), "/nonexistent/path/does-not-exist.mp3"); err == nil {
t.Fatal("expected error probing a nonexistent file")
}
}
+69
View File
@@ -0,0 +1,69 @@
// Package metadata reads audio/video tags and technical properties via
// ffprobe. It shells out with an explicit argument list (never a shell),
// so filenames and tag content can never be interpreted as commands.
package metadata
// Tags holds the metadata VibeSonic understands, extracted from a single
// media file. Multi-value fields (Artists, Genres) are kept as slices
// rather than flattened into a single string, since a song can have
// several performers or genres.
type Tags struct {
Title string
SortTitle string
Album string
SortAlbum string
AlbumArtist string
SortAlbumArtist string
Artists []string // primary + featured performers, in credit order
Composer string
Conductor string
Contributors []Contributor // producer/engineer/remixer/etc.
Genres []string
Year int
OriginalYear int
ReleaseDate string
OriginalReleaseDate string
RecordLabel string
TrackNumber int
TrackTotal int
DiscNumber int
DiscTotal int
DiscSubtitle string
Comment string
ISRC string
BPM int
MusicBrainzTrackID string
MusicBrainzRecordingID string
MusicBrainzAlbumID string
MusicBrainzArtistID string
ReplayGainTrackGainDB *float64
ReplayGainTrackPeak *float64
ReplayGainAlbumGainDB *float64
ReplayGainAlbumPeak *float64
HasEmbeddedArt bool
EmbeddedArtStream int
EmbeddedLyrics string
// Technical properties, read from the container/stream info rather
// than tags.
Format string
Kind string // "song" or "video"
DurationSeconds float64
BitrateKbps int
SampleRateHz int
Channels int
}
// Contributor is a non-performer credit, e.g. {Name: "Rick Rubin", Role: "producer"}.
type Contributor struct {
Name string
Role string
}
+233
View File
@@ -0,0 +1,233 @@
package metadata
import (
"strconv"
"strings"
)
// parseFFprobeOutput maps ffprobe's raw JSON (format+stream tags, which
// vary in casing and naming between containers/taggers) onto our Tags
// struct. It is a pure function so it can be tested without invoking
// ffprobe.
func parseFFprobeOutput(raw ffprobeOutput) *Tags {
tags := raw.Format.Tags
if tags == nil {
tags = map[string]string{}
}
t := &Tags{
Title: getTag(tags, "title"),
SortTitle: getTag(tags, "title-sort", "titlesort", "sort_name"),
Album: getTag(tags, "album"),
SortAlbum: getTag(tags, "album-sort", "albumsort"),
AlbumArtist: getTag(tags, "album_artist", "albumartist", "band"),
SortAlbumArtist: getTag(tags, "albumartistsort", "album_artist_sort"),
Composer: getTag(tags, "composer"),
Conductor: getTag(tags, "conductor"),
Comment: getTag(tags, "comment"),
ISRC: getTag(tags, "isrc"),
RecordLabel: getTag(tags, "label", "publisher", "organization"),
ReleaseDate: getTag(tags, "date", "release_date"),
OriginalReleaseDate: getTag(tags, "original_release_date", "originaldate"),
DiscSubtitle: getTag(tags, "discsubtitle", "disc_subtitle"),
MusicBrainzTrackID: getTag(tags, "musicbrainz_releasetrackid", "musicbrainz_trackid"),
MusicBrainzRecordingID: getTag(tags, "musicbrainz_trackid", "musicbrainz_recording_id"),
MusicBrainzAlbumID: getTag(tags, "musicbrainz_albumid"),
MusicBrainzArtistID: getTag(tags, "musicbrainz_artistid"),
}
artistField := getTag(tags, "artist")
t.Artists = splitMulti(artistField)
genreField := getTag(tags, "genre")
t.Genres = splitMulti(genreField)
t.Contributors = parseContributors(tags)
t.Year = parseYear(t.ReleaseDate)
if t.Year == 0 {
t.Year = atoiOr(getTag(tags, "year"), 0)
}
t.OriginalYear = parseYear(t.OriginalReleaseDate)
t.TrackNumber, t.TrackTotal = parseNumOfTotal(getTag(tags, "track"))
t.DiscNumber, t.DiscTotal = parseNumOfTotal(getTag(tags, "disc"))
t.BPM = atoiOr(getTag(tags, "bpm", "tbpm"), 0)
t.ReplayGainTrackGainDB = parseGainDB(getTag(tags, "replaygain_track_gain"))
t.ReplayGainTrackPeak = parseFloatPtr(getTag(tags, "replaygain_track_peak"))
t.ReplayGainAlbumGainDB = parseGainDB(getTag(tags, "replaygain_album_gain"))
t.ReplayGainAlbumPeak = parseFloatPtr(getTag(tags, "replaygain_album_peak"))
t.DurationSeconds = atofOr(raw.Format.Duration, 0)
t.Format = normalizeFormatName(raw.Format.FormatName)
t.Kind = "song"
for _, s := range raw.Streams {
switch s.CodecType {
case "audio":
if t.SampleRateHz == 0 {
t.SampleRateHz = int(atofOr(s.SampleRate, 0))
}
if t.Channels == 0 {
t.Channels = s.Channels
}
if lyrics := getTag(s.Tags, "lyrics", "unsyncedlyrics"); lyrics != "" && t.EmbeddedLyrics == "" {
t.EmbeddedLyrics = lyrics
}
case "video":
if s.Disposition["attached_pic"] == 1 {
t.HasEmbeddedArt = true
t.EmbeddedArtStream = s.Index
} else {
// A non-attached-pic video stream means this is an
// actual video file, not audio with cover art.
t.Kind = "video"
}
}
}
bitrate := raw.Format.BitRate
if bitrate == "" && len(raw.Streams) > 0 {
bitrate = raw.Streams[0].BitRate
}
t.BitrateKbps = int(atofOr(bitrate, 0) / 1000)
return t
}
// getTag looks up the first present key from candidates, case-
// insensitively — different containers/taggers spell the same field
// differently (e.g. ID3's TPE2 surfaces as "album_artist", Vorbis
// comments use "ALBUMARTIST").
func getTag(tags map[string]string, candidates ...string) string {
if len(tags) == 0 {
return ""
}
lower := make(map[string]string, len(tags))
for k, v := range tags {
lower[strings.ToLower(k)] = v
}
for _, c := range candidates {
if v, ok := lower[strings.ToLower(c)]; ok && v != "" {
return v
}
}
return ""
}
// splitMulti splits a multi-value tag on the conventional MusicBrainz
// Picard separator "; ". It deliberately does not split on "/" or "&",
// which commonly appear inside single artist/band names (e.g. "AC/DC").
func splitMulti(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ";")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func parseContributors(tags map[string]string) []Contributor {
roles := []string{"producer", "engineer", "mixer", "remixer", "arranger", "lyricist", "writer"}
var out []Contributor
for _, role := range roles {
v := getTag(tags, role)
if v == "" {
continue
}
for _, name := range splitMulti(v) {
out = append(out, Contributor{Name: name, Role: role})
}
}
return out
}
// parseNumOfTotal parses "3", "3/12", or "3 of 12" style track/disc tags.
func parseNumOfTotal(s string) (num, total int) {
s = strings.TrimSpace(s)
if s == "" {
return 0, 0
}
sep := "/"
if strings.Contains(s, " of ") {
sep = " of "
}
parts := strings.SplitN(s, sep, 2)
num = atoiOr(strings.TrimSpace(parts[0]), 0)
if len(parts) == 2 {
total = atoiOr(strings.TrimSpace(parts[1]), 0)
}
return num, total
}
// parseYear extracts a 4-digit year from the start of a date string like
// "2004-05-11" or "2004".
func parseYear(s string) int {
s = strings.TrimSpace(s)
if len(s) < 4 {
return 0
}
return atoiOr(s[:4], 0)
}
// parseGainDB parses a ReplayGain string like "-6.30 dB" into a plain dB
// float.
func parseGainDB(s string) *float64 {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, "dB")
s = strings.TrimSuffix(s, "DB")
return parseFloatPtr(strings.TrimSpace(s))
}
func parseFloatPtr(s string) *float64 {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
return &f
}
func atoiOr(s string, def int) int {
s = strings.TrimSpace(s)
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
func atofOr(s string, def float64) float64 {
s = strings.TrimSpace(s)
if s == "" {
return def
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return def
}
return f
}
// normalizeFormatName picks a short, stable format label from ffprobe's
// often multi-valued format_name (e.g. "mov,mp4,m4a,3gp,3g2,mj2").
func normalizeFormatName(formatName string) string {
if formatName == "" {
return ""
}
return strings.Split(formatName, ",")[0]
}
+149
View File
@@ -0,0 +1,149 @@
package metadata
import "testing"
func TestParseFFprobeOutputBasicTags(t *testing.T) {
raw := ffprobeOutput{
Format: ffprobeFormat{
FormatName: "mp3",
Duration: "215.5",
BitRate: "320000",
Tags: map[string]string{
"title": "Test Song",
"album": "Test Album",
"album_artist": "Album Artist",
"artist": "Artist One; Artist Two",
"genre": "Rock; Alternative",
"date": "2004-05-11",
"track": "3/12",
"disc": "1/2",
"composer": "Some Composer",
"isrc": "USRC17607839",
"producer": "Rick Rubin",
},
},
Streams: []ffprobeStream{
{Index: 0, CodecType: "audio", SampleRate: "44100", Channels: 2, BitRate: "320000"},
},
}
tags := parseFFprobeOutput(raw)
if tags.Title != "Test Song" {
t.Errorf("Title = %q", tags.Title)
}
if tags.Album != "Test Album" {
t.Errorf("Album = %q", tags.Album)
}
if tags.AlbumArtist != "Album Artist" {
t.Errorf("AlbumArtist = %q", tags.AlbumArtist)
}
if len(tags.Artists) != 2 || tags.Artists[0] != "Artist One" || tags.Artists[1] != "Artist Two" {
t.Errorf("Artists = %v", tags.Artists)
}
if len(tags.Genres) != 2 || tags.Genres[0] != "Rock" || tags.Genres[1] != "Alternative" {
t.Errorf("Genres = %v", tags.Genres)
}
if tags.Year != 2004 {
t.Errorf("Year = %d, want 2004", tags.Year)
}
if tags.TrackNumber != 3 || tags.TrackTotal != 12 {
t.Errorf("Track = %d/%d, want 3/12", tags.TrackNumber, tags.TrackTotal)
}
if tags.DiscNumber != 1 || tags.DiscTotal != 2 {
t.Errorf("Disc = %d/%d, want 1/2", tags.DiscNumber, tags.DiscTotal)
}
if tags.ISRC != "USRC17607839" {
t.Errorf("ISRC = %q", tags.ISRC)
}
if len(tags.Contributors) != 1 || tags.Contributors[0].Name != "Rick Rubin" || tags.Contributors[0].Role != "producer" {
t.Errorf("Contributors = %v", tags.Contributors)
}
if tags.DurationSeconds != 215.5 {
t.Errorf("DurationSeconds = %v", tags.DurationSeconds)
}
if tags.Format != "mp3" {
t.Errorf("Format = %q", tags.Format)
}
if tags.SampleRateHz != 44100 || tags.Channels != 2 {
t.Errorf("SampleRateHz/Channels = %d/%d", tags.SampleRateHz, tags.Channels)
}
if tags.BitrateKbps != 320 {
t.Errorf("BitrateKbps = %d, want 320", tags.BitrateKbps)
}
if tags.Kind != "song" {
t.Errorf("Kind = %q, want song", tags.Kind)
}
}
func TestParseFFprobeOutputArtistWithSlashNotSplit(t *testing.T) {
raw := ffprobeOutput{Format: ffprobeFormat{Tags: map[string]string{"artist": "AC/DC"}}}
tags := parseFFprobeOutput(raw)
if len(tags.Artists) != 1 || tags.Artists[0] != "AC/DC" {
t.Errorf("Artists = %v, want [\"AC/DC\"] (must not split band names on '/')", tags.Artists)
}
}
func TestParseFFprobeOutputEmbeddedArt(t *testing.T) {
raw := ffprobeOutput{
Format: ffprobeFormat{Tags: map[string]string{"title": "Song"}},
Streams: []ffprobeStream{
{Index: 0, CodecType: "audio"},
{Index: 1, CodecType: "video", CodecName: "mjpeg", Disposition: map[string]int{"attached_pic": 1}},
},
}
tags := parseFFprobeOutput(raw)
if !tags.HasEmbeddedArt {
t.Error("HasEmbeddedArt = false, want true")
}
if tags.EmbeddedArtStream != 1 {
t.Errorf("EmbeddedArtStream = %d, want 1", tags.EmbeddedArtStream)
}
if tags.Kind != "song" {
t.Errorf("Kind = %q, want song (attached_pic stream should not make this a video)", tags.Kind)
}
}
func TestParseFFprobeOutputRealVideoStream(t *testing.T) {
raw := ffprobeOutput{
Format: ffprobeFormat{Tags: map[string]string{"title": "Music Video"}},
Streams: []ffprobeStream{
{Index: 0, CodecType: "video", CodecName: "h264"},
{Index: 1, CodecType: "audio"},
},
}
tags := parseFFprobeOutput(raw)
if tags.Kind != "video" {
t.Errorf("Kind = %q, want video", tags.Kind)
}
}
func TestParseFFprobeOutputReplayGain(t *testing.T) {
raw := ffprobeOutput{Format: ffprobeFormat{Tags: map[string]string{
"replaygain_track_gain": "-6.30 dB",
"replaygain_track_peak": "0.987654",
}}}
tags := parseFFprobeOutput(raw)
if tags.ReplayGainTrackGainDB == nil || *tags.ReplayGainTrackGainDB != -6.30 {
t.Errorf("ReplayGainTrackGainDB = %v, want -6.30", tags.ReplayGainTrackGainDB)
}
if tags.ReplayGainTrackPeak == nil || *tags.ReplayGainTrackPeak != 0.987654 {
t.Errorf("ReplayGainTrackPeak = %v, want 0.987654", tags.ReplayGainTrackPeak)
}
}
func TestParseFFprobeOutputCaseInsensitiveTags(t *testing.T) {
raw := ffprobeOutput{Format: ffprobeFormat{Tags: map[string]string{"ALBUMARTIST": "Vorbis Style Artist"}}}
tags := parseFFprobeOutput(raw)
if tags.AlbumArtist != "Vorbis Style Artist" {
t.Errorf("AlbumArtist = %q, want case-insensitive match", tags.AlbumArtist)
}
}
func TestParseFFprobeOutputNoTags(t *testing.T) {
raw := ffprobeOutput{Format: ffprobeFormat{Duration: "10"}}
tags := parseFFprobeOutput(raw)
if tags.Title != "" || tags.Artists != nil {
t.Errorf("expected empty tags, got %+v", tags)
}
}
+463
View File
@@ -0,0 +1,463 @@
-- VibeSonic initial schema.
--
-- IDs are opaque strings assigned by internal/idgen (see that package for
-- why) and are stable across restarts and rescans. Timestamps are ISO-8601
-- UTC strings. Booleans are INTEGER 0/1.
-- ---------------------------------------------------------------------
-- Auth
-- ---------------------------------------------------------------------
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
password_hash TEXT, -- NULL for external-auth-only users
is_admin INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
external_auth INTEGER NOT NULL DEFAULT 0, -- created via trusted-proxy header auth
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_login_at TEXT
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
key_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_used_at TEXT,
revoked_at TEXT
);
CREATE INDEX idx_api_keys_user ON api_keys(user_id);
-- ---------------------------------------------------------------------
-- Library / scanning
-- ---------------------------------------------------------------------
CREATE TABLE music_folders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL DEFAULT 'music' CHECK (kind IN ('music','audiobooks','podcasts','video')),
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE media_files (
id TEXT PRIMARY KEY,
music_folder_id TEXT NOT NULL REFERENCES music_folders(id) ON DELETE CASCADE,
relative_path TEXT NOT NULL, -- path within the music folder; the stable natural key for rescans
size_bytes INTEGER NOT NULL DEFAULT 0,
mtime_unix INTEGER NOT NULL DEFAULT 0,
content_hash TEXT NOT NULL DEFAULT '', -- cheap fingerprint (size+mtime or partial hash), used for change/rename detection
format TEXT NOT NULL DEFAULT '', -- container/codec, e.g. "flac", "mp3"
kind TEXT NOT NULL DEFAULT 'song' CHECK (kind IN ('song','video')),
duration_seconds REAL NOT NULL DEFAULT 0,
bitrate_kbps INTEGER NOT NULL DEFAULT 0,
sample_rate_hz INTEGER NOT NULL DEFAULT 0,
channels INTEGER NOT NULL DEFAULT 0,
missing INTEGER NOT NULL DEFAULT 0, -- set when the file disappeared from disk but the row is kept (e.g. it has stars/playlists)
first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_scanned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE (music_folder_id, relative_path)
);
CREATE INDEX idx_media_files_folder ON media_files(music_folder_id);
CREATE INDEX idx_media_files_hash ON media_files(content_hash);
CREATE INDEX idx_media_files_missing ON media_files(missing);
CREATE TABLE scan_state (
id TEXT PRIMARY KEY,
music_folder_id TEXT NOT NULL UNIQUE REFERENCES music_folders(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'idle' CHECK (status IN ('idle','scanning','error')),
started_at TEXT,
finished_at TEXT,
files_seen INTEGER NOT NULL DEFAULT 0,
files_total_estimate INTEGER NOT NULL DEFAULT 0,
files_added INTEGER NOT NULL DEFAULT 0,
files_updated INTEGER NOT NULL DEFAULT 0,
files_removed INTEGER NOT NULL DEFAULT 0,
error_message TEXT NOT NULL DEFAULT '',
last_full_scan_at TEXT
);
-- ---------------------------------------------------------------------
-- Tags / ID3 browsing model
-- ---------------------------------------------------------------------
CREATE TABLE artists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
sort_name TEXT NOT NULL DEFAULT '',
musicbrainz_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE (name, musicbrainz_id)
);
CREATE INDEX idx_artists_name ON artists(name COLLATE NOCASE);
CREATE TABLE genres (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE albums (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
sort_title TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
original_release_date TEXT NOT NULL DEFAULT '',
year INTEGER,
original_year INTEGER,
record_label TEXT NOT NULL DEFAULT '',
musicbrainz_id TEXT NOT NULL DEFAULT '',
primary_album_artist_id TEXT REFERENCES artists(id) ON DELETE SET NULL, -- denormalized for fast "by artist" grouping; album_artists below is authoritative
cover_art_id TEXT REFERENCES cover_art(id) ON DELETE SET NULL, -- forward reference; SQLite resolves FKs at DML time, not CREATE TABLE time
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE (title, primary_album_artist_id)
);
CREATE INDEX idx_albums_title ON albums(title COLLATE NOCASE);
CREATE INDEX idx_albums_artist ON albums(primary_album_artist_id);
CREATE TABLE album_artists (
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
artist_id TEXT NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (album_id, artist_id)
);
CREATE TABLE album_genres (
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
genre_id TEXT NOT NULL REFERENCES genres(id) ON DELETE CASCADE,
PRIMARY KEY (album_id, genre_id)
);
CREATE TABLE songs (
id TEXT PRIMARY KEY,
media_file_id TEXT NOT NULL UNIQUE REFERENCES media_files(id) ON DELETE CASCADE,
album_id TEXT REFERENCES albums(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT '',
sort_title TEXT NOT NULL DEFAULT '',
track_number INTEGER,
track_total INTEGER,
disc_number INTEGER,
disc_total INTEGER,
disc_subtitle TEXT NOT NULL DEFAULT '',
composer TEXT NOT NULL DEFAULT '',
conductor TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
isrc TEXT NOT NULL DEFAULT '',
bpm INTEGER,
musicbrainz_track_id TEXT NOT NULL DEFAULT '',
musicbrainz_recording_id TEXT NOT NULL DEFAULT '',
replaygain_track_gain_db REAL,
replaygain_track_peak REAL,
replaygain_album_gain_db REAL,
replaygain_album_peak REAL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_songs_album ON songs(album_id);
CREATE INDEX idx_songs_title ON songs(title COLLATE NOCASE);
-- Main performer credits (primary artist, featured artists). role
-- distinguishes e.g. 'artist' vs 'featured'.
CREATE TABLE song_artists (
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
artist_id TEXT NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'artist',
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (song_id, artist_id, role)
);
CREATE INDEX idx_song_artists_artist ON song_artists(artist_id);
-- Non-performer credits (producer, engineer, remixer, arranger, ...).
CREATE TABLE contributors (
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
artist_id TEXT NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
role TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (song_id, artist_id, role)
);
CREATE TABLE song_genres (
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
genre_id TEXT NOT NULL REFERENCES genres(id) ON DELETE CASCADE,
PRIMARY KEY (song_id, genre_id)
);
CREATE INDEX idx_song_genres_genre ON song_genres(genre_id);
-- ---------------------------------------------------------------------
-- Cover art
-- ---------------------------------------------------------------------
CREATE TABLE cover_art (
id TEXT PRIMARY KEY,
album_id TEXT REFERENCES albums(id) ON DELETE CASCADE,
media_file_id TEXT REFERENCES media_files(id) ON DELETE CASCADE,
source TEXT NOT NULL CHECK (source IN ('embedded','folder','external')),
source_path TEXT NOT NULL DEFAULT '', -- absolute path (folder image) or empty (embedded, read via ffprobe on demand)
mime_type TEXT NOT NULL DEFAULT '',
content_hash TEXT NOT NULL DEFAULT '', -- also used as the on-disk resized-cache key
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
CHECK ((album_id IS NOT NULL) OR (media_file_id IS NOT NULL))
);
CREATE INDEX idx_cover_art_album ON cover_art(album_id);
CREATE INDEX idx_cover_art_media_file ON cover_art(media_file_id);
-- ---------------------------------------------------------------------
-- Lyrics
-- ---------------------------------------------------------------------
CREATE TABLE lyrics (
id TEXT PRIMARY KEY,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
language TEXT NOT NULL DEFAULT 'xxx',
source TEXT NOT NULL DEFAULT 'embedded' CHECK (source IN ('embedded','external')),
synced INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_lyrics_song ON lyrics(song_id);
CREATE TABLE lyrics_lines (
id TEXT PRIMARY KEY,
lyrics_id TEXT NOT NULL REFERENCES lyrics(id) ON DELETE CASCADE,
line_index INTEGER NOT NULL,
text TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_lyrics_lines_lyrics ON lyrics_lines(lyrics_id, line_index);
CREATE TABLE lyrics_cues (
id TEXT PRIMARY KEY,
lyrics_id TEXT NOT NULL REFERENCES lyrics(id) ON DELETE CASCADE,
line_index INTEGER NOT NULL,
start_ms INTEGER NOT NULL,
text TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_lyrics_cues_lyrics ON lyrics_cues(lyrics_id, start_ms);
-- ---------------------------------------------------------------------
-- Playlists
-- ---------------------------------------------------------------------
CREATE TABLE playlists (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
is_public INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_playlists_owner ON playlists(owner_id);
CREATE TABLE playlist_entries (
id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE (playlist_id, position)
);
CREATE INDEX idx_playlist_entries_playlist ON playlist_entries(playlist_id);
-- ---------------------------------------------------------------------
-- Favorites / ratings / listening activity
-- ---------------------------------------------------------------------
CREATE TABLE stars (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_type TEXT NOT NULL CHECK (item_type IN ('song','album','artist')),
item_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE (user_id, item_type, item_id)
);
CREATE INDEX idx_stars_user ON stars(user_id, item_type);
CREATE TABLE ratings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_type TEXT NOT NULL CHECK (item_type IN ('song','album','artist')),
item_id TEXT NOT NULL,
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE (user_id, item_type, item_id)
);
CREATE TABLE scrobbles (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
played_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
client TEXT NOT NULL DEFAULT '',
submitted_to_lastfm INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_scrobbles_user ON scrobbles(user_id, played_at);
CREATE TABLE playback_history (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
played_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
play_duration_seconds REAL NOT NULL DEFAULT 0
);
CREATE INDEX idx_playback_history_user ON playback_history(user_id, played_at);
CREATE TABLE now_playing (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
client TEXT NOT NULL DEFAULT '',
position_seconds REAL NOT NULL DEFAULT 0
);
CREATE TABLE bookmarks (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
position_ms INTEGER NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE (user_id, song_id)
);
CREATE TABLE play_queues (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
song_ids_json TEXT NOT NULL DEFAULT '[]', -- ordered list of song IDs; genuinely variable-length, JSON is appropriate here
current_index INTEGER NOT NULL DEFAULT 0,
position_ms INTEGER NOT NULL DEFAULT 0,
client TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- ---------------------------------------------------------------------
-- Sharing
-- ---------------------------------------------------------------------
CREATE TABLE shares (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_visited_at TEXT,
visit_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_shares_owner ON shares(owner_id);
CREATE TABLE share_items (
id TEXT PRIMARY KEY,
share_id TEXT NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
song_id TEXT REFERENCES songs(id) ON DELETE CASCADE,
album_id TEXT REFERENCES albums(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
CHECK ((song_id IS NOT NULL) OR (album_id IS NOT NULL))
);
CREATE INDEX idx_share_items_share ON share_items(share_id);
-- ---------------------------------------------------------------------
-- Podcasts
-- ---------------------------------------------------------------------
CREATE TABLE podcast_channels (
id TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
cover_art_id TEXT REFERENCES cover_art(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','downloading','completed','error')),
error_message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE podcast_episodes (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES podcast_channels(id) ON DELETE CASCADE,
guid TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
publish_date TEXT NOT NULL DEFAULT '',
duration_seconds REAL NOT NULL DEFAULT 0,
enclosure_url TEXT NOT NULL DEFAULT '',
media_file_id TEXT REFERENCES media_files(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new','downloading','completed','error','skipped')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE (channel_id, guid)
);
CREATE INDEX idx_podcast_episodes_channel ON podcast_episodes(channel_id);
-- ---------------------------------------------------------------------
-- Internet radio
-- ---------------------------------------------------------------------
CREATE TABLE internet_radio_stations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
stream_url TEXT NOT NULL,
homepage_url TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- ---------------------------------------------------------------------
-- Chat (Subsonic's simple shared chat room)
-- ---------------------------------------------------------------------
CREATE TABLE chat_messages (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_chat_messages_created ON chat_messages(created_at);
-- ---------------------------------------------------------------------
-- Transcoding
-- ---------------------------------------------------------------------
CREATE TABLE transcode_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
target_format TEXT NOT NULL,
target_bitrate_kbps INTEGER NOT NULL DEFAULT 0,
command_template TEXT NOT NULL DEFAULT '', -- ffmpeg argument template; empty means use the built-in default for target_format
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- ---------------------------------------------------------------------
-- Analysis (future audio fingerprinting / recommendations)
-- ---------------------------------------------------------------------
CREATE TABLE audio_features (
song_id TEXT PRIMARY KEY REFERENCES songs(id) ON DELETE CASCADE,
tempo_bpm REAL,
musical_key TEXT NOT NULL DEFAULT '',
loudness_lufs REAL,
extra_json TEXT NOT NULL DEFAULT '{}', -- forward-compatible bucket for analyzer-specific fields
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- ---------------------------------------------------------------------
-- Settings
-- ---------------------------------------------------------------------
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
+9
View File
@@ -0,0 +1,9 @@
// Package migrations embeds VibeSonic's SQL schema migrations so
// internal/database can apply them without relying on files being present
// on disk at runtime.
package migrations
import "embed"
//go:embed *.sql
var FS embed.FS
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long