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