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
+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)
}
}