update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled

This commit is contained in:
2025-12-08 16:16:23 +01:00
commit c251f174ed
1349 changed files with 194301 additions and 0 deletions

371
server/auth.go Normal file
View File

@@ -0,0 +1,371 @@
package server
import (
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"slices"
"strings"
"time"
"github.com/deluan/rest"
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/gravatar"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var (
ErrNoUsers = errors.New("no users created")
ErrUnauthenticated = errors.New("request not authenticated")
)
func login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
username, password, err := getCredentialsFromBody(r)
if err != nil {
log.Error(r, "Parsing request body", err)
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
return
}
doLogin(ds, username, password, w, r)
}
}
func doLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) {
user, err := validateLogin(ds.User(r.Context()), username, password)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
return
}
if user == nil {
log.Warn(r, "Unsuccessful login", "username", username, "request", r.Header)
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Invalid username or password")
return
}
tokenString, err := auth.CreateToken(user)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
return
}
payload := buildAuthPayload(user)
payload["token"] = tokenString
_ = rest.RespondWithJSON(w, http.StatusOK, payload)
}
func buildAuthPayload(user *model.User) map[string]interface{} {
payload := map[string]interface{}{
"id": user.ID,
"name": user.Name,
"username": user.UserName,
"isAdmin": user.IsAdmin,
}
if conf.Server.EnableGravatar && user.Email != "" {
payload["avatar"] = gravatar.Url(user.Email, 50)
}
bytes := make([]byte, 3)
_, err := rand.Read(bytes)
if err != nil {
log.Error("Could not create subsonic salt", "user", user.UserName, err)
return payload
}
subsonicSalt := hex.EncodeToString(bytes)
payload["subsonicSalt"] = subsonicSalt
subsonicToken := md5.Sum([]byte(user.Password + subsonicSalt))
payload["subsonicToken"] = hex.EncodeToString(subsonicToken[:])
return payload
}
func getCredentialsFromBody(r *http.Request) (username string, password string, err error) {
data := make(map[string]string)
decoder := json.NewDecoder(r.Body)
if err = decoder.Decode(&data); err != nil {
log.Error(r, "parsing request body", err)
err = errors.New("invalid request payload")
return
}
username = data["username"]
password = data["password"]
return username, password, nil
}
func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
username, password, err := getCredentialsFromBody(r)
if err != nil {
log.Error(r, "parsing request body", err)
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
return
}
c, err := ds.User(r.Context()).CountAll()
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
if c > 0 {
_ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin")
return
}
err = createAdminUser(r.Context(), ds, username, password)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
doLogin(ds, username, password, w, r)
}
}
func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error {
log.Warn(ctx, "Creating initial user", "user", username)
now := time.Now()
caser := cases.Title(language.Und)
initialUser := model.User{
ID: id.NewRandom(),
UserName: username,
Name: caser.String(username),
Email: "",
NewPassword: password,
IsAdmin: true,
LastLoginAt: &now,
}
err := ds.User(ctx).Put(&initialUser)
if err != nil {
log.Error(ctx, "Could not create initial user", "user", initialUser, err)
}
return nil
}
func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) {
u, err := userRepo.FindByUsernameWithPassword(userName)
if errors.Is(err, model.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if u.Password != password {
return nil, nil
}
err = userRepo.UpdateLastLoginAt(u.ID)
if err != nil {
log.Error("Could not update LastLoginAt", "user", userName)
}
return u, nil
}
func JWTVerifier(next http.Handler) http.Handler {
return jwtauth.Verify(auth.TokenAuth, tokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next)
}
func tokenFromHeader(r *http.Request) string {
// Get token from authorization header.
bearer := r.Header.Get(consts.UIAuthorizationHeader)
if len(bearer) > 7 && strings.ToUpper(bearer[0:6]) == "BEARER" {
return bearer[7:]
}
return ""
}
func UsernameFromToken(r *http.Request) string {
token, claims, err := jwtauth.FromContext(r.Context())
if err != nil || claims["sub"] == nil || token == nil {
return ""
}
log.Trace(r, "Found username in JWT token", "username", token.Subject())
return token.Subject()
}
func UsernameFromExtAuthHeader(r *http.Request) string {
if conf.Server.ExtAuth.TrustedSources == "" {
return ""
}
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
if !ok {
log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
return ""
}
if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return ""
}
username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" {
return ""
}
log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
return username
}
func InternalAuth(r *http.Request) string {
username, ok := request.InternalAuthFrom(r.Context())
if !ok {
return ""
}
log.Trace(r, "Found username in InternalAuth", "username", username)
return username
}
func UsernameFromConfig(*http.Request) string {
return conf.Server.DevAutoLoginUsername
}
func contextWithUser(ctx context.Context, ds model.DataStore, username string) (context.Context, error) {
user, err := ds.User(ctx).FindByUsername(username)
if err == nil {
ctx = log.NewContext(ctx, "username", username)
ctx = request.WithUsername(ctx, user.UserName)
return request.WithUser(ctx, *user), nil
}
log.Error(ctx, "Authenticated username not found in DB", "username", username)
return ctx, err
}
func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ...func(r *http.Request) string) (context.Context, error) {
var username string
for _, fn := range findUsernameFns {
username = fn(r)
if username != "" {
break
}
}
if username == "" {
return nil, ErrUnauthenticated
}
return contextWithUser(r.Context(), ds, username)
}
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
if err != nil {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// JWTRefresher updates the expiry date of the received JWT token, and add the new one to the Authorization Header
func JWTRefresher(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token, _, err := jwtauth.FromContext(ctx)
if err != nil {
next.ServeHTTP(w, r)
return
}
newTokenString, err := auth.TouchToken(token)
if err != nil {
log.Error(r, "Could not sign new token", err)
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
}
w.Header().Set(consts.UIAuthorizationHeader, newTokenString)
next.ServeHTTP(w, r)
})
}
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
username := UsernameFromConfig(r)
if username == "" {
username = UsernameFromExtAuthHeader(r)
if username == "" {
return nil
}
}
userRepo := ds.User(r.Context())
user, err := userRepo.FindByUsernameWithPassword(username)
if user == nil || err != nil {
log.Info(r, "User passed in header not found", "user", username)
// Check if this is the first user being created
count, _ := userRepo.CountAll()
isFirstUser := count == 0
newUser := model.User{
ID: id.NewRandom(),
UserName: username,
Name: username,
Email: "",
NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(),
IsAdmin: isFirstUser, // Make the first user an admin
}
err := userRepo.Put(&newUser)
if err != nil {
log.Error(r, "Could not create new user", "user", username, err)
return nil
}
user, err = userRepo.FindByUsernameWithPassword(username)
if user == nil || err != nil {
log.Error(r, "Created user but failed to fetch it", "user", username)
return nil
}
}
err = userRepo.UpdateLastLoginAt(user.ID)
if err != nil {
log.Error(r, "Could not update LastLoginAt", "user", username, err)
return nil
}
return buildAuthPayload(user)
}
func validateIPAgainstList(ip string, comaSeparatedList string) bool {
if comaSeparatedList == "" || ip == "" {
return false
}
cidrs := strings.Split(comaSeparatedList, ",")
// Per https://github.com/golang/go/issues/49825, the remote address
// on a unix socket is '@'
if ip == "@" && strings.HasPrefix(conf.Server.Address, "unix:") {
return slices.Contains(cidrs, "@")
}
if net.ParseIP(ip) == nil {
ip, _, _ = net.SplitHostPort(ip)
}
if ip == "" {
return false
}
testedIP, _, err := net.ParseCIDR(fmt.Sprintf("%s/32", ip))
if err != nil {
return false
}
for _, cidr := range cidrs {
_, ipnet, err := net.ParseCIDR(cidr)
if err == nil && ipnet.Contains(testedIP) {
return true
}
}
return false
}

345
server/auth_test.go Normal file
View File

@@ -0,0 +1,345 @@
package server
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Auth", func() {
Describe("User login", func() {
var ds model.DataStore
var req *http.Request
var resp *httptest.ResponseRecorder
BeforeEach(func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
})
Describe("createAdmin", func() {
var createdAt time.Time
BeforeEach(func() {
req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"johndoe", "password":"secret"}`))
resp = httptest.NewRecorder()
createdAt = time.Now()
createAdmin(ds)(resp, req)
})
It("creates an admin user with the specified password", func() {
usr := ds.User(context.Background())
u, err := usr.FindByUsername("johndoe")
Expect(err).To(BeNil())
Expect(u.Password).ToNot(BeEmpty())
Expect(u.IsAdmin).To(BeTrue())
Expect(*u.LastLoginAt).To(BeTemporally(">=", createdAt, time.Second))
})
It("returns the expected payload", func() {
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["isAdmin"]).To(Equal(true))
Expect(parsed["username"]).To(Equal("johndoe"))
Expect(parsed["name"]).To(Equal("Johndoe"))
Expect(parsed["id"]).ToNot(BeEmpty())
Expect(parsed["token"]).ToNot(BeEmpty())
})
})
Describe("Login from HTTP headers", func() {
const (
trustedIpv4 = "192.168.0.42"
untrustedIpv4 = "8.8.8.8"
trustedIpv6 = "2001:4860:4860:1234:5678:0000:4242:8888"
untrustedIpv6 = "5005:0:3003"
)
fs := os.DirFS("tests/fixtures")
BeforeEach(func() {
usr := ds.User(context.Background())
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
req = httptest.NewRequest("GET", "/index.html", nil)
req.Header.Add("Remote-User", "janedoe")
resp = httptest.NewRecorder()
conf.Server.UILoginBackgroundURL = ""
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48"
})
It("sets auth data if IPv4 matches whitelist", func() {
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv4))
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
parsed := config["auth"].(map[string]interface{})
Expect(parsed["id"]).To(Equal("111"))
})
It("sets no auth data if IPv4 does not match whitelist", func() {
req = req.WithContext(request.WithReverseProxyIp(req.Context(), untrustedIpv4))
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
Expect(config["auth"]).To(BeNil())
})
It("sets auth data if IPv6 matches whitelist", func() {
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv6))
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
parsed := config["auth"].(map[string]interface{})
Expect(parsed["id"]).To(Equal("111"))
})
It("sets no auth data if IPv6 does not match whitelist", func() {
req = req.WithContext(request.WithReverseProxyIp(req.Context(), untrustedIpv6))
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
Expect(config["auth"]).To(BeNil())
})
It("creates user and sets auth data if user does not exist", func() {
newUser := "NEW_USER_" + id.NewRandom()
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv4))
req.Header.Set("Remote-User", newUser)
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
parsed := config["auth"].(map[string]interface{})
Expect(parsed["username"]).To(Equal(newUser))
})
It("sets auth data if user exists", func() {
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv4))
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
parsed := config["auth"].(map[string]interface{})
Expect(parsed["id"]).To(Equal("111"))
Expect(parsed["isAdmin"]).To(BeFalse())
Expect(parsed["name"]).To(Equal("Jane"))
Expect(parsed["username"]).To(Equal("janedoe"))
Expect(parsed["subsonicSalt"]).ToNot(BeEmpty())
Expect(parsed["subsonicToken"]).ToNot(BeEmpty())
salt := parsed["subsonicSalt"].(string)
token := fmt.Sprintf("%x", md5.Sum([]byte("abc123"+salt)))
Expect(parsed["subsonicToken"]).To(Equal(token))
// Request Header authentication should not generate a JWT token
Expect(parsed).ToNot(HaveKey("token"))
})
It("does not set auth data when listening on unix socket without whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ExtAuth.TrustedSources = ""
// No ReverseProxyIp in request context
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
Expect(config["auth"]).To(BeNil())
})
It("does not set auth data when listening on unix socket with incorrect whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
Expect(config["auth"]).To(BeNil())
})
It("sets auth data when listening on unix socket with correct whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@"
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
serveIndex(ds, fs, nil)(resp, req)
config := extractAppConfig(resp.Body.String())
parsed := config["auth"].(map[string]interface{})
Expect(parsed["id"]).To(Equal("111"))
})
})
Describe("login", func() {
BeforeEach(func() {
req = httptest.NewRequest("POST", "/login", strings.NewReader(`{"username":"janedoe", "password":"abc123"}`))
resp = httptest.NewRecorder()
})
It("fails if user does not exist", func() {
login(ds)(resp, req)
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
})
It("logs in successfully if user exists", func() {
usr := ds.User(context.Background())
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
login(ds)(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["isAdmin"]).To(Equal(false))
Expect(parsed["username"]).To(Equal("janedoe"))
Expect(parsed["name"]).To(Equal("Jane"))
Expect(parsed["id"]).ToNot(BeEmpty())
Expect(parsed["token"]).ToNot(BeEmpty())
})
})
})
Describe("tokenFromHeader", func() {
It("returns the token when the Authorization header is set correctly", func() {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer testtoken")
token := tokenFromHeader(req)
Expect(token).To(Equal("testtoken"))
})
It("returns an empty string when the Authorization header is not set", func() {
req := httptest.NewRequest("GET", "/", nil)
token := tokenFromHeader(req)
Expect(token).To(BeEmpty())
})
It("returns an empty string when the Authorization header is not a Bearer token", func() {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Basic testtoken")
token := tokenFromHeader(req)
Expect(token).To(BeEmpty())
})
It("returns an empty string when the Bearer token is too short", func() {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer")
token := tokenFromHeader(req)
Expect(token).To(BeEmpty())
})
})
Describe("validateIPAgainstList", func() {
Context("when provided with empty inputs", func() {
It("should return false", func() {
Expect(validateIPAgainstList("", "")).To(BeFalse())
Expect(validateIPAgainstList("192.168.1.1", "")).To(BeFalse())
Expect(validateIPAgainstList("", "192.168.0.0/16")).To(BeFalse())
})
})
Context("when provided with invalid IP inputs", func() {
It("should return false", func() {
Expect(validateIPAgainstList("invalidIP", "192.168.0.0/16")).To(BeFalse())
})
})
Context("when provided with valid inputs", func() {
It("should return true when IP is in the list", func() {
Expect(validateIPAgainstList("192.168.1.1", "192.168.0.0/16,10.0.0.0/8")).To(BeTrue())
Expect(validateIPAgainstList("10.0.0.1", "192.168.0.0/16,10.0.0.0/8")).To(BeTrue())
})
It("should return false when IP is not in the list", func() {
Expect(validateIPAgainstList("172.16.0.1", "192.168.0.0/16,10.0.0.0/8")).To(BeFalse())
})
})
Context("when provided with invalid CIDR notation in the list", func() {
It("should ignore invalid CIDR and return the correct result", func() {
Expect(validateIPAgainstList("192.168.1.1", "192.168.0.0/16,invalidCIDR")).To(BeTrue())
Expect(validateIPAgainstList("10.0.0.1", "invalidCIDR,10.0.0.0/8")).To(BeTrue())
Expect(validateIPAgainstList("172.16.0.1", "192.168.0.0/16,invalidCIDR")).To(BeFalse())
})
})
Context("when provided with IP:port format", func() {
It("should handle IP:port format correctly", func() {
Expect(validateIPAgainstList("192.168.1.1:8080", "192.168.0.0/16,10.0.0.0/8")).To(BeTrue())
Expect(validateIPAgainstList("10.0.0.1:1234", "192.168.0.0/16,10.0.0.0/8")).To(BeTrue())
Expect(validateIPAgainstList("172.16.0.1:9999", "192.168.0.0/16,10.0.0.0/8")).To(BeFalse())
})
})
})
Describe("handleLoginFromHeaders", func() {
var ds model.DataStore
var req *http.Request
const trustedIP = "192.168.0.42"
BeforeEach(func() {
ds = &tests.MockDataStore{}
req = httptest.NewRequest("GET", "/", nil)
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("makes the first user an admin", func() {
// No existing users
req.Header.Set("Remote-User", "firstuser")
result := handleLoginFromHeaders(ds, req)
Expect(result).ToNot(BeNil())
Expect(result["isAdmin"]).To(BeTrue())
// Verify user was created as admin
u, err := ds.User(context.Background()).FindByUsername("firstuser")
Expect(err).To(BeNil())
Expect(u.IsAdmin).To(BeTrue())
})
It("does not make subsequent users admins", func() {
// Create the first user
_ = ds.User(context.Background()).Put(&model.User{
ID: "existing-user-id",
UserName: "existinguser",
Name: "Existing User",
IsAdmin: true,
})
// Try to create a second user via proxy header
req.Header.Set("Remote-User", "seconduser")
result := handleLoginFromHeaders(ds, req)
Expect(result).ToNot(BeNil())
Expect(result["isAdmin"]).To(BeFalse())
// Verify user was created as non-admin
u, err := ds.User(context.Background()).FindByUsername("seconduser")
Expect(err).To(BeNil())
Expect(u.IsAdmin).To(BeFalse())
})
})
})

View File

@@ -0,0 +1,140 @@
package backgrounds
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/random"
"gopkg.in/yaml.v3"
)
const (
//imageHostingUrl = "https://unsplash.com/photos/%s/download?fm=jpg&w=1600&h=900&fit=max"
imageHostingUrl = "https://www.navidrome.org/images/%s.webp"
imageListURL = "https://www.navidrome.org/images/index.yml"
imageListTTL = 24 * time.Hour
imageCacheDir = "backgrounds"
imageCacheSize = "100MB"
imageCacheMaxItems = 1000
imageRequestTimeout = 5 * time.Second
)
type Handler struct {
httpClient *cache.HTTPClient
cache cache.FileCache
}
func NewHandler() *Handler {
h := &Handler{}
h.httpClient = cache.NewHTTPClient(&http.Client{Timeout: 5 * time.Second}, imageListTTL)
h.cache = cache.NewFileCache(imageCacheDir, imageCacheSize, imageCacheDir, imageCacheMaxItems, h.serveImage)
go func() {
_, _ = h.getImageList(log.NewContext(context.Background()))
}()
return h
}
type cacheKey string
func (k cacheKey) Key() string {
return string(k)
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
image, err := h.getRandomImage(r.Context())
if err != nil {
h.serveDefaultImage(w)
return
}
s, err := h.cache.Get(r.Context(), cacheKey(image))
if err != nil {
h.serveDefaultImage(w)
return
}
defer s.Close()
w.Header().Set("content-type", "image/webp")
_, _ = io.Copy(w, s.Reader)
}
func (h *Handler) serveDefaultImage(w http.ResponseWriter) {
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
w.Header().Set("content-type", "image/png")
_, _ = w.Write(defaultImage)
}
func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, error) {
start := time.Now()
image := item.Key()
if image == "" {
return nil, errors.New("empty image name")
}
c := http.Client{Timeout: imageRequestTimeout}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil)
resp, err := c.Do(req) //nolint:bodyclose // No need to close resp.Body, it will be closed via the CachedStream wrapper
if errors.Is(err, context.DeadlineExceeded) {
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
return strings.NewReader(string(defaultImage)), nil
}
if err != nil {
return nil, fmt.Errorf("could not get background image from hosting service: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code getting background image from hosting service: %d", resp.StatusCode)
}
log.Debug(ctx, "Got background image from hosting service", "image", image, "elapsed", time.Since(start))
return resp.Body, nil
}
func (h *Handler) getRandomImage(ctx context.Context) (string, error) {
list, err := h.getImageList(ctx)
if err != nil {
return "", err
}
if len(list) == 0 {
return "", errors.New("no images available")
}
rnd := random.Int64N(len(list))
return list[rnd], nil
}
func (h *Handler) getImageList(ctx context.Context) ([]string, error) {
start := time.Now()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageListURL, nil)
resp, err := h.httpClient.Do(req)
if err != nil {
log.Warn(ctx, "Could not get background images from image service", err)
return nil, err
}
defer resp.Body.Close()
var list []string
dec := yaml.NewDecoder(resp.Body)
err = dec.Decode(&list)
if err != nil {
log.Warn(ctx, "Could not decode background images from image service", err)
return nil, err
}
log.Debug(ctx, "Loaded background images from image service", "total", len(list), "elapsed", time.Since(start))
return list, nil
}
func imageURL(imageName string) string {
// Discard extension
parts := strings.Split(imageName, ".")
if len(parts) > 1 {
imageName = parts[0]
}
return fmt.Sprintf(imageHostingUrl, imageName)
}

89
server/events/events.go Normal file
View File

@@ -0,0 +1,89 @@
package events
import (
"context"
"encoding/json"
"reflect"
"strings"
"time"
"unicode"
)
type eventCtxKey string
const broadcastToAllKey eventCtxKey = "broadcastToAll"
// broadcastToAll is a context key that can be used to broadcast an event to all clients
func broadcastToAll(ctx context.Context) context.Context {
return context.WithValue(ctx, broadcastToAllKey, true)
}
type Event interface {
Name(Event) string
Data(Event) string
}
type baseEvent struct{}
func (e *baseEvent) Name(evt Event) string {
str := strings.TrimPrefix(reflect.TypeOf(evt).String(), "*events.")
return str[:0] + string(unicode.ToLower(rune(str[0]))) + str[1:]
}
func (e *baseEvent) Data(evt Event) string {
data, _ := json.Marshal(evt)
return string(data)
}
type ScanStatus struct {
baseEvent
Scanning bool `json:"scanning"`
Count int64 `json:"count"`
FolderCount int64 `json:"folderCount"`
Error string `json:"error"`
ScanType string `json:"scanType"`
ElapsedTime time.Duration `json:"elapsedTime"`
}
type KeepAlive struct {
baseEvent
TS int64 `json:"ts"`
}
type ServerStart struct {
baseEvent
StartTime time.Time `json:"startTime"`
Version string `json:"version"`
}
const Any = "*"
type RefreshResource struct {
baseEvent
resources map[string][]string
}
type NowPlayingCount struct {
baseEvent
Count int `json:"count"`
}
func (rr *RefreshResource) With(resource string, ids ...string) *RefreshResource {
if rr.resources == nil {
rr.resources = make(map[string][]string)
}
if len(ids) == 0 {
rr.resources[resource] = append(rr.resources[resource], Any)
}
rr.resources[resource] = append(rr.resources[resource], ids...)
return rr
}
func (rr *RefreshResource) Data(evt Event) string {
if rr.resources == nil {
return `{"*":"*"}`
}
r := evt.(*RefreshResource)
data, _ := json.Marshal(r.resources)
return string(data)
}

View File

@@ -0,0 +1,17 @@
package events
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestEvents(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Events Suite")
}

View File

@@ -0,0 +1,46 @@
package events
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Events", func() {
Describe("Event", func() {
type TestEvent struct {
baseEvent
Test string
}
It("marshals Event to JSON", func() {
testEvent := TestEvent{Test: "some data"}
data := testEvent.Data(&testEvent)
Expect(data).To(Equal(`{"Test":"some data"}`))
name := testEvent.Name(&testEvent)
Expect(name).To(Equal("testEvent"))
})
})
Describe("RefreshResource", func() {
var rr *RefreshResource
BeforeEach(func() {
rr = &RefreshResource{}
})
It("should render to full refresh if event is empty", func() {
data := rr.Data(rr)
Expect(data).To(Equal(`{"*":"*"}`))
})
It("should group resources based on name", func() {
rr.With("album", "al-1").With("song", "sg-1").With("artist", "ar-1")
rr.With("album", "al-2", "al-3").With("song", "sg-2").With("artist", "ar-2")
data := rr.Data(rr)
Expect(data).To(Equal(`{"album":["al-1","al-2","al-3"],"artist":["ar-1","ar-2"],"song":["sg-1","sg-2"]}`))
})
It("should send a * when no ids are specified", func() {
rr.With("album")
data := rr.Data(rr)
Expect(data).To(Equal(`{"album":["*"]}`))
})
})
})

291
server/events/sse.go Normal file
View File

@@ -0,0 +1,291 @@
// Package events based on https://thoughtbot.com/blog/writing-a-server-sent-events-server-in-go
package events
import (
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/pl"
"github.com/navidrome/navidrome/utils/singleton"
)
type Broker interface {
http.Handler
SendMessage(ctx context.Context, event Event)
SendBroadcastMessage(ctx context.Context, event Event)
}
const (
keepAliveFrequency = 15 * time.Second
writeTimeOut = 5 * time.Second
bufferSize = 1
)
type (
message struct {
id uint64
event string
data string
senderCtx context.Context
}
messageChan chan message
clientsChan chan client
client struct {
id string
address string
username string
userAgent string
clientUniqueId string
displayString string
msgC chan message
}
)
func (c client) String() string {
return c.displayString
}
type broker struct {
// Events are pushed to this channel by the main events-gathering routine
publish messageChan
// New client connections
subscribing clientsChan
// Closed client connections
unsubscribing clientsChan
}
func GetBroker() Broker {
return singleton.GetInstance(func() *broker {
// Instantiate a broker
broker := &broker{
publish: make(messageChan, 2),
subscribing: make(clientsChan, 1),
unsubscribing: make(clientsChan, 1),
}
// Set it running - listening and broadcasting events
go broker.listen()
return broker
})
}
func (b *broker) SendBroadcastMessage(ctx context.Context, evt Event) {
ctx = broadcastToAll(ctx)
b.SendMessage(ctx, evt)
}
func (b *broker) SendMessage(ctx context.Context, evt Event) {
msg := b.prepareMessage(ctx, evt)
log.Trace("Broker received new event", "type", msg.event, "data", msg.data)
b.publish <- msg
}
func (b *broker) prepareMessage(ctx context.Context, event Event) message {
msg := message{}
msg.data = event.Data(event)
msg.event = event.Name(event)
msg.senderCtx = ctx
return msg
}
// writeEvent writes a message to the given io.Writer, formatted as a Server-Sent Event.
// If the writer is a http.Flusher, it flushes the data immediately instead of buffering it.
func writeEvent(ctx context.Context, w io.Writer, event message, timeout time.Duration) error {
if err := setWriteTimeout(w, timeout); err != nil {
log.Debug(ctx, "Error setting write timeout", err)
}
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data)
if err != nil {
return err
}
// If the writer is a http.Flusher, flush the data immediately.
if flusher, ok := w.(http.Flusher); ok && flusher != nil {
flusher.Flush()
}
return nil
}
func setWriteTimeout(rw io.Writer, timeout time.Duration) error {
for {
switch t := rw.(type) {
case interface{ SetWriteDeadline(time.Time) error }:
return t.SetWriteDeadline(time.Now().Add(timeout))
case interface{ Unwrap() http.ResponseWriter }:
rw = t.Unwrap()
default:
return fmt.Errorf("%T - %w", rw, http.ErrNotSupported)
}
}
}
func (b *broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, _ := request.UserFrom(ctx)
// Make sure that the writer supports flushing.
_, ok := w.(http.Flusher)
if !ok {
log.Error(r, "Streaming unsupported! Events cannot be sent to this client", "address", r.RemoteAddr,
"userAgent", r.UserAgent(), "user", user.UserName)
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
// Tells Nginx to not buffer this response. See https://stackoverflow.com/a/33414096
w.Header().Set("X-Accel-Buffering", "no")
// Each connection registers its own message channel with the Broker's connections registry
c := b.subscribe(r)
defer b.unsubscribe(c)
log.Debug(ctx, "Started new EventStream connection", "client", c.String())
for event := range pl.ReadOrDone(ctx, c.msgC) {
log.Trace(ctx, "Sending event to client", "event", event, "client", c.String())
err := writeEvent(ctx, w, event, writeTimeOut)
if err != nil {
log.Debug(ctx, "Error sending event to client. Closing connection", "event", event, "client", c.String(), err)
return
}
}
log.Trace(ctx, "Client EventStream connection closed", "client", c.String())
}
func (b *broker) subscribe(r *http.Request) client {
ctx := r.Context()
user, _ := request.UserFrom(ctx)
clientUniqueId, _ := request.ClientUniqueIdFrom(ctx)
c := client{
id: id.NewRandom(),
username: user.UserName,
address: r.RemoteAddr,
userAgent: r.UserAgent(),
clientUniqueId: clientUniqueId,
}
if log.IsGreaterOrEqualTo(log.LevelTrace) {
c.displayString = fmt.Sprintf("%s (%s - %s - %s - %s)", c.id, c.username, c.address, c.clientUniqueId, c.userAgent)
} else {
c.displayString = fmt.Sprintf("%s (%s - %s - %s)", c.id, c.username, c.address, c.clientUniqueId)
}
c.msgC = make(chan message, bufferSize)
// Signal the broker that we have a new client
b.subscribing <- c
return c
}
func (b *broker) unsubscribe(c client) {
b.unsubscribing <- c
}
func (b *broker) shouldSend(msg message, c client) bool {
if broadcastToAll, ok := msg.senderCtx.Value(broadcastToAllKey).(bool); ok && broadcastToAll {
return true
}
clientUniqueId, originatedFromClient := request.ClientUniqueIdFrom(msg.senderCtx)
if !originatedFromClient {
return true
}
if c.clientUniqueId == clientUniqueId {
return false
}
if username, ok := request.UsernameFrom(msg.senderCtx); ok {
return username == c.username
}
return true
}
func (b *broker) listen() {
keepAlive := time.NewTicker(keepAliveFrequency)
defer keepAlive.Stop()
clients := map[client]struct{}{}
var eventId uint64
getNextEventId := func() uint64 {
eventId++
return eventId
}
for {
select {
case c := <-b.subscribing:
// A new client has connected.
// Register their message channel
clients[c] = struct{}{}
log.Debug("Client added to EventStream broker", "numActiveClients", len(clients), "newClient", c.String())
// Send a serverStart event to new client
msg := b.prepareMessage(context.Background(),
&ServerStart{StartTime: consts.ServerStart, Version: consts.Version})
sendOrDrop(c, msg)
case c := <-b.unsubscribing:
// A client has detached, and we want to
// stop sending them messages.
close(c.msgC)
delete(clients, c)
log.Debug("Removed client from EventStream broker", "numActiveClients", len(clients), "client", c.String())
case msg := <-b.publish:
msg.id = getNextEventId()
log.Trace("Got new published event", "event", msg)
// We got a new event from the outside!
// Send event to all connected clients
for c := range clients {
if b.shouldSend(msg, c) {
log.Trace("Putting event on client's queue", "client", c.String(), "event", msg)
sendOrDrop(c, msg)
}
}
case ts := <-keepAlive.C:
// Send a keep alive message every 15 seconds to all connected clients
if len(clients) == 0 {
continue
}
msg := b.prepareMessage(context.Background(), &KeepAlive{TS: ts.Unix()})
msg.id = getNextEventId()
for c := range clients {
log.Trace("Putting a keepalive event on client's queue", "client", c.String(), "event", msg)
sendOrDrop(c, msg)
}
}
}
}
func sendOrDrop(client client, msg message) {
select {
case client.msgC <- msg:
default:
if log.IsGreaterOrEqualTo(log.LevelTrace) {
log.Trace("Event dropped because client's channel is full", "event", msg, "client", client.String())
}
}
}
func NoopBroker() Broker {
return noopBroker{}
}
type noopBroker struct {
http.Handler
}
func (b noopBroker) SendBroadcastMessage(context.Context, Event) {}
func (noopBroker) SendMessage(context.Context, Event) {}

61
server/events/sse_test.go Normal file
View File

@@ -0,0 +1,61 @@
package events
import (
"context"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Broker", func() {
var b broker
BeforeEach(func() {
b = broker{}
})
Describe("shouldSend", func() {
var c client
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
c = client{
clientUniqueId: "1111",
username: "janedoe",
}
})
Context("request has clientUniqueId", func() {
It("sends message for same username, different clientUniqueId", func() {
ctx = request.WithClientUniqueId(ctx, "2222")
ctx = request.WithUsername(ctx, "janedoe")
m := message{senderCtx: ctx}
Expect(b.shouldSend(m, c)).To(BeTrue())
})
It("does not send message for same username, same clientUniqueId", func() {
ctx = request.WithClientUniqueId(ctx, "1111")
ctx = request.WithUsername(ctx, "janedoe")
m := message{senderCtx: ctx}
Expect(b.shouldSend(m, c)).To(BeFalse())
})
It("does not send message for different username", func() {
ctx = request.WithClientUniqueId(ctx, "3333")
ctx = request.WithUsername(ctx, "johndoe")
m := message{senderCtx: ctx}
Expect(b.shouldSend(m, c)).To(BeFalse())
})
})
Context("request does not have clientUniqueId", func() {
It("sends message for same username", func() {
ctx = request.WithUsername(ctx, "janedoe")
m := message{senderCtx: ctx}
Expect(b.shouldSend(m, c)).To(BeTrue())
})
It("sends message for different username", func() {
ctx = request.WithUsername(ctx, "johndoe")
m := message{senderCtx: ctx}
Expect(b.shouldSend(m, c)).To(BeTrue())
})
})
})
})

101
server/initial_setup.go Normal file
View File

@@ -0,0 +1,101 @@
package server
import (
"context"
"fmt"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
func initialSetup(ds model.DataStore) {
ctx := context.TODO()
_ = ds.WithTx(func(tx model.DataStore) error {
if err := tx.Library(ctx).StoreMusicFolder(); err != nil {
return err
}
properties := tx.Property(ctx)
_, err := properties.Get(consts.InitialSetupFlagKey)
if err == nil {
return nil
}
log.Info("Running initial setup")
if conf.Server.DevAutoCreateAdminPassword != "" {
if err = createInitialAdminUser(tx, conf.Server.DevAutoCreateAdminPassword); err != nil {
return err
}
}
err = properties.Put(consts.InitialSetupFlagKey, time.Now().String())
return err
}, "initial setup")
}
// If the Dev Admin user is not present, create it
func createInitialAdminUser(ds model.DataStore, initialPassword string) error {
users := ds.User(context.TODO())
c, err := users.CountAll(model.QueryOptions{Filters: squirrel.Eq{"user_name": consts.DevInitialUserName}})
if err != nil {
panic(fmt.Sprintf("Could not access User table: %s", err))
}
if c == 0 {
newID := id.NewRandom()
log.Warn("Creating initial admin user. This should only be used for development purposes!!",
"user", consts.DevInitialUserName, "password", initialPassword, "id", newID)
initialUser := model.User{
ID: newID,
UserName: consts.DevInitialUserName,
Name: consts.DevInitialName,
Email: "",
NewPassword: initialPassword,
IsAdmin: true,
}
err := users.Put(&initialUser)
if err != nil {
log.Error("Could not create initial admin user", "user", initialUser, err)
}
}
return err
}
func checkFFmpegInstallation() {
f := ffmpeg.New()
_, err := f.CmdPath()
if err == nil {
return
}
log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err)
if conf.Server.Scanner.Extractor == "ffmpeg" {
log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib")
conf.Server.Scanner.Extractor = "taglib"
}
}
func checkExternalCredentials() {
if conf.Server.EnableExternalServices {
if !conf.Server.LastFM.Enabled {
log.Info("Last.fm integration is DISABLED")
} else {
log.Debug("Last.fm integration is ENABLED")
}
if !conf.Server.ListenBrainz.Enabled {
log.Info("ListenBrainz integration is DISABLED")
} else {
log.Debug("ListenBrainz integration is ENABLED", "ListenBrainz.BaseURL", conf.Server.ListenBrainz.BaseURL)
}
if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" {
log.Info("Spotify integration is not enabled: missing ID/Secret")
} else {
log.Debug("Spotify integration is ENABLED")
}
}
}

View File

@@ -0,0 +1,36 @@
package server
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("initial_setup", func() {
var ds model.DataStore
BeforeEach(func() {
ds = &tests.MockDataStore{}
})
Describe("createInitialAdminUser", func() {
It("creates a new admin user with specified password if User table is empty", func() {
Expect(createInitialAdminUser(ds, "pass123")).To(BeNil())
ur := ds.User(context.TODO())
admin, err := ur.FindByUsername("admin")
Expect(err).To(BeNil())
Expect(admin.Password).To(Equal("pass123"))
})
It("does not create a new admin user if User table is not empty", func() {
Expect(createInitialAdminUser(ds, "first")).To(BeNil())
ur := ds.User(context.TODO())
Expect(ur.CountAll()).To(Equal(int64(1)))
Expect(createInitialAdminUser(ds, "second")).To(BeNil())
Expect(ur.CountAll()).To(Equal(int64(1)))
})
})
})

329
server/middlewares.go Normal file
View File

@@ -0,0 +1,329 @@
package server
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
"github.com/unrolled/secure"
)
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
status := ww.Status()
message := fmt.Sprintf("HTTP: %s %s://%s%s", r.Method, scheme, r.Host, r.RequestURI)
logArgs := []interface{}{
r.Context(),
message,
"remoteAddr", r.RemoteAddr,
"elapsedTime", time.Since(start),
"httpStatus", ww.Status(),
"responseSize", ww.BytesWritten(),
}
if log.IsGreaterOrEqualTo(log.LevelTrace) {
headers, _ := json.Marshal(r.Header)
logArgs = append(logArgs, "header", string(headers))
} else if log.IsGreaterOrEqualTo(log.LevelDebug) {
logArgs = append(logArgs, "userAgent", r.UserAgent())
}
switch {
case status >= 500:
log.Error(logArgs...)
case status >= 400:
log.Warn(logArgs...)
default:
log.Debug(logArgs...)
}
})
}
func loggerInjector(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = log.NewContext(r.Context(), "requestId", middleware.GetReqID(ctx))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func robotsTXT(fs fs.FS) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/robots.txt") {
r.URL.Path = "/robots.txt"
http.FileServerFS(fs).ServeHTTP(w, r)
} else {
next.ServeHTTP(w, r)
}
})
}
}
func corsHandler() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
http.MethodHead,
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
},
AllowedHeaders: []string{"*"},
AllowCredentials: false,
ExposedHeaders: []string{"x-content-duration", "x-total-count", "x-nd-authorization"},
})
}
func secureMiddleware() func(http.Handler) http.Handler {
sec := secure.New(secure.Options{
ContentTypeNosniff: true,
FrameDeny: true,
ReferrerPolicy: "same-origin",
PermissionsPolicy: "autoplay=(), camera=(), microphone=(), usb=()",
CustomFrameOptionsValue: conf.Server.HTTPSecurityHeaders.CustomFrameOptionsValue,
//ContentSecurityPolicy: "script-src 'self' 'unsafe-inline'",
})
return sec.Handler
}
func compressMiddleware() func(http.Handler) http.Handler {
return middleware.Compress(
5,
"application/xml",
"application/json",
"application/javascript",
"text/html",
"text/plain",
"text/css",
"text/javascript",
"text/event-stream",
)
}
// clientUniqueIDMiddleware is a middleware that sets a unique client ID as a cookie if it's provided in the request header.
// If the unique client ID is not in the header but present as a cookie, it adds the ID to the request context.
func clientUniqueIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
clientUniqueId := r.Header.Get(consts.UIClientUniqueIDHeader)
// If clientUniqueId is found in the header, set it as a cookie
if clientUniqueId != "" {
c := &http.Cookie{
Name: consts.UIClientUniqueIDHeader,
Value: clientUniqueId,
MaxAge: consts.CookieExpiry,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: cmp.Or(conf.Server.BasePath, "/"),
}
http.SetCookie(w, c)
} else {
// If clientUniqueId is not found in the header, check if it's present as a cookie
c, err := r.Cookie(consts.UIClientUniqueIDHeader)
if !errors.Is(err, http.ErrNoCookie) {
clientUniqueId = c.Value
}
}
// If a valid clientUniqueId is found, add it to the request context
if clientUniqueId != "" {
ctx = request.WithClientUniqueId(ctx, clientUniqueId)
r = r.WithContext(ctx)
}
// Call the next middleware or handler in the chain
next.ServeHTTP(w, r)
})
}
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy.
func realIPMiddleware(next http.Handler) http.Handler {
if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP,
).Handler(next)
}
// The middleware is applied without a trusted reverse proxy to support other use-cases such as multiple clients
// behind a caching proxy. In this case, navidrome only uses the request's RemoteAddr for logging, so the security
// impact of reading the headers from untrusted sources is limited.
return middleware.RealIP(next)
}
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
// can only be set once.
func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Context().Value(key) == nil {
ctx := context.WithValue(r.Context(), key, fn(r))
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
}
// serverAddressMiddleware is a middleware function that modifies the request object
// to reflect the address of the server handling the request, as determined by the
// presence of X-Forwarded-* headers or the scheme and host of the request URL.
func serverAddressMiddleware(h http.Handler) http.Handler {
// Define a new handler function that will be returned by this middleware function.
fn := func(w http.ResponseWriter, r *http.Request) {
// Call the serverAddress function to get the scheme and host of the server
// handling the request. If a host is found, modify the request object to use
// that host and scheme instead of the original ones.
if rScheme, rHost := serverAddress(r); rHost != "" {
r.Host = rHost
r.URL.Scheme = rScheme
}
// Call the next handler in the chain with the modified request and response.
h.ServeHTTP(w, r)
}
// Return the new handler function as a http.Handler object.
return http.HandlerFunc(fn)
}
// Define constants for the X-Forwarded-* header keys.
var (
xForwardedHost = http.CanonicalHeaderKey("X-Forwarded-Host")
xForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto")
xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme")
)
// serverAddress is a helper function that returns the scheme and host of the server
// handling the given request, as determined by the presence of X-Forwarded-* headers
// or the scheme and host of the request URL.
func serverAddress(r *http.Request) (scheme, host string) {
// Save the original request host for later comparison.
origHost := r.Host
// Determine the protocol of the request based on the presence of a TLS connection.
protocol := "http"
if r.TLS != nil {
protocol = "https"
}
// Get the X-Forwarded-Host header and extract the first host name if there are
// multiple hosts listed. If there is no X-Forwarded-Host header, use the original
// request host as the default.
xfh := r.Header.Get(xForwardedHost)
if xfh != "" {
i := strings.Index(xfh, ",")
if i == -1 {
i = len(xfh)
}
xfh = xfh[:i]
}
host = cmp.Or(xfh, r.Host)
// Determine the protocol and scheme of the request based on the presence of
// X-Forwarded-* headers or the scheme of the request URL.
scheme = cmp.Or(
r.Header.Get(xForwardedProto),
r.Header.Get(xForwardedScheme),
r.URL.Scheme,
protocol,
)
// If the request host has changed due to the X-Forwarded-Host header, log a trace
// message with the original and new host values, as well as the scheme and URL.
if host != origHost {
log.Trace(r.Context(), "Request host has changed", "origHost", origHost, "host", host, "scheme", scheme, "url", r.URL)
}
// Return the scheme and host of the server handling the request.
return scheme, host
}
// URLParamsMiddleware is a middleware function that decodes the query string of
// the incoming HTTP request, adds the URL parameters from the routing context,
// and re-encodes the modified query string.
func URLParamsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Retrieve the routing context from the request context.
ctx := chi.RouteContext(r.Context())
// Parse the existing query string into a URL values map.
params, _ := url.ParseQuery(r.URL.RawQuery)
// Loop through each URL parameter in the routing context.
for i, key := range ctx.URLParams.Keys {
// Skip any wildcard URL parameter keys.
if strings.Contains(key, "*") {
continue
}
// Add the URL parameter key-value pair to the URL values map.
params.Add(":"+key, ctx.URLParams.Values[i])
}
// Re-encode the URL values map as a query string and replace the
// existing query string in the request.
r.URL.RawQuery = params.Encode()
// Call the next handler in the chain with the modified request and response.
next.ServeHTTP(w, r)
})
}
func UpdateLastAccessMiddleware(ds model.DataStore) func(next http.Handler) http.Handler {
userAccessLimiter := utils.Limiter{Interval: consts.UpdateLastAccessFrequency}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
usr, ok := request.UserFrom(ctx)
if ok {
userAccessLimiter.Do(usr.ID, func() {
start := time.Now()
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
err := ds.User(ctx).UpdateLastAccessAt(usr.ID)
if err != nil {
log.Warn(ctx, "Could not update user's lastAccessAt", "username", usr.UserName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "Update user's lastAccessAt", "username", usr.UserName,
"elapsed", time.Since(start))
}
})
}
next.ServeHTTP(w, r)
})
}
}

409
server/middlewares_test.go Normal file
View File

@@ -0,0 +1,409 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("middlewares", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
Describe("robotsTXT", func() {
var nextCalled bool
next := func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
}
BeforeEach(func() {
nextCalled = false
})
It("returns the robot.txt when requested from root", func() {
r := httptest.NewRequest("GET", "/robots.txt", nil)
w := httptest.NewRecorder()
robotsTXT(os.DirFS("tests/fixtures"))(http.HandlerFunc(next)).ServeHTTP(w, r)
Expect(nextCalled).To(BeFalse())
Expect(w.Body.String()).To(HavePrefix("User-agent:"))
})
It("allows prefixes", func() {
r := httptest.NewRequest("GET", "/app/robots.txt", nil)
w := httptest.NewRecorder()
robotsTXT(os.DirFS("tests/fixtures"))(http.HandlerFunc(next)).ServeHTTP(w, r)
Expect(nextCalled).To(BeFalse())
Expect(w.Body.String()).To(HavePrefix("User-agent:"))
})
It("passes through requests for other files", func() {
r := httptest.NewRequest("GET", "/this_is_not_a_robots.txt_file", nil)
w := httptest.NewRecorder()
robotsTXT(os.DirFS("tests/fixtures"))(http.HandlerFunc(next)).ServeHTTP(w, r)
Expect(nextCalled).To(BeTrue())
})
})
Describe("serverAddressMiddleware", func() {
var (
nextHandler http.Handler
middleware http.Handler
recorder *httptest.ResponseRecorder
req *http.Request
)
BeforeEach(func() {
nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
middleware = serverAddressMiddleware(nextHandler)
recorder = httptest.NewRecorder()
})
Context("with no X-Forwarded headers", func() {
BeforeEach(func() {
req, _ = http.NewRequest("GET", "http://example.com", nil)
})
It("should not modify the request", func() {
middleware.ServeHTTP(recorder, req)
Expect(req.Host).To(Equal("example.com"))
Expect(req.URL.Scheme).To(Equal("http"))
})
})
Context("with X-Forwarded-Host header", func() {
BeforeEach(func() {
req, _ = http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("X-Forwarded-Host", "forwarded.example.com")
})
It("should modify the request with the X-Forwarded-Host header value", func() {
middleware.ServeHTTP(recorder, req)
Expect(req.Host).To(Equal("forwarded.example.com"))
Expect(req.URL.Scheme).To(Equal("http"))
})
})
Context("with X-Forwarded-Proto header", func() {
BeforeEach(func() {
req, _ = http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("X-Forwarded-Proto", "https")
})
It("should modify the request with the X-Forwarded-Proto header value", func() {
middleware.ServeHTTP(recorder, req)
Expect(req.Host).To(Equal("example.com"))
Expect(req.URL.Scheme).To(Equal("https"))
})
})
Context("with X-Forwarded-Scheme header", func() {
BeforeEach(func() {
req, _ = http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("X-Forwarded-Scheme", "https")
})
It("should modify the request with the X-Forwarded-Scheme header value", func() {
middleware.ServeHTTP(recorder, req)
Expect(req.Host).To(Equal("example.com"))
Expect(req.URL.Scheme).To(Equal("https"))
})
})
Context("with multiple X-Forwarded headers", func() {
BeforeEach(func() {
req, _ = http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("X-Forwarded-Host", "forwarded.example.com")
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Scheme", "http")
})
It("should modify the request with the first non-empty X-Forwarded header value", func() {
middleware.ServeHTTP(recorder, req)
Expect(req.Host).To(Equal("forwarded.example.com"))
Expect(req.URL.Scheme).To(Equal("https"))
})
})
Context("with multiple values in X-Forwarded-Host header", func() {
BeforeEach(func() {
req, _ = http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("X-Forwarded-Host", "forwarded1.example.com, forwarded2.example.com")
})
It("should modify the request with the first value in X-Forwarded-Host header", func() {
middleware.ServeHTTP(recorder, req)
Expect(req.Host).To(Equal("forwarded1.example.com"))
Expect(req.URL.Scheme).To(Equal("http"))
})
})
})
Describe("clientUniqueIDMiddleware", func() {
var (
nextHandler http.Handler
middleware http.Handler
req *http.Request
nextReq *http.Request
rec *httptest.ResponseRecorder
)
BeforeEach(func() {
nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
nextReq = r
})
middleware = clientUniqueIDMiddleware(nextHandler)
req, _ = http.NewRequest(http.MethodGet, "/", nil)
rec = httptest.NewRecorder()
})
Context("when the request header has the unique client ID", func() {
BeforeEach(func() {
req.Header.Set(consts.UIClientUniqueIDHeader, "123456")
conf.Server.BasePath = "/music"
})
It("sets the unique client ID as a cookie and adds it to the request context", func() {
middleware.ServeHTTP(rec, req)
Expect(rec.Result().Cookies()).To(HaveLen(1))
Expect(rec.Result().Cookies()[0].Name).To(Equal(consts.UIClientUniqueIDHeader))
Expect(rec.Result().Cookies()[0].Value).To(Equal("123456"))
Expect(rec.Result().Cookies()[0].MaxAge).To(Equal(consts.CookieExpiry))
Expect(rec.Result().Cookies()[0].HttpOnly).To(BeTrue())
Expect(rec.Result().Cookies()[0].Secure).To(BeTrue())
Expect(rec.Result().Cookies()[0].SameSite).To(Equal(http.SameSiteStrictMode))
Expect(rec.Result().Cookies()[0].Path).To(Equal("/music"))
clientUniqueId, _ := request.ClientUniqueIdFrom(nextReq.Context())
Expect(clientUniqueId).To(Equal("123456"))
})
})
Context("when the request header does not have the unique client ID", func() {
Context("when the request has the unique client ID in a cookie", func() {
BeforeEach(func() {
req.AddCookie(&http.Cookie{
Name: consts.UIClientUniqueIDHeader,
Value: "123456",
})
})
It("adds the unique client ID to the request context", func() {
middleware.ServeHTTP(rec, req)
Expect(rec.Result().Cookies()).To(HaveLen(0))
clientUniqueId, _ := request.ClientUniqueIdFrom(nextReq.Context())
Expect(clientUniqueId).To(Equal("123456"))
})
})
Context("when the request does not have the unique client ID in a cookie", func() {
It("does not add the unique client ID to the request context", func() {
middleware.ServeHTTP(rec, req)
Expect(rec.Result().Cookies()).To(HaveLen(0))
clientUniqueId, _ := request.ClientUniqueIdFrom(nextReq.Context())
Expect(clientUniqueId).To(BeEmpty())
})
})
})
})
Describe("URLParamsMiddleware", func() {
var (
router *chi.Mux
middleware http.Handler
recorder *httptest.ResponseRecorder
testHandler http.HandlerFunc
)
BeforeEach(func() {
router = chi.NewRouter()
recorder = httptest.NewRecorder()
testHandler = func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("OK"))
}
})
Context("when request has no query parameters", func() {
It("adds URL parameters to the request", func() {
middleware = URLParamsMiddleware(testHandler)
router.Mount("/", middleware)
req, _ := http.NewRequest("GET", "/?user=1", nil)
router.ServeHTTP(recorder, req)
Expect(recorder.Code).To(Equal(http.StatusOK))
Expect(recorder.Body.String()).To(Equal("OK"))
Expect(req.URL.RawQuery).To(ContainSubstring("user=1"))
})
})
Context("when request has query parameters", func() {
It("merges URL parameters and query parameters", func() {
router.Route("/{key}", func(r chi.Router) {
r.Use(URLParamsMiddleware)
r.Get("/", testHandler)
})
req, _ := http.NewRequest("GET", "/test?key=value", nil)
router.ServeHTTP(recorder, req)
Expect(recorder.Code).To(Equal(http.StatusOK))
Expect(recorder.Body.String()).To(Equal("OK"))
Expect(req.URL.RawQuery).To(ContainSubstring("key=value"))
Expect(req.URL.RawQuery).To(ContainSubstring("%3Akey=test"))
})
})
Context("when URL parameter has wildcard key", func() {
It("does not include wildcard key in query parameters", func() {
router.Route("/{t*}", func(r chi.Router) {
r.Use(URLParamsMiddleware)
r.Get("/", testHandler)
})
req, _ := http.NewRequest("GET", "/test?key=value", nil)
router.ServeHTTP(recorder, req)
Expect(recorder.Code).To(Equal(http.StatusOK))
Expect(recorder.Body.String()).To(Equal("OK"))
Expect(req.URL.RawQuery).To(ContainSubstring("key=value"))
})
})
Context("when URL parameters require encoding", func() {
It("encodes URL parameters correctly", func() {
router.Route("/{key}", func(r chi.Router) {
r.Use(URLParamsMiddleware)
r.Get("/", testHandler)
})
req, _ := http.NewRequest("GET", "/test with space?key=another value", nil)
router.ServeHTTP(recorder, req)
Expect(recorder.Code).To(Equal(http.StatusOK))
Expect(recorder.Body.String()).To(Equal("OK"))
queryValues, _ := url.ParseQuery(req.URL.RawQuery)
Expect(queryValues.Get(":key")).To(Equal("test with space"))
Expect(queryValues.Get("key")).To(Equal("another value"))
})
})
Context("when there are multiple URL parameters", func() {
It("includes all URL parameters in the query string", func() {
router.Route("/{key}/{value}", func(r chi.Router) {
r.Use(URLParamsMiddleware)
r.Get("/", testHandler)
})
req, _ := http.NewRequest("GET", "/test/value?key=other_value", nil)
router.ServeHTTP(recorder, req)
Expect(recorder.Code).To(Equal(http.StatusOK))
Expect(recorder.Body.String()).To(Equal("OK"))
queryValues, _ := url.ParseQuery(req.URL.RawQuery)
Expect(queryValues.Get(":key")).To(Equal("test"))
Expect(queryValues.Get(":value")).To(Equal("value"))
Expect(queryValues.Get("key")).To(Equal("other_value"))
})
})
})
Describe("UpdateLastAccessMiddleware", func() {
var (
middleware func(next http.Handler) http.Handler
req *http.Request
ctx context.Context
ds *tests.MockDataStore
id string
lastAccessTime time.Time
)
callMiddleware := func(req *http.Request) {
middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(nil, req)
}
BeforeEach(func() {
id = uuid.NewString()
ds = &tests.MockDataStore{}
lastAccessTime = time.Now()
Expect(ds.User(ctx).Put(&model.User{ID: id, UserName: "johndoe", LastAccessAt: &lastAccessTime})).
To(Succeed())
middleware = UpdateLastAccessMiddleware(ds)
ctx = request.WithUser(
context.Background(),
model.User{ID: id, UserName: "johndoe"},
)
req, _ = http.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(ctx)
})
Context("when the request has a user", func() {
It("does calls the next handler", func() {
called := false
middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})).ServeHTTP(nil, req)
Expect(called).To(BeTrue())
})
It("updates the last access time", func() {
time.Sleep(3 * time.Millisecond)
callMiddleware(req)
user, _ := ds.MockedUser.FindByUsername("johndoe")
Expect(*user.LastAccessAt).To(BeTemporally(">", lastAccessTime, time.Second))
})
It("skip fast successive requests", func() {
// First request
callMiddleware(req)
user, _ := ds.MockedUser.FindByUsername("johndoe")
lastAccessTime = *user.LastAccessAt // Store the last access time
// Second request
time.Sleep(3 * time.Millisecond)
callMiddleware(req)
// The second request should not have changed the last access time
user, _ = ds.MockedUser.FindByUsername("johndoe")
Expect(user.LastAccessAt).To(Equal(&lastAccessTime))
})
})
Context("when the request has no user", func() {
It("does not update the last access time", func() {
req = req.WithContext(context.Background())
callMiddleware(req)
usr, _ := ds.MockedUser.FindByUsername("johndoe")
Expect(usr.LastAccessAt).To(Equal(&lastAccessTime))
})
})
})
})

132
server/nativeapi/config.go Normal file
View File

@@ -0,0 +1,132 @@
package nativeapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
// sensitiveFieldsPartialMask contains configuration field names that should be redacted
// using partial masking (first and last character visible, middle replaced with *).
// For values with 7+ characters: "secretvalue123" becomes "s***********3"
// For values with <7 characters: "short" becomes "****"
// Add field paths using dot notation (e.g., "LastFM.ApiKey", "Spotify.Secret")
var sensitiveFieldsPartialMask = []string{
"LastFM.ApiKey",
"LastFM.Secret",
"Prometheus.MetricsPath",
"Spotify.ID",
"Spotify.Secret",
"DevAutoLoginUsername",
}
// sensitiveFieldsFullMask contains configuration field names that should always be
// completely masked with "****" regardless of their length.
// Add field paths using dot notation for any fields that should never show any content.
var sensitiveFieldsFullMask = []string{
"DevAutoCreateAdminPassword",
"PasswordEncryptionKey",
"Prometheus.Password",
}
type configResponse struct {
ID string `json:"id"`
ConfigFile string `json:"configFile"`
Config map[string]interface{} `json:"config"`
}
func redactValue(key string, value string) string {
// Return empty values as-is
if len(value) == 0 {
return value
}
// Check if this field should be fully masked
for _, field := range sensitiveFieldsFullMask {
if field == key {
return "****"
}
}
// Check if this field should be partially masked
for _, field := range sensitiveFieldsPartialMask {
if field == key {
if len(value) < 7 {
return "****"
}
// Show first and last character with * in between
return string(value[0]) + strings.Repeat("*", len(value)-2) + string(value[len(value)-1])
}
}
// Return original value if not sensitive
return value
}
// applySensitiveFieldMasking recursively applies masking to sensitive fields in the configuration map
func applySensitiveFieldMasking(ctx context.Context, config map[string]interface{}, prefix string) {
for key, value := range config {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
switch v := value.(type) {
case map[string]interface{}:
// Recursively process nested maps
applySensitiveFieldMasking(ctx, v, fullKey)
case string:
// Apply masking to string values
config[key] = redactValue(fullKey, v)
default:
// For other types (numbers, booleans, etc.), convert to string and check for masking
if str := fmt.Sprint(v); str != "" {
masked := redactValue(fullKey, str)
if masked != str {
// Only replace if masking was applied
config[key] = masked
}
}
}
}
}
func getConfig(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Marshal the actual configuration struct to preserve original field names
configBytes, err := json.Marshal(*conf.Server)
if err != nil {
log.Error(ctx, "Error marshaling config", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Unmarshal back to map to get the structure with proper field names
var configMap map[string]interface{}
err = json.Unmarshal(configBytes, &configMap)
if err != nil {
log.Error(ctx, "Error unmarshaling config to map", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Apply sensitive field masking
applySensitiveFieldMasking(ctx, configMap, "")
resp := configResponse{
ID: "config",
ConfigFile: conf.Server.ConfigFile,
Config: configMap,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Error(ctx, "Error encoding config response", err)
}
}

View File

@@ -0,0 +1,227 @@
package nativeapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Config API", func() {
var ds model.DataStore
var router http.Handler
var adminUser, regularUser model.User
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
regularUser = model.User{
ID: "user-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
// Store in mock datastore
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
Expect(ds.User(context.TODO()).Put(&regularUser)).To(Succeed())
})
Describe("GET /api/config", func() {
Context("as admin user", func() {
var adminToken string
BeforeEach(func() {
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
})
It("returns config successfully", func() {
req := createAuthenticatedConfigRequest(adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.ID).To(Equal("config"))
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
Expect(resp.Config).ToNot(BeEmpty())
})
It("redacts sensitive fields", func() {
conf.Server.LastFM.ApiKey = "secretapikey123"
conf.Server.Spotify.Secret = "spotifysecret456"
conf.Server.PasswordEncryptionKey = "encryptionkey789"
conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
conf.Server.Prometheus.Password = "prometheuspass"
req := createAuthenticatedConfigRequest(adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
// Check LastFM.ApiKey (partially masked)
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
// Check Spotify.Secret (partially masked)
spotify, ok := resp.Config["Spotify"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(spotify["Secret"]).To(Equal("s**************6"))
// Check PasswordEncryptionKey (fully masked)
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
// Check DevAutoCreateAdminPassword (fully masked)
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
// Check Prometheus.Password (fully masked)
prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(prometheus["Password"]).To(Equal("****"))
})
It("handles empty sensitive values", func() {
conf.Server.LastFM.ApiKey = ""
conf.Server.PasswordEncryptionKey = ""
req := createAuthenticatedConfigRequest(adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
// Check LastFM.ApiKey - should be preserved because it's sensitive
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal(""))
// Empty sensitive values should remain empty - should be preserved because it's sensitive
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
})
})
Context("as regular user", func() {
var userToken string
BeforeEach(func() {
var err error
userToken, err = auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
})
It("denies access with forbidden status", func() {
req := createAuthenticatedConfigRequest(userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
})
Context("without authentication", func() {
It("denies access with unauthorized status", func() {
req := createUnauthenticatedConfigRequest("GET", "/config/", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
})
var _ = Describe("redactValue function", func() {
It("partially masks long sensitive values", func() {
Expect(redactValue("LastFM.ApiKey", "ba46f0e84a")).To(Equal("b********a"))
Expect(redactValue("Spotify.Secret", "verylongsecret123")).To(Equal("v***************3"))
})
It("fully masks long sensitive values that should be completely hidden", func() {
Expect(redactValue("PasswordEncryptionKey", "1234567890")).To(Equal("****"))
Expect(redactValue("DevAutoCreateAdminPassword", "1234567890")).To(Equal("****"))
Expect(redactValue("Prometheus.Password", "1234567890")).To(Equal("****"))
})
It("fully masks short sensitive values", func() {
Expect(redactValue("LastFM.Secret", "short")).To(Equal("****"))
Expect(redactValue("Spotify.ID", "abc")).To(Equal("****"))
Expect(redactValue("PasswordEncryptionKey", "12345")).To(Equal("****"))
Expect(redactValue("DevAutoCreateAdminPassword", "short")).To(Equal("****"))
Expect(redactValue("Prometheus.Password", "short")).To(Equal("****"))
})
It("does not mask non-sensitive values", func() {
Expect(redactValue("MusicFolder", "/path/to/music")).To(Equal("/path/to/music"))
Expect(redactValue("Port", "4533")).To(Equal("4533"))
Expect(redactValue("SomeOtherField", "secretvalue")).To(Equal("secretvalue"))
})
It("handles empty values", func() {
Expect(redactValue("LastFM.ApiKey", "")).To(Equal(""))
Expect(redactValue("NonSensitive", "")).To(Equal(""))
})
It("handles edge case values", func() {
Expect(redactValue("LastFM.ApiKey", "a")).To(Equal("****"))
Expect(redactValue("LastFM.ApiKey", "ab")).To(Equal("****"))
Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g"))
})
})
// Helper functions
func createAuthenticatedConfigRequest(token string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/config/config", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
return req
}
func createUnauthenticatedConfigRequest(method, path string, body *bytes.Buffer) *http.Request {
if body == nil {
body = &bytes.Buffer{}
}
req := httptest.NewRequest(method, path, body)
req.Header.Set("Content-Type", "application/json")
return req
}

View File

@@ -0,0 +1,67 @@
package nativeapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/req"
)
func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.InspectOutput, error) {
file, err := ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
if file.Missing {
return nil, model.ErrNotFound
}
return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID)
}
func inspect(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
id, err := p.String("id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
output, err := doInspect(ctx, ds, id)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "could not find file", "id", id)
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
log.Error(ctx, "Error reading tags", "id", id, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
output.MappedTags = nil
response, err := json.Marshal(output)
if err != nil {
log.Error(ctx, "Error marshalling json", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(response); err != nil {
log.Error(ctx, "Error sending response to client", err)
}
}
}

101
server/nativeapi/library.go Normal file
View File

@@ -0,0 +1,101 @@
package nativeapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// User-library association endpoints (admin only)
func (api *Router) addUserLibraryRoute(r chi.Router) {
r.Route("/user/{id}/library", func(r chi.Router) {
r.Use(parseUserIDMiddleware)
r.Get("/", getUserLibraries(api.libs))
r.Put("/", setUserLibraries(api.libs))
})
}
// Middleware to parse user ID from URL
func parseUserIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
if userID == "" {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// User-library association handlers
func getUserLibraries(service core.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
libraries, err := service.GetUserLibraries(r.Context(), userID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting user libraries", "userID", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(libraries); err != nil {
log.Error(r.Context(), "Error encoding user libraries response", err)
}
}
}
func setUserLibraries(service core.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
var request struct {
LibraryIDs []int `json:"libraryIds"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
log.Error(r.Context(), "Error decoding request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := service.SetUserLibraries(r.Context(), userID, request.LibraryIDs); err != nil {
log.Error(r.Context(), "Error setting user libraries", "userID", userID, err)
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "User not found", http.StatusNotFound)
return
}
if errors.Is(err, model.ErrValidation) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, "Failed to set user libraries", http.StatusInternalServerError)
return
}
// Return updated user libraries
libraries, err := service.GetUserLibraries(r.Context(), userID)
if err != nil {
log.Error(r.Context(), "Error getting updated user libraries", "userID", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(libraries); err != nil {
log.Error(r.Context(), "Error encoding user libraries response", err)
}
}
}

View File

@@ -0,0 +1,424 @@
package nativeapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Library API", func() {
var ds model.DataStore
var router http.Handler
var adminUser, regularUser model.User
var library1, library2 model.Library
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
regularUser = model.User{
ID: "user-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
// Create test libraries
library1 = model.Library{
ID: 1,
Name: "Test Library 1",
Path: "/music/library1",
}
library2 = model.Library{
ID: 2,
Name: "Test Library 2",
Path: "/music/library2",
}
// Store in mock datastore
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
Expect(ds.User(context.TODO()).Put(&regularUser)).To(Succeed())
Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed())
Expect(ds.Library(context.TODO()).Put(&library2)).To(Succeed())
})
Describe("Library CRUD Operations", func() {
Context("as admin user", func() {
var adminToken string
BeforeEach(func() {
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
})
Describe("GET /api/library", func() {
It("returns all libraries", func() {
req := createAuthenticatedRequest("GET", "/library", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var libraries []model.Library
err := json.Unmarshal(w.Body.Bytes(), &libraries)
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(2))
Expect(libraries[0].Name).To(Equal("Test Library 1"))
Expect(libraries[1].Name).To(Equal("Test Library 2"))
})
})
Describe("GET /api/library/{id}", func() {
It("returns a specific library", func() {
req := createAuthenticatedRequest("GET", "/library/1", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var library model.Library
err := json.Unmarshal(w.Body.Bytes(), &library)
Expect(err).ToNot(HaveOccurred())
Expect(library.Name).To(Equal("Test Library 1"))
Expect(library.Path).To(Equal("/music/library1"))
})
It("returns 404 for non-existent library", func() {
req := createAuthenticatedRequest("GET", "/library/999", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 400 for invalid library ID", func() {
req := createAuthenticatedRequest("GET", "/library/invalid", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("POST /api/library", func() {
It("creates a new library", func() {
newLibrary := model.Library{
Name: "New Library",
Path: "/music/new",
}
body, _ := json.Marshal(newLibrary)
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("validates required fields", func() {
invalidLibrary := model.Library{
Name: "", // Missing name
Path: "/music/invalid",
}
body, _ := json.Marshal(invalidLibrary)
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("library name is required"))
})
It("validates path field", func() {
invalidLibrary := model.Library{
Name: "Valid Name",
Path: "", // Missing path
}
body, _ := json.Marshal(invalidLibrary)
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("library path is required"))
})
})
Describe("PUT /api/library/{id}", func() {
It("updates an existing library", func() {
updatedLibrary := model.Library{
Name: "Updated Library 1",
Path: "/music/updated",
}
body, _ := json.Marshal(updatedLibrary)
req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var updated model.Library
err := json.Unmarshal(w.Body.Bytes(), &updated)
Expect(err).ToNot(HaveOccurred())
Expect(updated.ID).To(Equal(1))
Expect(updated.Name).To(Equal("Updated Library 1"))
Expect(updated.Path).To(Equal("/music/updated"))
})
It("validates required fields on update", func() {
invalidLibrary := model.Library{
Name: "",
Path: "/music/path",
}
body, _ := json.Marshal(invalidLibrary)
req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})
Describe("DELETE /api/library/{id}", func() {
It("deletes an existing library", func() {
req := createAuthenticatedRequest("DELETE", "/library/1", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("returns 404 for non-existent library", func() {
req := createAuthenticatedRequest("DELETE", "/library/999", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})
Context("as regular user", func() {
var userToken string
BeforeEach(func() {
var err error
userToken, err = auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
})
It("denies access to library management endpoints", func() {
endpoints := []string{
"GET /library",
"POST /library",
"GET /library/1",
"PUT /library/1",
"DELETE /library/1",
}
for _, endpoint := range endpoints {
parts := strings.Split(endpoint, " ")
method, path := parts[0], parts[1]
req := createAuthenticatedRequest(method, path, nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
}
})
})
Context("without authentication", func() {
It("denies access to library management endpoints", func() {
req := createUnauthenticatedRequest("GET", "/library", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("User-Library Association Operations", func() {
Context("as admin user", func() {
var adminToken string
BeforeEach(func() {
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
})
Describe("GET /api/user/{id}/library", func() {
It("returns user's libraries", func() {
// Set up user libraries
err := ds.User(context.TODO()).SetUserLibraries(regularUser.ID, []int{1, 2})
Expect(err).ToNot(HaveOccurred())
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var libraries []model.Library
err = json.Unmarshal(w.Body.Bytes(), &libraries)
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(2))
})
It("returns 404 for non-existent user", func() {
req := createAuthenticatedRequest("GET", "/user/non-existent/library", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("PUT /api/user/{id}/library", func() {
It("sets user's libraries", func() {
request := map[string][]int{
"libraryIds": {1, 2},
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var libraries []model.Library
err := json.Unmarshal(w.Body.Bytes(), &libraries)
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(2))
})
It("validates library IDs exist", func() {
request := map[string][]int{
"libraryIds": {999}, // Non-existent library
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("library ID 999 does not exist"))
})
It("requires at least one library for regular users", func() {
request := map[string][]int{
"libraryIds": {}, // Empty libraries
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("at least one library must be assigned"))
})
It("prevents manual assignment to admin users", func() {
request := map[string][]int{
"libraryIds": {1},
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", adminUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("cannot manually assign libraries to admin users"))
})
})
})
Context("as regular user", func() {
var userToken string
BeforeEach(func() {
var err error
userToken, err = auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
})
It("denies access to user-library association endpoints", func() {
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
})
})
})
// Helper functions
func createAuthenticatedRequest(method, path string, body *bytes.Buffer, token string) *http.Request {
if body == nil {
body = &bytes.Buffer{}
}
req := httptest.NewRequest(method, path, body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
return req
}
func createUnauthenticatedRequest(method, path string, body *bytes.Buffer) *http.Request {
if body == nil {
body = &bytes.Buffer{}
}
req := httptest.NewRequest(method, path, body)
req.Header.Set("Content-Type", "application/json")
return req
}

View File

@@ -0,0 +1,94 @@
package nativeapi
import (
"context"
"errors"
"maps"
"net/http"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/req"
)
type missingRepository struct {
model.ResourceRepository
mfRepo model.MediaFileRepository
}
func newMissingRepository(ds model.DataStore) rest.RepositoryConstructor {
return func(ctx context.Context) rest.Repository {
return &missingRepository{mfRepo: ds.MediaFile(ctx), ResourceRepository: ds.Resource(ctx, model.MediaFile{})}
}
}
func (r *missingRepository) Count(options ...rest.QueryOptions) (int64, error) {
opt := r.parseOptions(options)
return r.ResourceRepository.Count(opt)
}
func (r *missingRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
opt := r.parseOptions(options)
return r.ResourceRepository.ReadAll(opt)
}
func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.QueryOptions {
var opt rest.QueryOptions
if len(options) > 0 {
opt = options[0]
opt.Filters = maps.Clone(opt.Filters)
}
opt.Filters["missing"] = "true"
return opt
}
func (r *missingRepository) Read(id string) (any, error) {
all, err := r.mfRepo.GetAll(model.QueryOptions{Filters: squirrel.And{
squirrel.Eq{"id": id},
squirrel.Eq{"missing": true},
}})
if err != nil {
return nil, err
}
if len(all) == 0 {
return nil, model.ErrNotFound
}
return all[0], nil
}
func (r *missingRepository) EntityName() string {
return "missing_files"
}
func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
ids, _ := p.Strings("id")
var err error
if len(ids) == 0 {
err = maintenance.DeleteAllMissingFiles(ctx)
} else {
err = maintenance.DeleteMissingFiles(ctx, ids)
}
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Missing file not found", "id", ids[0])
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "failed to delete missing files", http.StatusInternalServerError)
return
}
writeDeleteManyResponse(w, r, ids)
}
}
var _ model.ResourceRepository = &missingRepository{}

View File

@@ -0,0 +1,247 @@
package nativeapi
import (
"context"
"encoding/json"
"html"
"net/http"
"strconv"
"time"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
)
type Router struct {
http.Handler
ds model.DataStore
share core.Share
playlists core.Playlists
insights metrics.Insights
libs core.Library
maintenance core.Maintenance
}
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router {
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance}
r.Handler = r.routes()
return r
}
func (api *Router) routes() http.Handler {
r := chi.NewRouter()
// Public
api.RX(r, "/translation", newTranslationRepository, false)
// Protected
r.Group(func(r chi.Router) {
r.Use(server.Authenticator(api.ds))
r.Use(server.JWTRefresher)
r.Use(server.UpdateLastAccessMiddleware(api.ds))
api.R(r, "/user", model.User{}, true)
api.R(r, "/song", model.MediaFile{}, false)
api.R(r, "/album", model.Album{}, false)
api.R(r, "/artist", model.Artist{}, false)
api.R(r, "/genre", model.Genre{}, false)
api.R(r, "/player", model.Player{}, true)
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
api.R(r, "/radio", model.Radio{}, true)
api.R(r, "/tag", model.Tag{}, true)
if conf.Server.EnableSharing {
api.RX(r, "/share", api.share.NewRepository, true)
}
api.addPlaylistRoute(r)
api.addPlaylistTrackRoute(r)
api.addSongPlaylistsRoute(r)
api.addQueueRoute(r)
api.addMissingFilesRoute(r)
api.addKeepAliveRoute(r)
api.addInsightsRoute(r)
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
api.addInspectRoute(r)
api.addConfigRoute(r)
api.addUserLibraryRoute(r)
api.RX(r, "/library", api.libs.NewRepository, true)
})
})
return r
}
func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
constructor := func(ctx context.Context) rest.Repository {
return api.ds.Resource(ctx, model)
}
api.RX(r, pathPrefix, constructor, persistable)
}
func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
r.Route(pathPrefix, func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
if persistable {
r.Post("/", rest.Post(constructor))
}
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(constructor))
if persistable {
r.Put("/", rest.Put(constructor))
r.Delete("/", rest.Delete(constructor))
}
})
})
}
func (api *Router) addPlaylistRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return api.ds.Resource(ctx, model.Playlist{})
}
r.Route("/playlist", func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-type") == "application/json" {
rest.Post(constructor)(w, r)
return
}
createPlaylistFromM3U(api.playlists)(w, r)
})
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(constructor))
r.Put("/", rest.Put(constructor))
r.Delete("/", rest.Delete(constructor))
})
})
}
func (api *Router) addPlaylistTrackRoute(r chi.Router) {
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
getPlaylist(api.ds)(w, r)
})
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
deleteFromPlaylist(api.ds)(w, r)
})
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
addToPlaylist(api.ds)(w, r)
})
})
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
getPlaylistTrack(api.ds)(w, r)
})
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
reorderItem(api.ds)(w, r)
})
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
deleteFromPlaylist(api.ds)(w, r)
})
})
})
}
func (api *Router) addSongPlaylistsRoute(r chi.Router) {
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
getSongPlaylists(api.ds)(w, r)
})
}
func (api *Router) addQueueRoute(r chi.Router) {
r.Route("/queue", func(r chi.Router) {
r.Get("/", getQueue(api.ds))
r.Post("/", saveQueue(api.ds))
r.Put("/", updateQueue(api.ds))
r.Delete("/", clearQueue(api.ds))
})
}
func (api *Router) addMissingFilesRoute(r chi.Router) {
r.Route("/missing", func(r chi.Router) {
api.RX(r, "/", newMissingRepository(api.ds), false)
r.Delete("/", deleteMissingFiles(api.maintenance))
})
}
func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []string) {
var resp []byte
var err error
if len(ids) == 1 {
resp = []byte(`{"id":"` + html.EscapeString(ids[0]) + `"}`)
} else {
resp, err = json.Marshal(&struct {
Ids []string `json:"ids"`
}{Ids: ids})
if err != nil {
log.Error(r.Context(), "Error marshaling response", "ids", ids, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
_, err = w.Write(resp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (api *Router) addInspectRoute(r chi.Router) {
if conf.Server.Inspect.Enabled {
r.Group(func(r chi.Router) {
if conf.Server.Inspect.MaxRequests > 0 {
log.Debug("Throttling inspect", "maxRequests", conf.Server.Inspect.MaxRequests,
"backlogLimit", conf.Server.Inspect.BacklogLimit, "backlogTimeout",
conf.Server.Inspect.BacklogTimeout)
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
}
r.Get("/inspect", inspect(api.ds))
})
}
}
func (api *Router) addConfigRoute(r chi.Router) {
if conf.Server.DevUIShowConfig {
r.Get("/config/*", getConfig)
}
}
func (api *Router) addKeepAliveRoute(r chi.Router) {
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
})
}
func (api *Router) addInsightsRoute(r chi.Router) {
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
last, success := api.insights.LastRun(r.Context())
if conf.Server.EnableInsightsCollector {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
} else {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
}
})
}
// Middleware to ensure only admin users can access endpoints
func adminOnlyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := request.UserFrom(r.Context())
if !ok || !user.IsAdmin {
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,431 @@
package nativeapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Song Endpoints", func() {
var (
router http.Handler
ds *tests.MockDataStore
mfRepo *tests.MockMediaFileRepo
userRepo *tests.MockedUserRepo
w *httptest.ResponseRecorder
testUser model.User
testSongs model.MediaFiles
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
// Setup mock repositories
mfRepo = tests.CreateMockMediaFileRepo()
userRepo = tests.CreateMockUserRepo()
ds = &tests.MockDataStore{
MockedMediaFile: mfRepo,
MockedUser: userRepo,
MockedProperty: &tests.MockedPropertyRepo{},
}
// Initialize auth system
auth.Init(ds)
// Create test user
testUser = model.User{
ID: "user-1",
UserName: "testuser",
Name: "Test User",
IsAdmin: false,
NewPassword: "testpass",
}
err := userRepo.Put(&testUser)
Expect(err).ToNot(HaveOccurred())
// Create test songs
testSongs = model.MediaFiles{
{
ID: "song-1",
Title: "Test Song 1",
Artist: "Test Artist 1",
Album: "Test Album 1",
AlbumID: "album-1",
ArtistID: "artist-1",
Duration: 180.5,
BitRate: 320,
Path: "/music/song1.mp3",
Suffix: "mp3",
Size: 5242880,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
ID: "song-2",
Title: "Test Song 2",
Artist: "Test Artist 2",
Album: "Test Album 2",
AlbumID: "album-2",
ArtistID: "artist-2",
Duration: 240.0,
BitRate: 256,
Path: "/music/song2.mp3",
Suffix: "mp3",
Size: 7340032,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
mfRepo.SetData(testSongs)
// Create the native API router and wrap it with the JWTVerifier middleware
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
// Helper function to create unauthenticated request
createUnauthenticatedRequest := func(method, path string, body []byte) *http.Request {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
return req
}
// Helper function to create authenticated request with JWT token
createAuthenticatedRequest := func(method, path string, body []byte) *http.Request {
req := createUnauthenticatedRequest(method, path, body)
// Create JWT token for the test user
token, err := auth.CreateToken(&testUser)
Expect(err).ToNot(HaveOccurred())
// Add JWT token to Authorization header
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
return req
}
Describe("GET /song", func() {
Context("when user is authenticated", func() {
It("returns all songs", func() {
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response).To(HaveLen(2))
Expect(response[0].ID).To(Equal("song-1"))
Expect(response[0].Title).To(Equal("Test Song 1"))
Expect(response[1].ID).To(Equal("song-2"))
Expect(response[1].Title).To(Equal("Test Song 2"))
})
It("handles repository errors gracefully", func() {
mfRepo.SetError(true)
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Context("when user is not authenticated", func() {
It("returns unauthorized", func() {
req := createUnauthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("GET /song/{id}", func() {
Context("when user is authenticated", func() {
It("returns the specific song", func() {
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.ID).To(Equal("song-1"))
Expect(response.Title).To(Equal("Test Song 1"))
Expect(response.Artist).To(Equal("Test Artist 1"))
})
It("returns 404 for non-existent song", func() {
req := createAuthenticatedRequest("GET", "/song/non-existent", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("handles repository errors gracefully", func() {
mfRepo.SetError(true)
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Context("when user is not authenticated", func() {
It("returns unauthorized", func() {
req := createUnauthenticatedRequest("GET", "/song/song-1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("Song endpoints are read-only", func() {
Context("POST /song", func() {
It("should not be available (songs are not persistable)", func() {
newSong := model.MediaFile{
Title: "New Song",
Artist: "New Artist",
Album: "New Album",
Duration: 200.0,
}
body, _ := json.Marshal(newSong)
req := createAuthenticatedRequest("POST", "/song", body)
router.ServeHTTP(w, req)
// Should return 405 Method Not Allowed or 404 Not Found
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
})
})
Context("PUT /song/{id}", func() {
It("should not be available (songs are not persistable)", func() {
updatedSong := model.MediaFile{
ID: "song-1",
Title: "Updated Song",
Artist: "Updated Artist",
Album: "Updated Album",
Duration: 250.0,
}
body, _ := json.Marshal(updatedSong)
req := createAuthenticatedRequest("PUT", "/song/song-1", body)
router.ServeHTTP(w, req)
// Should return 405 Method Not Allowed or 404 Not Found
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
})
})
Context("DELETE /song/{id}", func() {
It("should not be available (songs are not persistable)", func() {
req := createAuthenticatedRequest("DELETE", "/song/song-1", nil)
router.ServeHTTP(w, req)
// Should return 405 Method Not Allowed or 404 Not Found
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
})
})
})
Describe("Query parameters and filtering", func() {
Context("when using query parameters", func() {
It("handles pagination parameters", func() {
req := createAuthenticatedRequest("GET", "/song?_start=0&_end=1", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
// Should still return all songs since our mock doesn't implement pagination
// but the request should be processed successfully
Expect(len(response)).To(BeNumerically(">=", 1))
})
It("handles sort parameters", func() {
req := createAuthenticatedRequest("GET", "/song?_sort=title&_order=ASC", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response).To(HaveLen(2))
})
It("handles filter parameters", func() {
// Properly encode the URL with query parameters
baseURL := "/song"
params := url.Values{}
params.Add("title", "Test Song 1")
fullURL := baseURL + "?" + params.Encode()
req := createAuthenticatedRequest("GET", fullURL, nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
// Mock doesn't implement filtering, but request should be processed
Expect(len(response)).To(BeNumerically(">=", 1))
})
})
})
Describe("Response headers and content type", func() {
It("sets correct content type for JSON responses", func() {
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
})
It("includes total count header when available", func() {
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
// The X-Total-Count header might be set by the REST framework
// We just verify the request is processed successfully
})
})
Describe("Edge cases and error handling", func() {
Context("when repository is unavailable", func() {
It("handles database connection errors", func() {
mfRepo.SetError(true)
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Context("when no songs exist", func() {
It("returns empty array when no songs are found", func() {
mfRepo.SetData(model.MediaFiles{}) // Empty dataset
req := createAuthenticatedRequest("GET", "/song", nil)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var response []model.MediaFile
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response).To(HaveLen(0))
})
})
})
Describe("Authentication middleware integration", func() {
Context("with different user types", func() {
It("works with admin users", func() {
adminUser := model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
err := userRepo.Put(&adminUser)
Expect(err).ToNot(HaveOccurred())
// Create JWT token for admin user
token, err := auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
req := createUnauthenticatedRequest("GET", "/song", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("works with regular users", func() {
regularUser := model.User{
ID: "user-2",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
err := userRepo.Put(&regularUser)
Expect(err).ToNot(HaveOccurred())
// Create JWT token for regular user
token, err := auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
req := createUnauthenticatedRequest("GET", "/song", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
})
Context("with missing authentication context", func() {
It("rejects requests without user context", func() {
req := createUnauthenticatedRequest("GET", "/song", nil)
// No authentication header added
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("rejects requests with invalid JWT tokens", func() {
req := createUnauthenticatedRequest("GET", "/song", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer invalid.token.here")
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
})

View File

@@ -0,0 +1,17 @@
package nativeapi
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestNativeApi(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Native RESTful API Suite")
}

View File

@@ -0,0 +1,244 @@
package nativeapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/req"
)
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
func getPlaylist(ds model.DataStore) http.HandlerFunc {
// Add a middleware to capture the playlistId
wrapper := func(handler restHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
constructor := func(ctx context.Context) rest.Repository {
plsRepo := ds.Playlist(ctx)
plsId := chi.URLParam(r, "playlistId")
p := req.Params(r)
start := p.Int64Or("_start", 0)
return plsRepo.Tracks(plsId, start == 0)
}
handler(constructor).ServeHTTP(w, r)
}
}
return func(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("accept")
if strings.ToLower(accept) == "audio/x-mpegurl" {
handleExportPlaylist(ds)(w, r)
return
}
wrapper(rest.GetAll)(w, r)
}
}
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
// Add a middleware to capture the playlistId
wrapper := func(handler restHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
constructor := func(ctx context.Context) rest.Repository {
plsRepo := ds.Playlist(ctx)
plsId := chi.URLParam(r, "playlistId")
return plsRepo.Tracks(plsId, true)
}
handler(constructor).ServeHTTP(w, r)
}
}
return wrapper(rest.Get)
}
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pls, err := playlists.ImportM3U(ctx, r.Body)
if err != nil {
log.Error(r.Context(), "Error parsing playlist", err)
// TODO: consider returning StatusBadRequest for playlists that are malformed
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
_, err = w.Write([]byte(pls.ToM3U8()))
if err != nil {
log.Error(ctx, "Error sending m3u contents", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
plsRepo := ds.Playlist(ctx)
plsId := chi.URLParam(r, "playlistId")
pls, err := plsRepo.GetWithTracks(plsId, true, false)
if errors.Is(err, model.ErrNotFound) {
log.Warn(r.Context(), "Playlist not found", "playlistId", plsId)
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
log.Error(r.Context(), "Error retrieving the playlist", "playlistId", plsId, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", pls.Name)
w.Header().Set("Content-Type", "audio/x-mpegurl")
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name)
w.Header().Set("Content-Disposition", disposition)
_, err = w.Write([]byte(pls.ToM3U8()))
if err != nil {
log.Error(ctx, "Error sending playlist", "name", pls.Name)
return
}
}
}
func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
ids, _ := p.Strings("id")
err := ds.WithTxImmediate(func(tx model.DataStore) error {
tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true)
return tracksRepo.Delete(ids...)
})
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0])
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeDeleteManyResponse(w, r, ids)
}
}
func addToPlaylist(ds model.DataStore) http.HandlerFunc {
type addTracksPayload struct {
Ids []string `json:"ids"`
AlbumIds []string `json:"albumIds"`
ArtistIds []string `json:"artistIds"`
Discs []model.DiscID `json:"discs"`
}
return func(w http.ResponseWriter, r *http.Request) {
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
var payload addTracksPayload
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
count, c := 0, 0
if c, err = tracksRepo.Add(payload.Ids); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
count += c
if c, err = tracksRepo.AddAlbums(payload.AlbumIds); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
count += c
if c, err = tracksRepo.AddArtists(payload.ArtistIds); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
count += c
if c, err = tracksRepo.AddDiscs(payload.Discs); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
count += c
// Must return an object with an ID, to satisfy ReactAdmin `create` call
_, err = fmt.Fprintf(w, `{"added":%d}`, count)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func reorderItem(ds model.DataStore) http.HandlerFunc {
type reorderPayload struct {
InsertBefore string `json:"insert_before"`
}
return func(w http.ResponseWriter, r *http.Request) {
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
id := p.IntOr(":id", 0)
if id == 0 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var payload reorderPayload
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
newPos, err := strconv.Atoi(payload.InsertBefore)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
err = tracksRepo.Reorder(id, newPos)
if errors.Is(err, rest.ErrPermissionDenied) {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_, err = w.Write([]byte(fmt.Sprintf(`{"id":"%d"}`, id)))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := req.Params(r)
trackId, _ := p.String(":id")
playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data, err := json.Marshal(playlists)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(data)
}
}

214
server/nativeapi/queue.go Normal file
View File

@@ -0,0 +1,214 @@
package nativeapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
)
type updateQueuePayload struct {
Ids *[]string `json:"ids,omitempty"`
Current *int `json:"current,omitempty"`
Position *int64 `json:"position,omitempty"`
}
// validateCurrentIndex validates that the current index is within bounds of the items array.
// Returns false if validation fails (and sends error response), true if validation passes.
func validateCurrentIndex(w http.ResponseWriter, current int, itemsLength int) bool {
if current < 0 || current >= itemsLength {
http.Error(w, "current index out of bounds", http.StatusBadRequest)
return false
}
return true
}
// retrieveExistingQueue retrieves an existing play queue for a user with proper error handling.
// Returns the queue (nil if not found) and false if an error occurred and response was sent.
func retrieveExistingQueue(ctx context.Context, w http.ResponseWriter, ds model.DataStore, userID string) (*model.PlayQueue, bool) {
existing, err := ds.PlayQueue(ctx).Retrieve(userID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Error retrieving queue", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return nil, false
}
return existing, true
}
// decodeUpdatePayload decodes the JSON payload from the request body.
// Returns false if decoding fails (and sends error response), true if successful.
func decodeUpdatePayload(w http.ResponseWriter, r *http.Request) (*updateQueuePayload, bool) {
var payload updateQueuePayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return nil, false
}
return &payload, true
}
// createMediaFileItems converts a slice of IDs to MediaFile items.
func createMediaFileItems(ids []string) []model.MediaFile {
return slice.Map(ids, func(id string) model.MediaFile {
return model.MediaFile{ID: id}
})
}
// extractUserAndClient extracts user and client from the request context.
func extractUserAndClient(ctx context.Context) (model.User, string) {
user, _ := request.UserFrom(ctx)
client, _ := request.ClientFrom(ctx)
return user, client
}
func getQueue(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, _ := request.UserFrom(ctx)
repo := ds.PlayQueue(ctx)
pq, err := repo.RetrieveWithMediaFiles(user.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Error retrieving queue", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if pq == nil {
pq = &model.PlayQueue{}
}
resp, err := json.Marshal(pq)
if err != nil {
log.Error(ctx, "Error marshalling queue", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(resp)
}
}
func saveQueue(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, ok := decodeUpdatePayload(w, r)
if !ok {
return
}
user, client := extractUserAndClient(ctx)
ids := V(payload.Ids)
items := createMediaFileItems(ids)
current := V(payload.Current)
if len(ids) > 0 && !validateCurrentIndex(w, current, len(ids)) {
return
}
pq := &model.PlayQueue{
UserID: user.ID,
Current: current,
Position: max(V(payload.Position), 0),
ChangedBy: client,
Items: items,
}
if err := ds.PlayQueue(ctx).Store(pq); err != nil {
log.Error(ctx, "Error saving queue", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func updateQueue(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Decode and validate the JSON payload
payload, ok := decodeUpdatePayload(w, r)
if !ok {
return
}
// Extract user and client information from request context
user, client := extractUserAndClient(ctx)
// Initialize play queue with user ID and client info
pq := &model.PlayQueue{UserID: user.ID, ChangedBy: client}
var cols []string // Track which columns to update in the database
// Handle queue items update
if payload.Ids != nil {
pq.Items = createMediaFileItems(*payload.Ids)
cols = append(cols, "items")
// If current index is not being updated, validate existing current index
// against the new items list to ensure it remains valid
if payload.Current == nil {
existing, ok := retrieveExistingQueue(ctx, w, ds, user.ID)
if !ok {
return
}
if existing != nil && !validateCurrentIndex(w, existing.Current, len(*payload.Ids)) {
return
}
}
}
// Handle current track index update
if payload.Current != nil {
pq.Current = *payload.Current
cols = append(cols, "current")
if payload.Ids != nil {
// If items are also being updated, validate current index against new items
if !validateCurrentIndex(w, *payload.Current, len(*payload.Ids)) {
return
}
} else {
// If only current index is being updated, validate against existing items
existing, ok := retrieveExistingQueue(ctx, w, ds, user.ID)
if !ok {
return
}
if existing != nil && !validateCurrentIndex(w, *payload.Current, len(existing.Items)) {
return
}
}
}
// Handle playback position update
if payload.Position != nil {
pq.Position = max(*payload.Position, 0) // Ensure position is non-negative
cols = append(cols, "position")
}
// If no fields were specified for update, return success without doing anything
if len(cols) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
// Perform partial update of the specified columns only
if err := ds.PlayQueue(ctx).Store(pq, cols...); err != nil {
log.Error(ctx, "Error updating queue", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func clearQueue(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, _ := request.UserFrom(ctx)
if err := ds.PlayQueue(ctx).Clear(user.ID); err != nil {
log.Error(ctx, "Error clearing queue", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,282 @@
package nativeapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Queue Endpoints", func() {
var (
ds *tests.MockDataStore
repo *tests.MockPlayQueueRepo
user model.User
userRepo *tests.MockedUserRepo
)
BeforeEach(func() {
repo = &tests.MockPlayQueueRepo{}
user = model.User{ID: "u1", UserName: "user"}
userRepo = tests.CreateMockUserRepo()
_ = userRepo.Put(&user)
ds = &tests.MockDataStore{MockedPlayQueue: repo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}}
})
Describe("POST /queue", func() {
It("saves the queue", func() {
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
ctx := request.WithUser(req.Context(), user)
ctx = request.WithClient(ctx, "TestClient")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
saveQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.Queue).ToNot(BeNil())
Expect(repo.Queue.Current).To(Equal(1))
Expect(repo.Queue.Items).To(HaveLen(2))
Expect(repo.Queue.Items[1].ID).To(Equal("s2"))
Expect(repo.Queue.ChangedBy).To(Equal("TestClient"))
})
It("saves an empty queue", func() {
payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
saveQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.Queue).ToNot(BeNil())
Expect(repo.Queue.Items).To(HaveLen(0))
})
It("returns bad request for invalid current index (negative)", func() {
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
saveQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("current index out of bounds"))
})
It("returns bad request for invalid current index (too large)", func() {
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
saveQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("current index out of bounds"))
})
It("returns bad request for malformed JSON", func() {
req := httptest.NewRequest("POST", "/queue", bytes.NewReader([]byte("invalid json")))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
saveQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns internal server error when store fails", func() {
repo.Err = true
payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
saveQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("GET /queue", func() {
It("returns the queue", func() {
queue := &model.PlayQueue{
UserID: user.ID,
Current: 1,
Position: 55,
Items: model.MediaFiles{
{ID: "track1", Title: "Song 1"},
{ID: "track2", Title: "Song 2"},
{ID: "track3", Title: "Song 3"},
},
}
repo.Queue = queue
req := httptest.NewRequest("GET", "/queue", nil)
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
getQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var resp model.PlayQueue
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.Current).To(Equal(1))
Expect(resp.Position).To(Equal(int64(55)))
Expect(resp.Items).To(HaveLen(3))
Expect(resp.Items[0].ID).To(Equal("track1"))
Expect(resp.Items[1].ID).To(Equal("track2"))
Expect(resp.Items[2].ID).To(Equal("track3"))
})
It("returns empty queue when user has no queue", func() {
req := httptest.NewRequest("GET", "/queue", nil)
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
getQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var resp model.PlayQueue
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.Items).To(BeEmpty())
Expect(resp.Current).To(Equal(0))
Expect(resp.Position).To(Equal(int64(0)))
})
It("returns internal server error when retrieve fails", func() {
repo.Err = true
req := httptest.NewRequest("GET", "/queue", nil)
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
getQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("PUT /queue", func() {
It("updates the queue fields", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}}
payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
ctx := request.WithUser(req.Context(), user)
ctx = request.WithClient(ctx, "TestClient")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.Queue).ToNot(BeNil())
Expect(repo.Queue.Current).To(Equal(2))
Expect(repo.Queue.Position).To(Equal(int64(20)))
Expect(repo.Queue.ChangedBy).To(Equal("TestClient"))
})
It("updates only ids", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.Queue.Items).To(HaveLen(2))
Expect(repo.LastCols).To(ConsistOf("items"))
})
It("updates ids and current", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.Queue.Items).To(HaveLen(2))
Expect(repo.Queue.Current).To(Equal(1))
Expect(repo.LastCols).To(ConsistOf("items", "current"))
})
It("returns bad request when new ids invalidate current", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2}
payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns bad request when current out of bounds", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}}
payload := updateQueuePayload{Current: gg.P(3)}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns bad request for malformed JSON", func() {
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader([]byte("{")))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns internal server error when store fails", func() {
repo.Err = true
payload := updateQueuePayload{Position: gg.P(int64(10))}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body))
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
updateQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("DELETE /queue", func() {
It("clears the queue", func() {
repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}}
req := httptest.NewRequest("DELETE", "/queue", nil)
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
clearQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(repo.Queue).To(BeNil())
})
It("returns internal server error when clear fails", func() {
repo.Err = true
req := httptest.NewRequest("DELETE", "/queue", nil)
req = req.WithContext(request.WithUser(req.Context(), user))
w := httptest.NewRecorder()
clearQueue(ds)(w, req)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
})

View File

@@ -0,0 +1,123 @@
package nativeapi
import (
"bytes"
"context"
"encoding/json"
"io"
"io/fs"
"path"
"strings"
"sync"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/resources"
)
type translation struct {
ID string `json:"id"`
Name string `json:"name"`
Data string `json:"data"`
}
func newTranslationRepository(context.Context) rest.Repository {
return &translationRepository{}
}
type translationRepository struct{}
func (r *translationRepository) Read(id string) (interface{}, error) {
translations, _ := loadTranslations()
if t, ok := translations[id]; ok {
return t, nil
}
return nil, rest.ErrNotFound
}
// Count simple implementation, does not support any `options`
func (r *translationRepository) Count(...rest.QueryOptions) (int64, error) {
_, count := loadTranslations()
return count, nil
}
// ReadAll simple implementation, only returns IDs. Does not support any `options`
func (r *translationRepository) ReadAll(...rest.QueryOptions) (interface{}, error) {
translations, _ := loadTranslations()
var result []translation
for _, t := range translations {
t.Data = ""
result = append(result, t)
}
return result, nil
}
func (r *translationRepository) EntityName() string {
return "translation"
}
func (r *translationRepository) NewInstance() interface{} {
return &translation{}
}
var loadTranslations = sync.OnceValues(func() (map[string]translation, int64) {
translations := make(map[string]translation)
fsys := resources.FS()
dir, err := fsys.Open(consts.I18nFolder)
if err != nil {
log.Error("Error opening translation folder", err)
return translations, 0
}
files, err := dir.(fs.ReadDirFile).ReadDir(-1)
if err != nil {
log.Error("Error reading translation folder", err)
return translations, 0
}
var languages []string
for _, f := range files {
t, err := loadTranslation(fsys, f.Name())
if err != nil {
log.Error("Error loading translation file", "file", f.Name(), err)
continue
}
translations[t.ID] = t
languages = append(languages, t.ID)
}
log.Info("Loaded translations", "languages", languages)
return translations, int64(len(translations))
})
func loadTranslation(fsys fs.FS, fileName string) (translation translation, err error) {
// Get id and full path
name := path.Base(fileName)
id := strings.TrimSuffix(name, path.Ext(name))
filePath := path.Join(consts.I18nFolder, name)
// Load translation from json file
file, err := fsys.Open(filePath)
if err != nil {
return
}
data, err := io.ReadAll(file)
if err != nil {
return
}
var out map[string]interface{}
if err = json.Unmarshal(data, &out); err != nil {
return
}
// Compress JSON
buf := new(bytes.Buffer)
if err = json.Compact(buf, data); err != nil {
return
}
translation.Data = buf.String()
translation.Name = out["languageName"].(string)
translation.ID = id
return
}
var _ rest.Repository = (*translationRepository)(nil)

View File

@@ -0,0 +1,47 @@
package nativeapi
import (
"encoding/json"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/resources"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Translations", func() {
Describe("I18n files", func() {
It("contains only valid json language files", func() {
fsys := resources.FS()
dir, _ := fsys.Open(consts.I18nFolder)
files, _ := dir.(fs.ReadDirFile).ReadDir(-1)
for _, f := range files {
name := filepath.Base(f.Name())
filePath := filepath.Join(consts.I18nFolder, name)
file, _ := fsys.Open(filePath)
data, _ := io.ReadAll(file)
var out map[string]interface{}
Expect(filepath.Ext(filePath)).To(Equal(".json"), filePath)
Expect(json.Unmarshal(data, &out)).To(BeNil(), filePath)
Expect(out["languageName"]).ToNot(BeEmpty(), filePath)
}
})
})
Describe("loadTranslation", func() {
It("loads a translation file correctly", func() {
fs := os.DirFS("ui/src")
tr, err := loadTranslation(fs, "en.json")
Expect(err).To(BeNil())
Expect(tr.ID).To(Equal("en"))
Expect(tr.Name).To(Equal("English"))
var out map[string]interface{}
Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil())
})
})
})

View File

@@ -0,0 +1,71 @@
package public
import (
"context"
"errors"
"net/http"
"net/url"
"path"
"strconv"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
)
func ImageURL(r *http.Request, artID model.ArtworkID, size int) string {
token := encodeArtworkID(artID)
uri := path.Join(consts.URLPathPublicImages, token)
params := url.Values{}
if size > 0 {
params.Add("size", strconv.Itoa(size))
}
return publicURL(r, uri, params)
}
func encodeArtworkID(artID model.ArtworkID) string {
token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()})
return token
}
func decodeArtworkID(tokenString string) (model.ArtworkID, error) {
token, err := auth.TokenAuth.Decode(tokenString)
if err != nil {
return model.ArtworkID{}, err
}
if token == nil {
return model.ArtworkID{}, errors.New("unauthorized")
}
err = jwt.Validate(token, jwt.WithRequiredClaim("id"))
if err != nil {
return model.ArtworkID{}, err
}
claims, err := token.AsMap(context.Background())
if err != nil {
return model.ArtworkID{}, err
}
id, ok := claims["id"].(string)
if !ok {
return model.ArtworkID{}, errors.New("invalid id type")
}
artID, err := model.ParseArtworkID(id)
if err == nil {
return artID, nil
}
// Try to default to mediafile artworkId (if used with a mediafileShare token)
return model.ParseArtworkID("mf-" + id)
}
func encodeMediafileShare(s model.Share, id string) string {
claims := map[string]any{"id": id}
if s.Format != "" {
claims["f"] = s.Format
}
if s.MaxBitRate != 0 {
claims["b"] = s.MaxBitRate
}
token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims)
return token
}

View File

@@ -0,0 +1,39 @@
package public
import (
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("encodeArtworkID", func() {
Context("Public ID Encoding", func() {
BeforeEach(func() {
auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil)
})
It("returns a reversible string representation", func() {
id := model.NewArtworkID(model.KindArtistArtwork, "1234", nil)
encoded := encodeArtworkID(id)
decoded, err := decodeArtworkID(encoded)
Expect(err).ToNot(HaveOccurred())
Expect(decoded).To(Equal(id))
})
It("fails to decode an invalid token", func() {
_, err := decodeArtworkID("xx-123")
Expect(err).To(MatchError("invalid JWT"))
})
It("defaults to kind mediafile", func() {
encoded := encodeArtworkID(model.ArtworkID{})
id, err := decodeArtworkID(encoded)
Expect(err).ToNot(HaveOccurred())
Expect(id.Kind).To(Equal(model.KindMediaFileArtwork))
})
It("fails to decode a token without an id", func() {
token, _ := auth.CreatePublicToken(map[string]any{})
_, err := decodeArtworkID(token)
Expect(err).To(HaveOccurred())
})
})
})

View File

@@ -0,0 +1,18 @@
package public
import (
"net/http"
"github.com/navidrome/navidrome/utils/req"
)
func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) {
id, err := req.Params(r).String(":id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = pub.archiver.ZipShare(r.Context(), id, w)
checkShareError(r.Context(), w, err, id)
}

View File

@@ -0,0 +1,67 @@
package public
import (
"context"
"errors"
"io"
"net/http"
"time"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/req"
)
func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
// If context is already canceled, discard request without further processing
if r.Context().Err() != nil {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
p := req.Params(r)
id, _ := p.String(":id")
if id == "" {
log.Warn(r, "No id provided")
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
artId, err := decodeArtworkID(id)
if err != nil {
log.Error(r, "Error decoding artwork id", "id", id, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
size := p.IntOr("size", 0)
square := p.BoolOr("square", false)
imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square)
switch {
case errors.Is(err, context.Canceled):
return
case errors.Is(err, model.ErrNotFound):
log.Warn(r, "Couldn't find coverArt", "id", id, err)
http.Error(w, "Artwork not found", http.StatusNotFound)
return
case errors.Is(err, artwork.ErrUnavailable):
log.Debug(r, "Item does not have artwork", "id", id, err)
http.Error(w, "Artwork not found", http.StatusNotFound)
return
case err != nil:
log.Error(r, "Error retrieving coverArt", "id", id, err)
http.Error(w, "Error retrieving coverArt", http.StatusInternalServerError)
return
}
defer imgReader.Close()
w.Header().Set("Cache-Control", "public, max-age=315360000")
w.Header().Set("Last-Modified", lastUpdate.Format(time.RFC1123))
cnt, err := io.Copy(w, imgReader)
if err != nil {
log.Warn(ctx, "Error sending image", "count", cnt, err)
}
}

View File

@@ -0,0 +1,94 @@
package public
import (
"context"
"errors"
"net/http"
"path"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/ui"
"github.com/navidrome/navidrome/utils/req"
)
func (pub *Router) handleShares(w http.ResponseWriter, r *http.Request) {
id, err := req.Params(r).String(":id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If requested file is a UI asset, just serve it
_, err = ui.BuildAssets().Open(id)
if err == nil {
pub.assetsHandler.ServeHTTP(w, r)
return
}
// If it is not, consider it a share ID
s, err := pub.share.Load(r.Context(), id)
if err != nil {
checkShareError(r.Context(), w, err, id)
return
}
s = pub.mapShareInfo(r, *s)
server.IndexWithShare(pub.ds, ui.BuildAssets(), s)(w, r)
}
func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) {
id, err := req.Params(r).String(":id")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If it is not, consider it a share ID
s, err := pub.share.Load(r.Context(), id)
if err != nil {
checkShareError(r.Context(), w, err, id)
return
}
s = pub.mapShareToM3U(r, *s)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "audio/x-mpegurl")
_, _ = w.Write([]byte(s.ToM3U8()))
}
func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) {
switch {
case errors.Is(err, model.ErrExpired):
log.Error(ctx, "Share expired", "id", id, err)
http.Error(w, "Share not available anymore", http.StatusGone)
case errors.Is(err, model.ErrNotFound):
log.Error(ctx, "Share not found", "id", id, err)
http.Error(w, "Share not found", http.StatusNotFound)
case errors.Is(err, model.ErrNotAuthorized):
log.Error(ctx, "Share is not downloadable", "id", id, err)
http.Error(w, "This share is not downloadable", http.StatusForbidden)
case err != nil:
log.Error(ctx, "Error retrieving share", "id", id, err)
http.Error(w, "Error retrieving share", http.StatusInternalServerError)
}
}
func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share {
s.URL = ShareURL(r, s.ID)
s.ImageURL = ImageURL(r, s.CoverArtID(), consts.UICoverArtSize)
for i := range s.Tracks {
s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID)
}
return &s
}
func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share {
for i := range s.Tracks {
id := encodeMediafileShare(s, s.Tracks[i].ID)
s.Tracks[i].Path = publicURL(r, path.Join(consts.URLPathPublic, "s", id), nil)
}
return &s
}

View File

@@ -0,0 +1,105 @@
package public
import (
"context"
"errors"
"io"
"net/http"
"strconv"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/req"
)
func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
tokenId, _ := p.String(":id")
info, err := decodeStreamInfo(tokenId)
if err != nil {
log.Error(ctx, "Error parsing shared stream info", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0)
if err != nil {
log.Error(ctx, "Error starting shared stream", err)
http.Error(w, "invalid request", http.StatusInternalServerError)
}
// Make sure the stream will be closed at the end, to avoid leakage
defer func() {
if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error("Error closing shared stream", "id", info.id, "file", stream.Name(), err)
}
}()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
if stream.Seekable() {
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
} else {
// If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length
w.Header().Set("Accept-Ranges", "none")
w.Header().Set("Content-Type", stream.ContentType())
estimateContentLength := p.BoolOr("estimateContentLength", false)
// if Client requests the estimated content-length, send it
if estimateContentLength {
length := strconv.Itoa(stream.EstimatedContentLength())
log.Trace(ctx, "Estimated content-length", "contentLength", length)
w.Header().Set("Content-Length", length)
}
if r.Method == http.MethodHead {
go func() { _, _ = io.Copy(io.Discard, stream) }()
} else {
c, err := io.Copy(w, stream)
if log.IsGreaterOrEqualTo(log.LevelDebug) {
if err != nil {
log.Error(ctx, "Error sending shared transcoded file", "id", info.id, err)
} else {
log.Trace(ctx, "Success sending shared transcode file", "id", info.id, "size", c)
}
}
}
}
}
type shareTrackInfo struct {
id string
format string
bitrate int
}
func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
token, err := auth.TokenAuth.Decode(tokenString)
if err != nil {
return shareTrackInfo{}, err
}
if token == nil {
return shareTrackInfo{}, errors.New("unauthorized")
}
err = jwt.Validate(token, jwt.WithRequiredClaim("id"))
if err != nil {
return shareTrackInfo{}, err
}
claims, err := token.AsMap(context.Background())
if err != nil {
return shareTrackInfo{}, err
}
id, ok := claims["id"].(string)
if !ok {
return shareTrackInfo{}, errors.New("invalid id type")
}
resp := shareTrackInfo{}
resp.id = id
resp.format, _ = claims["f"].(string)
resp.bitrate, _ = claims["b"].(int)
return resp, nil
}

85
server/public/public.go Normal file
View File

@@ -0,0 +1,85 @@
package public
import (
"net/http"
"net/url"
"path"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/ui"
)
type Router struct {
http.Handler
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
share core.Share
assetsHandler http.Handler
ds model.DataStore
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, share core.Share, archiver core.Archiver) *Router {
p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver}
shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic)
p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets())))
p.Handler = p.routes()
return p
}
func (pub *Router) routes() http.Handler {
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Group(func(r chi.Router) {
if conf.Server.DevArtworkMaxRequests > 0 {
log.Debug("Throttling public images endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
"backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
conf.Server.DevArtworkThrottleBacklogTimeout)
r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
conf.Server.DevArtworkThrottleBacklogTimeout))
}
r.HandleFunc("/img/{id}", pub.handleImages)
})
if conf.Server.EnableSharing {
r.HandleFunc("/s/{id}", pub.handleStream)
if conf.Server.EnableDownloads {
r.HandleFunc("/d/{id}", pub.handleDownloads)
}
r.HandleFunc("/{id}/m3u", pub.handleM3U)
r.HandleFunc("/{id}", pub.handleShares)
r.HandleFunc("/", pub.handleShares)
r.Handle("/*", pub.assetsHandler)
}
})
return r
}
func ShareURL(r *http.Request, id string) string {
uri := path.Join(consts.URLPathPublic, id)
return publicURL(r, uri, nil)
}
func publicURL(r *http.Request, u string, params url.Values) string {
if conf.Server.ShareURL != "" {
shareUrl, _ := url.Parse(conf.Server.ShareURL)
buildUrl, _ := url.Parse(u)
buildUrl.Scheme = shareUrl.Scheme
buildUrl.Host = shareUrl.Host
if len(params) > 0 {
buildUrl.RawQuery = params.Encode()
}
return buildUrl.String()
}
return server.AbsoluteURL(r, u, params)
}

View File

@@ -0,0 +1,17 @@
package public
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPublicEndpoints(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Public Endpoints Suite")
}

View File

@@ -0,0 +1,56 @@
package public
import (
"net/http"
"net/url"
"path"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("publicURL", func() {
When("ShareURL is set", func() {
BeforeEach(func() {
conf.Server.ShareURL = "http://share.myotherserver.com"
})
It("uses the config value instead of AbsoluteURL", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil)
uri := path.Join(consts.URLPathPublic, "123")
actual := publicURL(r, uri, nil)
Expect(actual).To(Equal("http://share.myotherserver.com/share/123"))
})
It("concatenates params if provided", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil)
uri := path.Join(consts.URLPathPublicImages, "123")
params := url.Values{
"size": []string{"300"},
}
actual := publicURL(r, uri, params)
Expect(actual).To(Equal("http://share.myotherserver.com/share/img/123?size=300"))
})
})
When("ShareURL is not set", func() {
BeforeEach(func() {
conf.Server.ShareURL = ""
})
It("uses AbsoluteURL", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil)
uri := path.Join(consts.URLPathPublic, "123")
actual := publicURL(r, uri, nil)
Expect(actual).To(Equal("https://myserver.com/share/123"))
})
It("concatenates params if provided", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil)
uri := path.Join(consts.URLPathPublicImages, "123")
params := url.Values{
"size": []string{"300"},
}
actual := publicURL(r, uri, params)
Expect(actual).To(Equal("https://myserver.com/share/img/123?size=300"))
})
})
})

183
server/serve_index.go Normal file
View File

@@ -0,0 +1,183 @@
package server
import (
"encoding/json"
"html/template"
"io"
"io/fs"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/mime"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
func Index(ds model.DataStore, fs fs.FS) http.HandlerFunc {
return serveIndex(ds, fs, nil)
}
func IndexWithShare(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.HandlerFunc {
return serveIndex(ds, fs, shareInfo)
}
// Injects the config in the `index.html` template
func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := ds.User(r.Context()).CountAll()
firstTime := c == 0 && err == nil
t, err := getIndexTemplate(r, fs)
if err != nil {
http.NotFound(w, r)
return
}
appConfig := map[string]interface{}{
"version": consts.Version,
"firstTime": firstTime,
"variousArtistsId": consts.VariousArtistsID,
"baseURL": str.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")),
"loginBackgroundURL": str.SanitizeText(conf.Server.UILoginBackgroundURL),
"welcomeMessage": str.SanitizeText(conf.Server.UIWelcomeMessage),
"maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists,
"enableTranscodingConfig": conf.Server.EnableTranscodingConfig,
"enableDownloads": conf.Server.EnableDownloads,
"enableFavourites": conf.Server.EnableFavourites,
"enableStarRating": conf.Server.EnableStarRating,
"defaultTheme": conf.Server.DefaultTheme,
"defaultLanguage": conf.Server.DefaultLanguage,
"defaultUIVolume": conf.Server.DefaultUIVolume,
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
"enableNowPlaying": conf.Server.EnableNowPlaying,
"gaTrackingId": conf.Server.GATrackingID,
"losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")),
"devActivityPanel": conf.Server.DevActivityPanel,
"enableUserEditing": conf.Server.EnableUserEditing,
"enableSharing": conf.Server.EnableSharing,
"shareURL": conf.Server.ShareURL,
"defaultDownloadableShare": conf.Server.DefaultDownloadableShare,
"devSidebarPlaylists": conf.Server.DevSidebarPlaylists,
"lastFMEnabled": conf.Server.LastFM.Enabled,
"devShowArtistPage": conf.Server.DevShowArtistPage,
"devUIShowConfig": conf.Server.DevUIShowConfig,
"devNewEventStream": conf.Server.DevNewEventStream,
"listenBrainzEnabled": conf.Server.ListenBrainz.Enabled,
"enableExternalServices": conf.Server.EnableExternalServices,
"enableReplayGain": conf.Server.EnableReplayGain,
"defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat,
"separator": string(os.PathSeparator),
"enableInspect": conf.Server.Inspect.Enabled,
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL)
}
auth := handleLoginFromHeaders(ds, r)
if auth != nil {
appConfig["auth"] = auth
}
appConfigJson, err := json.Marshal(appConfig)
if err != nil {
log.Error(r, "Error converting config to JSON", "config", appConfig, err)
} else {
log.Trace(r, "Injecting config in index.html", "config", string(appConfigJson))
}
log.Debug("UI configuration", "appConfig", appConfig)
version := consts.Version
if version != "dev" {
version = "v" + version
}
data := map[string]interface{}{
"AppConfig": string(appConfigJson),
"Version": version,
}
addShareData(r, data, shareInfo)
w.Header().Set("Content-Type", "text/html")
err = t.Execute(w, data)
if err != nil {
log.Error(r, "Could not execute `index.html` template", err)
}
}
}
func getIndexTemplate(r *http.Request, fs fs.FS) (*template.Template, error) {
t := template.New("initial state")
indexHtml, err := fs.Open("index.html")
if err != nil {
log.Error(r, "Could not find `index.html` template", err)
return nil, err
}
indexStr, err := io.ReadAll(indexHtml)
if err != nil {
log.Error(r, "Could not read from `index.html`", err)
return nil, err
}
t, err = t.Parse(string(indexStr))
if err != nil {
log.Error(r, "Error parsing `index.html`", err)
return nil, err
}
return t, nil
}
type shareData struct {
ID string `json:"id"`
Description string `json:"description"`
Downloadable bool `json:"downloadable"`
Tracks []shareTrack `json:"tracks"`
}
type shareTrack struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Artist string `json:"artist,omitempty"`
Album string `json:"album,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
Duration float32 `json:"duration,omitempty"`
}
func addShareData(r *http.Request, data map[string]interface{}, shareInfo *model.Share) {
ctx := r.Context()
if shareInfo == nil || shareInfo.ID == "" {
return
}
sd := shareData{
ID: shareInfo.ID,
Description: shareInfo.Description,
Downloadable: shareInfo.Downloadable,
}
sd.Tracks = slice.Map(shareInfo.Tracks, func(mf model.MediaFile) shareTrack {
return shareTrack{
ID: mf.ID,
Title: mf.Title,
Artist: mf.Artist,
Album: mf.Album,
Duration: mf.Duration,
UpdatedAt: mf.UpdatedAt,
}
})
shareInfoJson, err := json.Marshal(sd)
if err != nil {
log.Error(ctx, "Error converting shareInfo to JSON", "config", shareInfo, err)
} else {
log.Trace(ctx, "Injecting shareInfo in index.html", "config", string(shareInfoJson))
}
if shareInfo.Description != "" {
data["ShareDescription"] = shareInfo.Description
} else {
data["ShareDescription"] = shareInfo.Contents
}
data["ShareURL"] = shareInfo.URL
data["ShareImageURL"] = shareInfo.ImageURL
data["ShareInfo"] = string(shareInfoJson)
}

331
server/serve_index_test.go Normal file
View File

@@ -0,0 +1,331 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/conf/mime"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("serveIndex", func() {
var ds model.DataStore
mockUser := &mockedUserRepo{}
fs := os.DirFS("tests/fixtures")
BeforeEach(func() {
ds = &tests.MockDataStore{MockedUser: mockUser}
DeferCleanup(configtest.SetupConfig())
})
It("adds app_config to index.html", func() {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
Expect(w.Code).To(Equal(200))
config := extractAppConfig(w.Body.String())
Expect(config).To(BeAssignableToTypeOf(map[string]any{}))
})
It("sets firstTime = true when User table is empty", func() {
mockUser.empty = true
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("firstTime", true))
})
It("sets firstTime = false when User table is not empty", func() {
mockUser.empty = false
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("firstTime", false))
})
DescribeTable("sets configuration values",
func(configSetter func(), configKey string, expectedValue any) {
configSetter()
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue(configKey, expectedValue))
},
Entry("baseURL", func() { conf.Server.BasePath = "base_url_test" }, "baseURL", "base_url_test"),
Entry("welcomeMessage", func() { conf.Server.UIWelcomeMessage = "Hello" }, "welcomeMessage", "Hello"),
Entry("maxSidebarPlaylists", func() { conf.Server.MaxSidebarPlaylists = 42 }, "maxSidebarPlaylists", float64(42)),
Entry("enableTranscodingConfig", func() { conf.Server.EnableTranscodingConfig = true }, "enableTranscodingConfig", true),
Entry("enableDownloads", func() { conf.Server.EnableDownloads = true }, "enableDownloads", true),
Entry("enableFavourites", func() { conf.Server.EnableFavourites = true }, "enableFavourites", true),
Entry("enableStarRating", func() { conf.Server.EnableStarRating = true }, "enableStarRating", true),
Entry("defaultTheme", func() { conf.Server.DefaultTheme = "Light" }, "defaultTheme", "Light"),
Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"),
Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)),
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true),
Entry("devSidebarPlaylists", func() { conf.Server.DevSidebarPlaylists = true }, "devSidebarPlaylists", true),
Entry("lastFMEnabled", func() { conf.Server.LastFM.Enabled = true }, "lastFMEnabled", true),
Entry("devShowArtistPage", func() { conf.Server.DevShowArtistPage = true }, "devShowArtistPage", true),
Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true),
Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true),
Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true),
Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true),
Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true),
Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"),
Entry("enableInspect", func() { conf.Server.Inspect.Enabled = true }, "enableInspect", true),
Entry("defaultDownsamplingFormat", func() { conf.Server.DefaultDownsamplingFormat = "mp3" }, "defaultDownsamplingFormat", "mp3"),
Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false),
Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true),
Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true),
)
DescribeTable("sets other UI configuration values",
func(configKey string, expectedValueFunc func() any) {
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue(configKey, expectedValueFunc()))
},
Entry("version", "version", func() any { return consts.Version }),
Entry("variousArtistsId", "variousArtistsId", func() any { return consts.VariousArtistsID }),
Entry("losslessFormats", "losslessFormats", func() any {
return strings.ToUpper(strings.Join(mime.LosslessFormats, ","))
}),
Entry("separator", "separator", func() any { return string(os.PathSeparator) }),
)
Describe("loginBackgroundURL", func() {
Context("empty BaseURL", func() {
BeforeEach(func() {
conf.Server.BasePath = "/"
})
When("it is the default URL", func() {
It("points to the default URL", func() {
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURL
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURL))
})
})
When("it is the default offline URL", func() {
It("points to the offline URL", func() {
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline))
})
})
When("it is a custom URL", func() {
It("points to the offline URL", func() {
conf.Server.UILoginBackgroundURL = "https://example.com/images/1.jpg"
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "https://example.com/images/1.jpg"))
})
})
})
Context("with a BaseURL", func() {
BeforeEach(func() {
conf.Server.BasePath = "/music"
})
When("it is the default URL", func() {
It("points to the default URL with BaseURL prefix", func() {
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURL
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "/music"+consts.DefaultUILoginBackgroundURL))
})
})
When("it is the default offline URL", func() {
It("points to the offline URL", func() {
conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline))
})
})
When("it is a custom URL", func() {
It("points to the offline URL", func() {
conf.Server.UILoginBackgroundURL = "https://example.com/images/1.jpg"
r := httptest.NewRequest("GET", "/index.html", nil)
w := httptest.NewRecorder()
serveIndex(ds, fs, nil)(w, r)
config := extractAppConfig(w.Body.String())
Expect(config).To(HaveKeyWithValue("loginBackgroundURL", "https://example.com/images/1.jpg"))
})
})
})
})
})
var _ = Describe("addShareData", func() {
var (
r *http.Request
data map[string]any
shareInfo *model.Share
)
BeforeEach(func() {
data = make(map[string]any)
r = httptest.NewRequest("GET", "/", nil)
})
Context("when shareInfo is nil or has an empty ID", func() {
It("should not modify data", func() {
addShareData(r, data, nil)
Expect(data).To(BeEmpty())
shareInfo = &model.Share{}
addShareData(r, data, shareInfo)
Expect(data).To(BeEmpty())
})
})
Context("when shareInfo is not nil and has a non-empty ID", func() {
BeforeEach(func() {
shareInfo = &model.Share{
ID: "testID",
Description: "Test description",
Downloadable: true,
Tracks: []model.MediaFile{
{
ID: "track1",
Title: "Track 1",
Artist: "Artist 1",
Album: "Album 1",
Duration: 100,
UpdatedAt: time.Date(2023, time.Month(3), 27, 0, 0, 0, 0, time.UTC),
},
{
ID: "track2",
Title: "Track 2",
Artist: "Artist 2",
Album: "Album 2",
Duration: 200,
UpdatedAt: time.Date(2023, time.Month(3), 26, 0, 0, 0, 0, time.UTC),
},
},
Contents: "Test contents",
URL: "https://example.com/share/testID",
ImageURL: "https://example.com/share/testID/image",
}
})
It("should populate data with shareInfo data", func() {
addShareData(r, data, shareInfo)
Expect(data["ShareDescription"]).To(Equal(shareInfo.Description))
Expect(data["ShareURL"]).To(Equal(shareInfo.URL))
Expect(data["ShareImageURL"]).To(Equal(shareInfo.ImageURL))
var shareData shareData
err := json.Unmarshal([]byte(data["ShareInfo"].(string)), &shareData)
Expect(err).NotTo(HaveOccurred())
Expect(shareData.ID).To(Equal(shareInfo.ID))
Expect(shareData.Description).To(Equal(shareInfo.Description))
Expect(shareData.Downloadable).To(Equal(shareInfo.Downloadable))
Expect(shareData.Tracks).To(HaveLen(len(shareInfo.Tracks)))
for i, track := range shareData.Tracks {
Expect(track.ID).To(Equal(shareInfo.Tracks[i].ID))
Expect(track.Title).To(Equal(shareInfo.Tracks[i].Title))
Expect(track.Artist).To(Equal(shareInfo.Tracks[i].Artist))
Expect(track.Album).To(Equal(shareInfo.Tracks[i].Album))
Expect(track.Duration).To(Equal(shareInfo.Tracks[i].Duration))
Expect(track.UpdatedAt).To(Equal(shareInfo.Tracks[i].UpdatedAt))
}
})
Context("when shareInfo has an empty description", func() {
BeforeEach(func() {
shareInfo.Description = ""
})
It("should use shareInfo.Contents as ShareDescription", func() {
addShareData(r, data, shareInfo)
Expect(data["ShareDescription"]).To(Equal(shareInfo.Contents))
})
})
})
})
var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__=(.*);</script>`)
func extractAppConfig(body string) map[string]any {
config := make(map[string]any)
match := appConfigRegex.FindStringSubmatch(body)
if match == nil {
return config
}
str, err := strconv.Unquote(match[1])
if err != nil {
panic(fmt.Sprintf("%s: %s", match[1], err))
}
if err := json.Unmarshal([]byte(str), &config); err != nil {
panic(err)
}
return config
}
type mockedUserRepo struct {
model.UserRepository
empty bool
}
func (u *mockedUserRepo) CountAll(...model.QueryOptions) (int64, error) {
if u.empty {
return 0, nil
}
return 1, nil
}

314
server/server.go Normal file
View File

@@ -0,0 +1,314 @@
package server
import (
"bytes"
"cmp"
"context"
"crypto/tls"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/ui"
)
type Server struct {
router chi.Router
ds model.DataStore
appRoot string
broker events.Broker
insights metrics.Insights
}
func New(ds model.DataStore, broker events.Broker, insights metrics.Insights) *Server {
s := &Server{ds: ds, broker: broker, insights: insights}
initialSetup(ds)
auth.Init(s.ds)
s.initRoutes()
s.mountAuthenticationRoutes()
s.mountRootRedirector()
checkFFmpegInstallation()
checkExternalCredentials()
return s
}
func (s *Server) MountRouter(description, urlPath string, subRouter http.Handler) {
urlPath = path.Join(conf.Server.BasePath, urlPath)
log.Info(fmt.Sprintf("Mounting %s routes", description), "path", urlPath)
s.router.Group(func(r chi.Router) {
r.Mount(urlPath, subRouter)
})
}
// Run starts the server with the given address, and if specified, with TLS enabled.
func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string, tlsKey string) error {
// Mount the router for the frontend assets
s.MountRouter("WebUI", consts.URLPathUI, s.frontendAssetsHandler())
// Create a new http.Server with the specified read header timeout and handler
server := &http.Server{
ReadHeaderTimeout: consts.ServerReadHeaderTimeout,
Handler: s.router,
}
// Determine if TLS is enabled
tlsEnabled := tlsCert != "" && tlsKey != ""
// Validate TLS certificates before starting the server
if tlsEnabled {
if err := validateTLSCertificates(tlsCert, tlsKey); err != nil {
return err
}
}
// Create a listener based on the address type (either Unix socket or TCP)
var listener net.Listener
var err error
if strings.HasPrefix(addr, "unix:") {
socketPath := strings.TrimPrefix(addr, "unix:")
listener, err = createUnixSocketFile(socketPath, conf.Server.UnixSocketPerm)
if err != nil {
return err
}
} else {
addr = fmt.Sprintf("%s:%d", addr, port)
listener, err = net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("creating tcp listener: %w", err)
}
}
// Start the server in a new goroutine and send an error signal to errC if there's an error
errC := make(chan error)
go func() {
var err error
if tlsEnabled {
// Start the HTTPS server
log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey)
err = server.ServeTLS(listener, tlsCert, tlsKey)
} else {
// Start the HTTP server
err = server.Serve(listener)
}
if !errors.Is(err, http.ErrServerClosed) {
errC <- err
}
}()
// Measure server startup time
startupTime := time.Since(consts.ServerStart)
// Wait a short time to make sure the server has started successfully
select {
case err := <-errC:
log.Error(ctx, "Could not start server. Aborting", err)
return fmt.Errorf("starting server: %w", err)
case <-time.After(50 * time.Millisecond):
log.Info(ctx, "----> Navidrome server is ready!", "address", addr, "startupTime", startupTime, "tlsEnabled", tlsEnabled)
}
// Wait for a signal to terminate
select {
case err := <-errC:
return fmt.Errorf("running server: %w", err)
case <-ctx.Done():
// If the context is done (i.e. the server should stop), proceed to shutting down the server
}
// Try to stop the HTTP server gracefully
log.Info(ctx, "Stopping HTTP server")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
server.SetKeepAlivesEnabled(false)
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
log.Error(ctx, "Unexpected error in http.Shutdown()", err)
}
return nil
}
func createUnixSocketFile(socketPath string, socketPerm string) (net.Listener, error) {
// Remove the socket file if it already exists
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("removing previous unix socket file: %w", err)
}
// Create listener
listener, err := net.Listen("unix", socketPath)
if err != nil {
return nil, fmt.Errorf("creating unix socket listener: %w", err)
}
// Converts the socketPerm to uint and updates the permission of the unix socket file
perm, err := strconv.ParseUint(socketPerm, 8, 32)
if err != nil {
return nil, fmt.Errorf("parsing unix socket file permissions: %w", err)
}
err = os.Chmod(socketPath, os.FileMode(perm))
if err != nil {
return nil, fmt.Errorf("updating permission of unix socket file: %w", err)
}
return listener, nil
}
func (s *Server) initRoutes() {
s.appRoot = path.Join(conf.Server.BasePath, consts.URLPathUI)
r := chi.NewRouter()
defaultMiddlewares := chi.Middlewares{
secureMiddleware(),
corsHandler(),
middleware.RequestID,
realIPMiddleware,
middleware.Recoverer,
middleware.Heartbeat("/ping"),
robotsTXT(ui.BuildAssets()),
serverAddressMiddleware,
clientUniqueIDMiddleware,
compressMiddleware(),
loggerInjector,
JWTVerifier,
}
// Mount the Native API /events endpoint with all default middlewares, adding the authentication middlewares
if conf.Server.DevActivityPanel {
r.Group(func(r chi.Router) {
r.Use(defaultMiddlewares...)
r.Use(Authenticator(s.ds))
r.Use(JWTRefresher)
r.Handle(path.Join(conf.Server.BasePath, consts.URLPathNativeAPI, "events"), s.broker)
})
}
// Configure the router with the default middlewares and requestLogger
r.Group(func(r chi.Router) {
r.Use(defaultMiddlewares...)
r.Use(requestLogger)
s.router = r
})
}
func (s *Server) mountAuthenticationRoutes() chi.Router {
r := s.router
return r.Route(path.Join(conf.Server.BasePath, "/auth"), func(r chi.Router) {
if conf.Server.AuthRequestLimit > 0 {
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
"windowLength", conf.Server.AuthWindowLength)
rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
r.With(rateLimiter).Post("/login", login(s.ds))
} else {
log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks")
r.Post("/login", login(s.ds))
}
r.Post("/createAdmin", createAdmin(s.ds))
})
}
// Serve UI app assets
func (s *Server) mountRootRedirector() {
r := s.router
// Redirect root to UI URL
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.appRoot+"/", http.StatusFound)
})
r.Get(s.appRoot, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.appRoot+"/", http.StatusFound)
})
}
func (s *Server) frontendAssetsHandler() http.Handler {
r := chi.NewRouter()
r.Handle("/", Index(s.ds, ui.BuildAssets()))
r.Handle("/*", http.StripPrefix(s.appRoot, http.FileServer(http.FS(ui.BuildAssets()))))
return r
}
func AbsoluteURL(r *http.Request, u string, params url.Values) string {
buildUrl, _ := url.Parse(u)
if strings.HasPrefix(u, "/") {
buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path)
if conf.Server.BaseHost != "" {
buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http")
buildUrl.Host = conf.Server.BaseHost
} else {
buildUrl.Scheme = r.URL.Scheme
buildUrl.Host = r.Host
}
}
if len(params) > 0 {
buildUrl.RawQuery = params.Encode()
}
return buildUrl.String()
}
// validateTLSCertificates validates the TLS certificate and key files before starting the server.
// It provides detailed error messages for common issues like encrypted private keys.
func validateTLSCertificates(certFile, keyFile string) error {
// Read the key file to check for encryption
keyData, err := os.ReadFile(keyFile)
if err != nil {
return fmt.Errorf("reading TLS key file: %w", err)
}
// Parse PEM blocks and check for encryption
block, _ := pem.Decode(keyData)
if block == nil {
return errors.New("TLS key file does not contain a valid PEM block")
}
// Check for encrypted private key indicators
if isEncryptedPEM(block, keyData) {
return errors.New("TLS private key is encrypted (password-protected). " +
"Navidrome does not support encrypted private keys. " +
"Please decrypt your key using: openssl pkey -in <encrypted-key> -out <decrypted-key>")
}
// Try to load the certificate pair to validate it
_, err = tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("loading TLS certificate/key pair: %w", err)
}
return nil
}
// isEncryptedPEM checks if a PEM block represents an encrypted private key.
func isEncryptedPEM(block *pem.Block, rawData []byte) bool {
// Check for PKCS#8 encrypted format (BEGIN ENCRYPTED PRIVATE KEY)
if block.Type == "ENCRYPTED PRIVATE KEY" {
return true
}
// Check for legacy encrypted format with Proc-Type header
if block.Headers != nil {
if procType, ok := block.Headers["Proc-Type"]; ok && strings.Contains(procType, "ENCRYPTED") {
return true
}
}
// Also check raw data for DEK-Info header (in case pem.Decode doesn't parse headers correctly)
if bytes.Contains(rawData, []byte("DEK-Info:")) || bytes.Contains(rawData, []byte("Proc-Type: 4,ENCRYPTED")) {
return true
}
return false
}

View File

@@ -0,0 +1,17 @@
package server
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestServer(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Server Suite")
}

259
server/server_test.go Normal file
View File

@@ -0,0 +1,259 @@
package server
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("AbsoluteURL", func() {
When("BaseURL is empty", func() {
BeforeEach(func() {
conf.Server.BasePath = ""
})
It("uses the scheme/host from the request", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/rest/ping?id=123", nil)
actual := AbsoluteURL(r, "/share/img/123", url.Values{"a": []string{"xyz"}})
Expect(actual).To(Equal("https://myserver.com/share/img/123?a=xyz"))
})
It("does not override provided schema/host", func() {
r, _ := http.NewRequest("GET", "http://localhost/rest/ping?id=123", nil)
actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}})
Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz"))
})
})
When("BaseURL has only path", func() {
BeforeEach(func() {
conf.Server.BasePath = "/music"
})
It("uses the scheme/host from the request", func() {
r, _ := http.NewRequest("GET", "https://myserver.com/rest/ping?id=123", nil)
actual := AbsoluteURL(r, "/share/img/123", url.Values{"a": []string{"xyz"}})
Expect(actual).To(Equal("https://myserver.com/music/share/img/123?a=xyz"))
})
It("does not override provided schema/host", func() {
r, _ := http.NewRequest("GET", "http://localhost/rest/ping?id=123", nil)
actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}})
Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz"))
})
})
When("BaseURL has full URL", func() {
BeforeEach(func() {
conf.Server.BaseScheme = "https"
conf.Server.BaseHost = "myserver.com:8080"
conf.Server.BasePath = "/music"
})
It("use the configured scheme/host/path", func() {
r, _ := http.NewRequest("GET", "https://localhost:4533/rest/ping?id=123", nil)
actual := AbsoluteURL(r, "/share/img/123", url.Values{"a": []string{"xyz"}})
Expect(actual).To(Equal("https://myserver.com:8080/music/share/img/123?a=xyz"))
})
It("does not override provided schema/host", func() {
r, _ := http.NewRequest("GET", "http://localhost/rest/ping?id=123", nil)
actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}})
Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz"))
})
})
})
var _ = Describe("createUnixSocketFile", func() {
var socketPath string
BeforeEach(func() {
tempDir, _ := os.MkdirTemp("", "create_unix_socket_file_test")
socketPath = filepath.Join(tempDir, "test.sock")
DeferCleanup(func() {
_ = os.RemoveAll(tempDir)
})
})
When("unixSocketPerm is valid", func() {
It("updates the permission of the unix socket file and returns nil", func() {
_, err := createUnixSocketFile(socketPath, "0777")
fileInfo, _ := os.Stat(socketPath)
actualPermission := fileInfo.Mode().Perm()
Expect(actualPermission).To(Equal(os.FileMode(0777)))
Expect(err).ToNot(HaveOccurred())
})
})
When("unixSocketPerm is invalid", func() {
It("returns an error", func() {
_, err := createUnixSocketFile(socketPath, "invalid")
Expect(err).To(HaveOccurred())
})
})
When("file already exists", func() {
It("recreates the file as a socket with the right permissions", func() {
_, err := os.Create(socketPath)
Expect(err).ToNot(HaveOccurred())
Expect(os.Chmod(socketPath, os.FileMode(0777))).To(Succeed())
_, err = createUnixSocketFile(socketPath, "0600")
Expect(err).ToNot(HaveOccurred())
fileInfo, _ := os.Stat(socketPath)
Expect(fileInfo.Mode().Perm()).To(Equal(os.FileMode(0600)))
Expect(fileInfo.Mode().Type()).To(Equal(fs.ModeSocket))
})
})
})
var _ = Describe("TLS support", func() {
Describe("validateTLSCertificates", func() {
const testDataDir = "server/testdata"
When("certificate and key are valid and unencrypted", func() {
It("returns nil", func() {
certFile := filepath.Join(testDataDir, "test_cert.pem")
keyFile := filepath.Join(testDataDir, "test_key.pem")
err := validateTLSCertificates(certFile, keyFile)
Expect(err).ToNot(HaveOccurred())
})
})
When("private key is encrypted with PKCS#8 format", func() {
It("returns an error with helpful message", func() {
certFile := filepath.Join(testDataDir, "test_cert_encrypted.pem")
keyFile := filepath.Join(testDataDir, "test_key_encrypted.pem")
err := validateTLSCertificates(certFile, keyFile)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("encrypted"))
Expect(err.Error()).To(ContainSubstring("openssl"))
})
})
When("private key is encrypted with legacy format (Proc-Type header)", func() {
It("returns an error with helpful message", func() {
certFile := filepath.Join(testDataDir, "test_cert.pem")
keyFile := filepath.Join(testDataDir, "test_key_encrypted_legacy.pem")
err := validateTLSCertificates(certFile, keyFile)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("encrypted"))
Expect(err.Error()).To(ContainSubstring("openssl"))
})
})
When("key file does not exist", func() {
It("returns an error", func() {
certFile := filepath.Join(testDataDir, "test_cert.pem")
keyFile := filepath.Join(testDataDir, "nonexistent.pem")
err := validateTLSCertificates(certFile, keyFile)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("reading TLS key file"))
})
})
When("key file does not contain valid PEM", func() {
It("returns an error", func() {
// Create a temp file with invalid PEM content
tmpFile, err := os.CreateTemp("", "invalid_key*.pem")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = os.Remove(tmpFile.Name())
})
_, err = tmpFile.WriteString("not a valid PEM file")
Expect(err).ToNot(HaveOccurred())
_ = tmpFile.Close()
certFile := filepath.Join(testDataDir, "test_cert.pem")
err = validateTLSCertificates(certFile, tmpFile.Name())
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("valid PEM block"))
})
})
When("certificate file does not exist", func() {
It("returns an error from tls.LoadX509KeyPair", func() {
certFile := filepath.Join(testDataDir, "nonexistent_cert.pem")
keyFile := filepath.Join(testDataDir, "test_key.pem")
err := validateTLSCertificates(certFile, keyFile)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("loading TLS certificate/key pair"))
})
})
})
Describe("Server TLS", func() {
const testDataDir = "server/testdata"
When("server is started with valid TLS certificates", func() {
It("accepts HTTPS connections", func() {
DeferCleanup(configtest.SetupConfig())
// Create server with mock dependencies
ds := &tests.MockDataStore{}
server := New(ds, nil, nil)
// Load the test certificate to create a trusted CA pool
certFile := filepath.Join(testDataDir, "test_cert.pem")
keyFile := filepath.Join(testDataDir, "test_key.pem")
caCert, err := os.ReadFile(certFile)
Expect(err).ToNot(HaveOccurred())
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
// Create an HTTPS client that trusts our test certificate
httpClient := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caCertPool,
MinVersion: tls.VersionTLS12,
},
},
}
// Start the server in a goroutine
ctx, cancel := context.WithCancel(GinkgoT().Context())
defer cancel()
errChan := make(chan error, 1)
go func() {
errChan <- server.Run(ctx, "127.0.0.1", 14534, certFile, keyFile)
}()
Eventually(func() error {
// Make an HTTPS request to the server
resp, err := httpClient.Get("https://127.0.0.1:14534/ping")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return nil
}, 2*time.Second, 100*time.Millisecond).Should(Succeed())
// Stop the server
cancel()
// Wait for server to stop (with timeout)
select {
case <-errChan:
// Server stopped
case <-time.After(2 * time.Second):
Fail("Server did not stop in time")
}
})
})
})
})

View File

@@ -0,0 +1,285 @@
package subsonic
import (
"context"
"net/http"
"strconv"
"time"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/filter"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/run"
"github.com/navidrome/navidrome/utils/slice"
)
func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) {
p := req.Params(r)
typ, err := p.String("type")
if err != nil {
return nil, 0, err
}
var opts filter.Options
switch typ {
case "newest":
opts = filter.AlbumsByNewest()
case "recent":
opts = filter.AlbumsByRecent()
case "random":
opts = filter.AlbumsByRandom()
case "alphabeticalByName":
opts = filter.AlbumsByName()
case "alphabeticalByArtist":
opts = filter.AlbumsByArtist()
case "frequent":
opts = filter.AlbumsByFrequent()
case "starred":
opts = filter.ByStarred()
case "highest":
opts = filter.ByRating()
case "byGenre":
genre, err := p.String("genre")
if err != nil {
return nil, 0, err
}
opts = filter.ByGenre(genre)
case "byYear":
fromYear, err := p.Int("fromYear")
if err != nil {
return nil, 0, err
}
toYear, err := p.Int("toYear")
if err != nil {
return nil, 0, err
}
opts = filter.AlbumsByYear(fromYear, toYear)
default:
log.Error(r, "albumList type not implemented", "type", typ)
return nil, 0, newError(responses.ErrorGeneric, "type '%s' not implemented", typ)
}
// Get optional library IDs from musicFolderId parameter
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
return nil, 0, err
}
opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
opts.Offset = p.IntOr("offset", 0)
opts.Max = min(p.IntOr("size", 10), 500)
albums, err := api.ds.Album(r.Context()).GetAll(opts)
if err != nil {
log.Error(r, "Error retrieving albums", err)
return nil, 0, newError(responses.ErrorGeneric, "internal error")
}
count, err := api.ds.Album(r.Context()).CountAll(opts)
if err != nil {
log.Error(r, "Error counting albums", err)
return nil, 0, newError(responses.ErrorGeneric, "internal error")
}
return albums, count, nil
}
func (api *Router) GetAlbumList(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
albums, count, err := api.getAlbumList(r)
if err != nil {
return nil, err
}
w.Header().Set("x-total-count", strconv.Itoa(int(count)))
response := newResponse()
response.AlbumList = &responses.AlbumList{
Album: slice.MapWithArg(albums, r.Context(), childFromAlbum),
}
return response, nil
}
func (api *Router) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
albums, pageCount, err := api.getAlbumList(r)
if err != nil {
return nil, err
}
w.Header().Set("x-total-count", strconv.FormatInt(pageCount, 10))
response := newResponse()
response.AlbumList2 = &responses.AlbumList2{
Album: slice.MapWithArg(albums, r.Context(), buildAlbumID3),
}
return response, nil
}
func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums, model.MediaFiles, error) {
ctx := r.Context()
// Get optional library IDs from musicFolderId parameter
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
return nil, nil, nil, err
}
// Prepare variables to capture results from parallel execution
var artists model.Artists
var albums model.Albums
var mediaFiles model.MediaFiles
// Execute all three queries in parallel for better performance
err = run.Parallel(
// Query starred artists
func() error {
artistOpts := filter.ApplyArtistLibraryFilter(filter.ArtistsByStarred(), musicFolderIds)
var err error
artists, err = api.ds.Artist(ctx).GetAll(artistOpts)
if err != nil {
log.Error(r, "Error retrieving starred artists", err)
}
return err
},
// Query starred albums
func() error {
albumOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds)
var err error
albums, err = api.ds.Album(ctx).GetAll(albumOpts)
if err != nil {
log.Error(r, "Error retrieving starred albums", err)
}
return err
},
// Query starred media files
func() error {
mediaFileOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds)
var err error
mediaFiles, err = api.ds.MediaFile(ctx).GetAll(mediaFileOpts)
if err != nil {
log.Error(r, "Error retrieving starred mediaFiles", err)
}
return err
},
)()
// Return the first error if any occurred
if err != nil {
return nil, nil, nil, err
}
return artists, albums, mediaFiles, nil
}
func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) {
artists, albums, mediaFiles, err := api.getStarredItems(r)
if err != nil {
return nil, err
}
response := newResponse()
response.Starred = &responses.Starred{}
response.Starred.Artist = slice.MapWithArg(artists, r, toArtist)
response.Starred.Album = slice.MapWithArg(albums, r.Context(), childFromAlbum)
response.Starred.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile)
return response, nil
}
func (api *Router) GetStarred2(r *http.Request) (*responses.Subsonic, error) {
artists, albums, mediaFiles, err := api.getStarredItems(r)
if err != nil {
return nil, err
}
response := newResponse()
response.Starred2 = &responses.Starred2{}
response.Starred2.Artist = slice.MapWithArg(artists, r, toArtistID3)
response.Starred2.Album = slice.MapWithArg(albums, r.Context(), buildAlbumID3)
response.Starred2.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile)
return response, nil
}
func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
npInfo, err := api.scrobbler.GetNowPlaying(ctx)
if err != nil {
log.Error(r, "Error retrieving now playing list", err)
return nil, err
}
response := newResponse()
response.NowPlaying = &responses.NowPlaying{}
var i int32
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry {
return responses.NowPlayingEntry{
Child: childFromMediaFile(ctx, np.MediaFile),
UserName: np.Username,
MinutesAgo: int32(time.Since(np.Start).Minutes()),
PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything
PlayerName: np.PlayerName,
}
})
return response, nil
}
func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
size := min(p.IntOr("size", 10), 500)
genre, _ := p.String("genre")
fromYear := p.IntOr("fromYear", 0)
toYear := p.IntOr("toYear", 0)
// Get optional library IDs from musicFolderId parameter
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
return nil, err
}
opts := filter.SongsByRandom(genre, fromYear, toYear)
opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
songs, err := api.getSongs(r.Context(), 0, size, opts)
if err != nil {
log.Error(r, "Error retrieving random songs", err)
return nil, err
}
response := newResponse()
response.RandomSongs = &responses.Songs{}
response.RandomSongs.Songs = slice.MapWithArg(songs, r.Context(), childFromMediaFile)
return response, nil
}
func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
count := min(p.IntOr("count", 10), 500)
offset := p.IntOr("offset", 0)
genre, _ := p.String("genre")
// Get optional library IDs from musicFolderId parameter
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
return nil, err
}
opts := filter.ByGenre(genre)
opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
ctx := r.Context()
songs, err := api.getSongs(ctx, offset, count, opts)
if err != nil {
log.Error(r, "Error retrieving random songs", err)
return nil, err
}
response := newResponse()
response.SongsByGenre = &responses.Songs{}
response.SongsByGenre.Songs = slice.MapWithArg(songs, ctx, childFromMediaFile)
return response, nil
}
func (api *Router) getSongs(ctx context.Context, offset, size int, opts filter.Options) (model.MediaFiles, error) {
opts.Offset = offset
opts.Max = size
return api.ds.MediaFile(ctx).GetAll(opts)
}

View File

@@ -0,0 +1,542 @@
package subsonic
import (
"context"
"errors"
"net/http/httptest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/req"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Album Lists", func() {
var router *Router
var ds model.DataStore
var mockRepo *tests.MockAlbumRepo
var w *httptest.ResponseRecorder
ctx := log.NewContext(context.TODO())
BeforeEach(func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
})
Describe("GetAlbumList", func() {
It("should return list of the type specified", func() {
r := newGetRequest("type=newest", "offset=10", "size=20")
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList.Album[0].Id).To(Equal("1"))
Expect(resp.AlbumList.Album[1].Id).To(Equal("2"))
Expect(w.Header().Get("x-total-count")).To(Equal("2"))
Expect(mockRepo.Options.Offset).To(Equal(10))
Expect(mockRepo.Options.Max).To(Equal(20))
})
It("should fail if missing type parameter", func() {
r := newGetRequest()
_, err := router.GetAlbumList(w, r)
Expect(err).To(MatchError(req.ErrMissingParam))
})
It("should return error if call fails", func() {
mockRepo.SetError(true)
r := newGetRequest("type=newest")
_, err := router.GetAlbumList(w, r)
Expect(err).To(MatchError(errSubsonic))
var subErr subError
errors.As(err, &subErr)
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
})
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter albums by specific library when musicFolderId is provided", func() {
r := newGetRequest("type=newest", "musicFolderId=1")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList.Album).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?)"))
Expect(args).To(ContainElement(1))
})
It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() {
r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList.Album).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?)"))
Expect(args).To(ContainElements(1, 2))
})
It("should return all accessible albums when no musicFolderId is provided", func() {
r := newGetRequest("type=newest")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList.Album).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
Expect(args).To(ContainElements(1, 2, 3))
})
})
})
Describe("GetAlbumList2", func() {
It("should return list of the type specified", func() {
r := newGetRequest("type=newest", "offset=10", "size=20")
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList2(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList2.Album[0].Id).To(Equal("1"))
Expect(resp.AlbumList2.Album[1].Id).To(Equal("2"))
Expect(w.Header().Get("x-total-count")).To(Equal("2"))
Expect(mockRepo.Options.Offset).To(Equal(10))
Expect(mockRepo.Options.Max).To(Equal(20))
})
It("should fail if missing type parameter", func() {
r := newGetRequest()
_, err := router.GetAlbumList2(w, r)
Expect(err).To(MatchError(req.ErrMissingParam))
})
It("should return error if call fails", func() {
mockRepo.SetError(true)
r := newGetRequest("type=newest")
_, err := router.GetAlbumList2(w, r)
Expect(err).To(MatchError(errSubsonic))
var subErr subError
errors.As(err, &subErr)
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
})
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter albums by specific library when musicFolderId is provided", func() {
r := newGetRequest("type=newest", "musicFolderId=1")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList2(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList2.Album).To(HaveLen(2))
// Verify that library filter was applied
Expect(mockRepo.Options.Filters).ToNot(BeNil())
})
It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() {
r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList2(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList2.Album).To(HaveLen(2))
// Verify that library filter was applied
Expect(mockRepo.Options.Filters).ToNot(BeNil())
})
It("should return all accessible albums when no musicFolderId is provided", func() {
r := newGetRequest("type=newest")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList2(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList2.Album).To(HaveLen(2))
})
})
})
Describe("GetRandomSongs", func() {
var mockMediaFileRepo *tests.MockMediaFileRepo
BeforeEach(func() {
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
})
It("should return random songs", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("size=2")
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
})
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter songs by specific library when musicFolderId is provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("size=2", "musicFolderId=1")
r = r.WithContext(ctx)
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?)"))
Expect(args).To(ContainElement(1))
})
It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("size=2", "musicFolderId=1", "musicFolderId=2")
r = r.WithContext(ctx)
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?)"))
Expect(args).To(ContainElements(1, 2))
})
It("should return all accessible songs when no musicFolderId is provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("size=2")
r = r.WithContext(ctx)
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
Expect(args).To(ContainElements(1, 2, 3))
})
})
})
Describe("GetSongsByGenre", func() {
var mockMediaFileRepo *tests.MockMediaFileRepo
BeforeEach(func() {
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
})
It("should return songs by genre", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("count=2", "genre=rock")
resp, err := router.GetSongsByGenre(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
})
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter songs by specific library when musicFolderId is provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("count=2", "genre=rock", "musicFolderId=1")
r = r.WithContext(ctx)
resp, err := router.GetSongsByGenre(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?)"))
Expect(args).To(ContainElement(1))
})
It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("count=2", "genre=rock", "musicFolderId=1", "musicFolderId=2")
r = r.WithContext(ctx)
resp, err := router.GetSongsByGenre(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?)"))
Expect(args).To(ContainElements(1, 2))
})
It("should return all accessible songs when no musicFolderId is provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("count=2", "genre=rock")
r = r.WithContext(ctx)
resp, err := router.GetSongsByGenre(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
Expect(args).To(ContainElements(1, 2, 3))
})
})
})
Describe("GetStarred", func() {
var mockArtistRepo *tests.MockArtistRepo
var mockAlbumRepo *tests.MockAlbumRepo
var mockMediaFileRepo *tests.MockMediaFileRepo
BeforeEach(func() {
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
})
It("should return starred items", func() {
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
r := newGetRequest()
resp, err := router.GetStarred(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred.Artist).To(HaveLen(1))
Expect(resp.Starred.Album).To(HaveLen(1))
Expect(resp.Starred.Song).To(HaveLen(1))
})
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter starred items by specific library when musicFolderId is provided", func() {
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
r := newGetRequest("musicFolderId=1")
r = r.WithContext(ctx)
resp, err := router.GetStarred(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred.Artist).To(HaveLen(1))
Expect(resp.Starred.Album).To(HaveLen(1))
Expect(resp.Starred.Song).To(HaveLen(1))
// Verify that library filter was applied to all types
artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql()
Expect(artistQuery).To(ContainSubstring("library_id IN (?)"))
Expect(artistArgs).To(ContainElement(1))
})
})
})
Describe("GetStarred2", func() {
var mockArtistRepo *tests.MockArtistRepo
var mockAlbumRepo *tests.MockAlbumRepo
var mockMediaFileRepo *tests.MockMediaFileRepo
BeforeEach(func() {
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
})
It("should return starred items in ID3 format", func() {
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
r := newGetRequest()
resp, err := router.GetStarred2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred2.Artist).To(HaveLen(1))
Expect(resp.Starred2.Album).To(HaveLen(1))
Expect(resp.Starred2.Song).To(HaveLen(1))
})
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter starred items by specific library when musicFolderId is provided", func() {
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
r := newGetRequest("musicFolderId=1")
r = r.WithContext(ctx)
resp, err := router.GetStarred2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred2.Artist).To(HaveLen(1))
Expect(resp.Starred2.Album).To(HaveLen(1))
Expect(resp.Starred2.Song).To(HaveLen(1))
// Verify that library filter was applied to all types
artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql()
Expect(artistQuery).To(ContainSubstring("library_id IN (?)"))
Expect(artistArgs).To(ContainElement(1))
})
})
})
})

361
server/subsonic/api.go Normal file
View File

@@ -0,0 +1,361 @@
package subsonic
import (
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
)
const Version = "1.16.1"
type handler = func(*http.Request) (*responses.Subsonic, error)
type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error)
type Router struct {
http.Handler
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
provider external.Provider
playlists core.Playlists
scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
metrics metrics.Metrics
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics,
) *Router {
r := &Router{
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
provider: provider,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
metrics: metrics,
}
r.Handler = r.routes()
return r
}
func (api *Router) routes() http.Handler {
r := chi.NewRouter()
if conf.Server.Prometheus.Enabled {
r.Use(recordStats(api.metrics))
}
r.Use(postFormToQueryParams)
// Public
h(r, "getOpenSubsonicExtensions", api.GetOpenSubsonicExtensions)
// Protected
r.Group(func(r chi.Router) {
r.Use(checkRequiredParameters)
r.Use(authenticate(api.ds))
r.Use(server.UpdateLastAccessMiddleware(api.ds))
// Subsonic endpoints, grouped by controller
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "ping", api.Ping)
h(r, "getLicense", api.GetLicense)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getMusicFolders", api.GetMusicFolders)
h(r, "getIndexes", api.GetIndexes)
h(r, "getArtists", api.GetArtists)
h(r, "getGenres", api.GetGenres)
h(r, "getMusicDirectory", api.GetMusicDirectory)
h(r, "getArtist", api.GetArtist)
h(r, "getAlbum", api.GetAlbum)
h(r, "getSong", api.GetSong)
h(r, "getAlbumInfo", api.GetAlbumInfo)
h(r, "getAlbumInfo2", api.GetAlbumInfo)
h(r, "getArtistInfo", api.GetArtistInfo)
h(r, "getArtistInfo2", api.GetArtistInfo2)
h(r, "getTopSongs", api.GetTopSongs)
h(r, "getSimilarSongs", api.GetSimilarSongs)
h(r, "getSimilarSongs2", api.GetSimilarSongs2)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
hr(r, "getAlbumList", api.GetAlbumList)
hr(r, "getAlbumList2", api.GetAlbumList2)
h(r, "getStarred", api.GetStarred)
h(r, "getStarred2", api.GetStarred2)
if conf.Server.EnableNowPlaying {
h(r, "getNowPlaying", api.GetNowPlaying)
} else {
h501(r, "getNowPlaying")
}
h(r, "getRandomSongs", api.GetRandomSongs)
h(r, "getSongsByGenre", api.GetSongsByGenre)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "setRating", api.SetRating)
h(r, "star", api.Star)
h(r, "unstar", api.Unstar)
h(r, "scrobble", api.Scrobble)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getPlaylists", api.GetPlaylists)
h(r, "getPlaylist", api.GetPlaylist)
h(r, "createPlaylist", api.CreatePlaylist)
h(r, "deletePlaylist", api.DeletePlaylist)
h(r, "updatePlaylist", api.UpdatePlaylist)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getBookmarks", api.GetBookmarks)
h(r, "createBookmark", api.CreateBookmark)
h(r, "deleteBookmark", api.DeleteBookmark)
h(r, "getPlayQueue", api.GetPlayQueue)
h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
h(r, "savePlayQueue", api.SavePlayQueue)
h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "search2", api.Search2)
h(r, "search3", api.Search3)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getUser", api.GetUser)
h(r, "getUsers", api.GetUsers)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getScanStatus", api.GetScanStatus)
h(r, "startScan", api.StartScan)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
hr(r, "getAvatar", api.GetAvatar)
h(r, "getLyrics", api.GetLyrics)
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
hr(r, "stream", api.Stream)
hr(r, "download", api.Download)
})
r.Group(func(r chi.Router) {
// configure request throttling
if conf.Server.DevArtworkMaxRequests > 0 {
log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
"backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
conf.Server.DevArtworkThrottleBacklogTimeout)
r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
conf.Server.DevArtworkThrottleBacklogTimeout))
}
hr(r, "getCoverArt", api.GetCoverArt)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "createInternetRadioStation", api.CreateInternetRadio)
h(r, "deleteInternetRadioStation", api.DeleteInternetRadio)
h(r, "getInternetRadioStations", api.GetInternetRadios)
h(r, "updateInternetRadioStation", api.UpdateInternetRadio)
})
if conf.Server.EnableSharing {
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getShares", api.GetShares)
h(r, "createShare", api.CreateShare)
h(r, "updateShare", api.UpdateShare)
h(r, "deleteShare", api.DeleteShare)
})
} else {
h501(r, "getShares", "createShare", "updateShare", "deleteShare")
}
if conf.Server.Jukebox.Enabled {
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "jukeboxControl", api.JukeboxControl)
})
} else {
h501(r, "jukeboxControl")
}
// Not Implemented (yet?)
h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel",
"deletePodcastEpisode", "downloadPodcastEpisode")
h501(r, "createUser", "updateUser", "deleteUser", "changePassword")
// Deprecated/Won't implement/Out of scope endpoints
h410(r, "search")
h410(r, "getChatMessages", "addChatMessage")
h410(r, "getVideos", "getVideoInfo", "getCaptions", "hls")
})
return r
}
// Add a Subsonic handler
func h(r chi.Router, path string, f handler) {
hr(r, path, func(_ http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
return f(r)
})
}
// Add a Subsonic handler that requires an http.ResponseWriter (ex: stream, getCoverArt...)
func hr(r chi.Router, path string, f handlerRaw) {
handle := func(w http.ResponseWriter, r *http.Request) {
res, err := f(w, r)
if err != nil {
sendError(w, r, err)
return
}
if r.Context().Err() != nil {
if log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Warn(r.Context(), "Request was interrupted", "endpoint", r.URL.Path, r.Context().Err())
}
return
}
if res != nil {
sendResponse(w, r, res)
}
}
addHandler(r, path, handle)
}
// Add a handler that returns 501 - Not implemented. Used to signal that an endpoint is not implemented yet
func h501(r chi.Router, paths ...string) {
for _, path := range paths {
handle := func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Cache-Control", "no-cache")
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte("This endpoint is not implemented, but may be in future releases"))
}
addHandler(r, path, handle)
}
}
// Add a handler that returns 410 - Gone. Used to signal that an endpoint will not be implemented
func h410(r chi.Router, paths ...string) {
for _, path := range paths {
handle := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusGone)
_, _ = w.Write([]byte("This endpoint will not be implemented"))
}
addHandler(r, path, handle)
}
}
func addHandler(r chi.Router, path string, handle func(w http.ResponseWriter, r *http.Request)) {
r.HandleFunc("/"+path, handle)
r.HandleFunc("/"+path+".view", handle)
}
func mapToSubsonicError(err error) subError {
switch {
case errors.Is(err, errSubsonic): // do nothing
case errors.Is(err, req.ErrMissingParam):
err = newError(responses.ErrorMissingParameter, err.Error())
case errors.Is(err, req.ErrInvalidParam):
err = newError(responses.ErrorGeneric, err.Error())
case errors.Is(err, model.ErrNotFound):
err = newError(responses.ErrorDataNotFound, "data not found")
default:
err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err))
}
var subErr subError
errors.As(err, &subErr)
return subErr
}
func sendError(w http.ResponseWriter, r *http.Request, err error) {
subErr := mapToSubsonicError(err)
response := newResponse()
response.Status = responses.StatusFailed
response.Error = &responses.Error{Code: subErr.code, Message: subErr.Error()}
sendResponse(w, r, response)
}
func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) {
p := req.Params(r)
f, _ := p.String("f")
var response []byte
var err error
switch f {
case "json":
w.Header().Set("Content-Type", "application/json")
wrapper := &responses.JsonWrapper{Subsonic: *payload}
response, err = json.Marshal(wrapper)
case "jsonp":
w.Header().Set("Content-Type", "application/javascript")
callback, _ := p.String("callback")
wrapper := &responses.JsonWrapper{Subsonic: *payload}
response, err = json.Marshal(wrapper)
response = []byte(fmt.Sprintf("%s(%s)", callback, response))
default:
w.Header().Set("Content-Type", "application/xml")
response, err = xml.Marshal(payload)
}
// This should never happen, but if it does, we need to know
if err != nil {
log.Error(r.Context(), "Error marshalling response", "format", f, err)
sendError(w, r, err)
return
}
if payload.Status == responses.StatusOK {
if log.IsGreaterOrEqualTo(log.LevelTrace) {
log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK", "body", string(response))
} else {
log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK")
}
} else {
log.Warn(r.Context(), "API: Failed response", "endpoint", r.URL.Path, "error", payload.Error.Code, "message", payload.Error.Message)
}
statusPointer, ok := r.Context().Value(subsonicErrorPointer).(*int32)
if ok && statusPointer != nil {
if payload.Status == responses.StatusOK {
*statusPointer = 0
} else {
*statusPointer = payload.Error.Code
}
}
if _, err := w.Write(response); err != nil {
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
}
}

View File

@@ -0,0 +1,17 @@
package subsonic
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSubsonicApi(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Subsonic API Suite")
}

127
server/subsonic/api_test.go Normal file
View File

@@ -0,0 +1,127 @@
package subsonic
import (
"encoding/json"
"encoding/xml"
"math"
"net/http"
"net/http/httptest"
"strings"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"golang.org/x/net/context"
)
var _ = Describe("sendResponse", func() {
var (
w *httptest.ResponseRecorder
r *http.Request
payload *responses.Subsonic
)
BeforeEach(func() {
w = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/somepath", nil)
payload = &responses.Subsonic{
Status: responses.StatusOK,
Version: "1.16.1",
}
})
When("format is JSON", func() {
It("should set Content-Type to application/json and return the correct body", func() {
q := r.URL.Query()
q.Add("f", "json")
r.URL.RawQuery = q.Encode()
sendResponse(w, r, payload)
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
Expect(w.Body.String()).NotTo(BeEmpty())
var wrapper responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
Expect(err).NotTo(HaveOccurred())
Expect(wrapper.Subsonic.Status).To(Equal(payload.Status))
Expect(wrapper.Subsonic.Version).To(Equal(payload.Version))
})
})
When("format is JSONP", func() {
It("should set Content-Type to application/javascript and return the correct callback body", func() {
q := r.URL.Query()
q.Add("f", "jsonp")
q.Add("callback", "testCallback")
r.URL.RawQuery = q.Encode()
sendResponse(w, r, payload)
Expect(w.Header().Get("Content-Type")).To(Equal("application/javascript"))
body := w.Body.String()
Expect(body).To(SatisfyAll(
HavePrefix("testCallback("),
HaveSuffix(")"),
))
// Extract JSON from the JSONP response
jsonBody := body[strings.Index(body, "(")+1 : strings.LastIndex(body, ")")]
var wrapper responses.JsonWrapper
err := json.Unmarshal([]byte(jsonBody), &wrapper)
Expect(err).NotTo(HaveOccurred())
Expect(wrapper.Subsonic.Status).To(Equal(payload.Status))
})
})
When("format is XML or unspecified", func() {
It("should set Content-Type to application/xml and return the correct body", func() {
// No format specified, expecting XML by default
sendResponse(w, r, payload)
Expect(w.Header().Get("Content-Type")).To(Equal("application/xml"))
var subsonicResponse responses.Subsonic
err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse)
Expect(err).NotTo(HaveOccurred())
Expect(subsonicResponse.Status).To(Equal(payload.Status))
Expect(subsonicResponse.Version).To(Equal(payload.Version))
})
})
When("an error occurs during marshalling", func() {
It("should return a fail response", func() {
payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}}
// An +Inf value will cause an error when marshalling to JSON
payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))}
q := r.URL.Query()
q.Add("f", "json")
r.URL.RawQuery = q.Encode()
sendResponse(w, r, payload)
Expect(w.Code).To(Equal(http.StatusOK))
var wrapper responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &wrapper)
Expect(err).NotTo(HaveOccurred())
Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed))
Expect(wrapper.Subsonic.Version).To(Equal(payload.Version))
Expect(wrapper.Subsonic.Error.Message).To(ContainSubstring("json: unsupported value: +Inf"))
})
})
It("updates status pointer when an error occurs", func() {
pointer := int32(0)
ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer)
r = r.WithContext(ctx)
payload.Status = responses.StatusFailed
payload.Error = &responses.Error{Code: responses.ErrorDataNotFound}
sendResponse(w, r, payload)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(pointer).To(Equal(responses.ErrorDataNotFound))
})
})

View File

@@ -0,0 +1,208 @@
package subsonic
import (
"errors"
"net/http"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) {
user, _ := request.UserFrom(r.Context())
repo := api.ds.MediaFile(r.Context())
bookmarks, err := repo.GetBookmarks()
if err != nil {
return nil, err
}
response := newResponse()
response.Bookmarks = &responses.Bookmarks{}
response.Bookmarks.Bookmark = slice.Map(bookmarks, func(bmk model.Bookmark) responses.Bookmark {
return responses.Bookmark{
Entry: childFromMediaFile(r.Context(), bmk.Item),
Position: bmk.Position,
Username: user.UserName,
Comment: bmk.Comment,
Created: bmk.CreatedAt,
Changed: bmk.UpdatedAt,
}
})
return response, nil
}
func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
comment, _ := p.String("comment")
position := p.Int64Or("position", 0)
repo := api.ds.MediaFile(r.Context())
err = repo.AddBookmark(id, comment, position)
if err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) DeleteBookmark(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
repo := api.ds.MediaFile(r.Context())
err = repo.DeleteBookmark(id)
if err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
user, _ := request.UserFrom(r.Context())
repo := api.ds.PlayQueue(r.Context())
pq, err := repo.RetrieveWithMediaFiles(user.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, err
}
if pq == nil || len(pq.Items) == 0 {
return newResponse(), nil
}
response := newResponse()
var currentID string
if pq.Current >= 0 && pq.Current < len(pq.Items) {
currentID = pq.Items[pq.Current].ID
}
response.PlayQueue = &responses.PlayQueue{
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
Current: currentID,
Position: pq.Position,
Username: user.UserName,
Changed: pq.UpdatedAt,
ChangedBy: pq.ChangedBy,
}
return response, nil
}
func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, _ := p.Strings("id")
currentID, _ := p.String("current")
position := p.Int64Or("position", 0)
user, _ := request.UserFrom(r.Context())
client, _ := request.ClientFrom(r.Context())
items := slice.Map(ids, func(id string) model.MediaFile {
return model.MediaFile{ID: id}
})
currentIndex := 0
for i, id := range ids {
if id == currentID {
currentIndex = i
break
}
}
pq := &model.PlayQueue{
UserID: user.ID,
Current: currentIndex,
Position: position,
ChangedBy: client,
Items: items,
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
}
repo := api.ds.PlayQueue(r.Context())
err := repo.Store(pq)
if err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
user, _ := request.UserFrom(r.Context())
repo := api.ds.PlayQueue(r.Context())
pq, err := repo.RetrieveWithMediaFiles(user.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, err
}
if pq == nil || len(pq.Items) == 0 {
return newResponse(), nil
}
response := newResponse()
var index *int
if len(pq.Items) > 0 {
index = &pq.Current
}
response.PlayQueueByIndex = &responses.PlayQueueByIndex{
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
CurrentIndex: index,
Position: pq.Position,
Username: user.UserName,
Changed: pq.UpdatedAt,
ChangedBy: pq.ChangedBy,
}
return response, nil
}
func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, _ := p.Strings("id")
position := p.Int64Or("position", 0)
var err error
var currentIndex int
if len(ids) > 0 {
currentIndex, err = p.Int("currentIndex")
if err != nil || currentIndex < 0 || currentIndex >= len(ids) {
return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
}
}
items := slice.Map(ids, func(id string) model.MediaFile {
return model.MediaFile{ID: id}
})
user, _ := request.UserFrom(r.Context())
client, _ := request.ClientFrom(r.Context())
pq := &model.PlayQueue{
UserID: user.ID,
Current: currentIndex,
Position: position,
ChangedBy: client,
Items: items,
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
}
repo := api.ds.PlayQueue(r.Context())
err = repo.Store(pq)
if err != nil {
return nil, err
}
return newResponse(), nil
}

470
server/subsonic/browsing.go Normal file
View File

@@ -0,0 +1,470 @@
package subsonic
import (
"context"
"errors"
"net/http"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic/filter"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) {
libraries := getUserAccessibleLibraries(r.Context())
folders := make([]responses.MusicFolder, len(libraries))
for i, f := range libraries {
folders[i].Id = int32(f.ID)
folders[i].Name = f.Name
}
response := newResponse()
response.MusicFolders = &responses.MusicFolders{Folders: folders}
return response, nil
}
func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) {
ctx := r.Context()
lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
if err != nil {
log.Error(ctx, "Error retrieving last scan start time", err)
return nil, 0, err
}
lastScan := time.Now()
if lastScanStr != "" {
lastScan, err = time.Parse(time.RFC3339, lastScanStr)
}
var indexes model.ArtistIndexes
if lastScan.After(ifModifiedSince) {
indexes, err = api.ds.Artist(ctx).GetIndex(false, libIds, model.RoleAlbumArtist)
if err != nil {
log.Error(ctx, "Error retrieving Indexes", err)
return nil, 0, err
}
if len(indexes) == 0 {
log.Debug(ctx, "No artists found in library", "libId", libIds)
return nil, 0, newError(responses.ErrorDataNotFound, "Library not found or empty")
}
}
return indexes, lastScan.UnixMilli(), err
}
func (api *Router) getArtistIndex(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) {
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
if err != nil {
return nil, err
}
res := &responses.Indexes{
IgnoredArticles: conf.Server.IgnoredArticles,
LastModified: modified,
}
res.Index = make([]responses.Index, len(indexes))
for i, idx := range indexes {
res.Index[i].Name = idx.ID
res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtist)
}
return res, nil
}
func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Artists, error) {
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
if err != nil {
return nil, err
}
res := &responses.Artists{
IgnoredArticles: conf.Server.IgnoredArticles,
LastModified: modified,
}
res.Index = make([]responses.IndexID3, len(indexes))
for i, idx := range indexes {
res.Index[i].Name = idx.ID
res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtistID3)
}
return res, nil
}
func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
musicFolderIds, _ := selectedMusicFolderIds(r, false)
ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{})
res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince)
if err != nil {
return nil, err
}
response := newResponse()
response.Indexes = res
return response, nil
}
func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) {
musicFolderIds, _ := selectedMusicFolderIds(r, false)
res, err := api.getArtistIndexID3(r, musicFolderIds, time.Time{})
if err != nil {
return nil, err
}
response := newResponse()
response.Artist = res
return response, nil
}
func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, _ := p.String("id")
ctx := r.Context()
entity, err := model.GetEntityByID(ctx, api.ds, id)
if errors.Is(err, model.ErrNotFound) {
log.Error(r, "Requested ID not found ", "id", id)
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
}
if err != nil {
log.Error(err)
return nil, err
}
var dir *responses.Directory
switch v := entity.(type) {
case *model.Artist:
dir, err = api.buildArtistDirectory(ctx, v)
case *model.Album:
dir, err = api.buildAlbumDirectory(ctx, v)
default:
log.Error(r, "Requested ID of invalid type", "id", id, "entity", v)
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
}
if err != nil {
log.Error(err)
return nil, err
}
response := newResponse()
response.Directory = dir
return response, nil
}
func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, _ := p.String("id")
ctx := r.Context()
artist, err := api.ds.Artist(ctx).Get(id)
if errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Requested ArtistID not found ", "id", id)
return nil, newError(responses.ErrorDataNotFound, "Artist not found")
}
if err != nil {
log.Error(ctx, "Error retrieving artist", "id", id, err)
return nil, err
}
response := newResponse()
response.ArtistWithAlbumsID3, err = api.buildArtist(r, artist)
if err != nil {
log.Error(ctx, "Error retrieving albums by artist", "id", artist.ID, "name", artist.Name, err)
}
return response, err
}
func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, _ := p.String("id")
ctx := r.Context()
album, err := api.ds.Album(ctx).Get(id)
if errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Requested AlbumID not found ", "id", id)
return nil, newError(responses.ErrorDataNotFound, "Album not found")
}
if err != nil {
log.Error(ctx, "Error retrieving album", "id", id, err)
return nil, err
}
mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(id))
if err != nil {
log.Error(ctx, "Error retrieving tracks from album", "id", id, "name", album.Name, err)
return nil, err
}
response := newResponse()
response.AlbumWithSongsID3 = api.buildAlbum(ctx, album, mfs)
return response, nil
}
func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, err := p.String("id")
ctx := r.Context()
if err != nil {
return nil, err
}
album, err := api.provider.UpdateAlbumInfo(ctx, id)
if err != nil {
return nil, err
}
response := newResponse()
response.AlbumInfo = &responses.AlbumInfo{}
response.AlbumInfo.Notes = album.Description
response.AlbumInfo.SmallImageUrl = public.ImageURL(r, album.CoverArtID(), 300)
response.AlbumInfo.MediumImageUrl = public.ImageURL(r, album.CoverArtID(), 600)
response.AlbumInfo.LargeImageUrl = public.ImageURL(r, album.CoverArtID(), 1200)
response.AlbumInfo.LastFmUrl = album.ExternalUrl
response.AlbumInfo.MusicBrainzID = album.MbzAlbumID
return response, nil
}
func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, _ := p.String("id")
ctx := r.Context()
mf, err := api.ds.MediaFile(ctx).Get(id)
if errors.Is(err, model.ErrNotFound) {
log.Error(r, "Requested MediaFileID not found ", "id", id)
return nil, newError(responses.ErrorDataNotFound, "Song not found")
}
if err != nil {
log.Error(r, "Error retrieving MediaFile", "id", id, err)
return nil, err
}
response := newResponse()
child := childFromMediaFile(ctx, *mf)
response.Song = &child
return response, nil
}
func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"})
if err != nil {
log.Error(r, err)
return nil, err
}
for i, g := range genres {
if g.Name == "" {
genres[i].Name = "<Empty>"
}
}
response := newResponse()
response.Genres = toGenres(genres)
return response, nil
}
func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *model.Artists, error) {
ctx := r.Context()
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, nil, err
}
count := p.IntOr("count", 20)
includeNotPresent := p.BoolOr("includeNotPresent", false)
artist, err := api.provider.UpdateArtistInfo(ctx, id, count, includeNotPresent)
if err != nil {
return nil, nil, err
}
base := responses.ArtistInfoBase{}
base.Biography = artist.Biography
base.SmallImageUrl = public.ImageURL(r, artist.CoverArtID(), 300)
base.MediumImageUrl = public.ImageURL(r, artist.CoverArtID(), 600)
base.LargeImageUrl = public.ImageURL(r, artist.CoverArtID(), 1200)
base.LastFmUrl = artist.ExternalUrl
base.MusicBrainzID = artist.MbzArtistID
return &base, &artist.SimilarArtists, nil
}
func (api *Router) GetArtistInfo(r *http.Request) (*responses.Subsonic, error) {
base, similarArtists, err := api.getArtistInfo(r)
if err != nil {
return nil, err
}
response := newResponse()
response.ArtistInfo = &responses.ArtistInfo{}
response.ArtistInfo.ArtistInfoBase = *base
for _, s := range *similarArtists {
similar := toArtist(r, s)
if s.ID == "" {
similar.Id = "-1"
}
response.ArtistInfo.SimilarArtist = append(response.ArtistInfo.SimilarArtist, similar)
}
return response, nil
}
func (api *Router) GetArtistInfo2(r *http.Request) (*responses.Subsonic, error) {
base, similarArtists, err := api.getArtistInfo(r)
if err != nil {
return nil, err
}
response := newResponse()
response.ArtistInfo2 = &responses.ArtistInfo2{}
response.ArtistInfo2.ArtistInfoBase = *base
for _, s := range *similarArtists {
similar := toArtistID3(r, s)
if s.ID == "" {
similar.Id = "-1"
}
response.ArtistInfo2.SimilarArtist = append(response.ArtistInfo2.SimilarArtist, similar)
}
return response, nil
}
func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
count := p.IntOr("count", 50)
songs, err := api.provider.ArtistRadio(ctx, id, count)
if err != nil {
return nil, err
}
response := newResponse()
response.SimilarSongs = &responses.SimilarSongs{
Song: slice.MapWithArg(songs, ctx, childFromMediaFile),
}
return response, nil
}
func (api *Router) GetSimilarSongs2(r *http.Request) (*responses.Subsonic, error) {
res, err := api.GetSimilarSongs(r)
if err != nil {
return nil, err
}
response := newResponse()
response.SimilarSongs2 = &responses.SimilarSongs2{
Song: res.SimilarSongs.Song,
}
return response, nil
}
func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
p := req.Params(r)
artist, err := p.String("artist")
if err != nil {
return nil, err
}
count := p.IntOr("count", 50)
songs, err := api.provider.TopSongs(ctx, artist, count)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, err
}
response := newResponse()
response.TopSongs = &responses.TopSongs{
Song: slice.MapWithArg(songs, ctx, childFromMediaFile),
}
return response, nil
}
func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artist) (*responses.Directory, error) {
dir := &responses.Directory{}
dir.Id = artist.ID
dir.Name = artist.Name
dir.PlayCount = artist.PlayCount
if artist.PlayCount > 0 {
dir.Played = artist.PlayDate
}
dir.AlbumCount = getArtistAlbumCount(artist)
dir.UserRating = int32(artist.Rating)
if artist.Starred {
dir.Starred = artist.StarredAt
}
albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID))
if err != nil {
return nil, err
}
dir.Child = slice.MapWithArg(albums, ctx, childFromAlbum)
return dir, nil
}
func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*responses.ArtistWithAlbumsID3, error) {
ctx := r.Context()
a := &responses.ArtistWithAlbumsID3{}
a.ArtistID3 = toArtistID3(r, *artist)
albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID))
if err != nil {
return nil, err
}
a.Album = slice.MapWithArg(albums, ctx, buildAlbumID3)
return a, nil
}
func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) {
dir := &responses.Directory{}
dir.Id = album.ID
dir.Name = album.Name
dir.Parent = album.AlbumArtistID
dir.PlayCount = album.PlayCount
if album.PlayCount > 0 {
dir.Played = album.PlayDate
}
dir.UserRating = int32(album.Rating)
dir.SongCount = int32(album.SongCount)
dir.CoverArt = album.CoverArtID().String()
if album.Starred {
dir.Starred = album.StarredAt
}
mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(album.ID))
if err != nil {
return nil, err
}
dir.Child = slice.MapWithArg(mfs, ctx, childFromMediaFile)
return dir, nil
}
func (api *Router) buildAlbum(ctx context.Context, album *model.Album, mfs model.MediaFiles) *responses.AlbumWithSongsID3 {
dir := &responses.AlbumWithSongsID3{}
dir.AlbumID3 = buildAlbumID3(ctx, *album)
dir.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile)
return dir
}

View File

@@ -0,0 +1,160 @@
package subsonic
import (
"context"
"fmt"
"net/http/httptest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func contextWithUser(ctx context.Context, userID string, libraryIDs ...int) context.Context {
libraries := make([]model.Library, len(libraryIDs))
for i, id := range libraryIDs {
libraries[i] = model.Library{ID: id, Name: fmt.Sprintf("Test Library %d", id), Path: fmt.Sprintf("/music/library%d", id)}
}
user := model.User{
ID: userID,
Libraries: libraries,
}
return request.WithUser(ctx, user)
}
var _ = Describe("Browsing", func() {
var api *Router
var ctx context.Context
var ds model.DataStore
BeforeEach(func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
api = &Router{ds: ds}
ctx = context.Background()
})
Describe("GetMusicFolders", func() {
It("should return all libraries the user has access", func() {
// Create mock user with libraries
ctx := contextWithUser(ctx, "user-id", 1, 2, 3)
// Create request
r := httptest.NewRequest("GET", "/rest/getMusicFolders", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetMusicFolders(r)
// Verify results
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
Expect(response.MusicFolders).ToNot(BeNil())
Expect(response.MusicFolders.Folders).To(HaveLen(3))
Expect(response.MusicFolders.Folders[0].Name).To(Equal("Test Library 1"))
Expect(response.MusicFolders.Folders[1].Name).To(Equal("Test Library 2"))
Expect(response.MusicFolders.Folders[2].Name).To(Equal("Test Library 3"))
})
})
Describe("GetIndexes", func() {
It("should validate user access to the specified musicFolderId", func() {
// Create mock user with access to library 1 only
ctx = contextWithUser(ctx, "user-id", 1)
// Create request with musicFolderId=2 (not accessible)
r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetIndexes(r)
// Should return error due to lack of access
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
})
It("should default to first accessible library when no musicFolderId specified", func() {
// Create mock user with access to libraries 2 and 3
ctx = contextWithUser(ctx, "user-id", 2, 3)
// Setup minimal mock library data for working tests
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
{ID: 3, Name: "Test Library 3", Path: "/music/library3"},
})
// Setup mock artist data
mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo)
mockArtistRepo.SetData(model.Artists{
{ID: "1", Name: "Test Artist 1"},
{ID: "2", Name: "Test Artist 2"},
})
// Create request without musicFolderId
r := httptest.NewRequest("GET", "/rest/getIndexes", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetIndexes(r)
// Should succeed and use first accessible library (2)
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
Expect(response.Indexes).ToNot(BeNil())
})
})
Describe("GetArtists", func() {
It("should validate user access to the specified musicFolderId", func() {
// Create mock user with access to library 1 only
ctx = contextWithUser(ctx, "user-id", 1)
// Create request with musicFolderId=3 (not accessible)
r := httptest.NewRequest("GET", "/rest/getArtists?musicFolderId=3", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetArtists(r)
// Should return error due to lack of access
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
})
It("should default to first accessible library when no musicFolderId specified", func() {
// Create mock user with access to libraries 1 and 2
ctx = contextWithUser(ctx, "user-id", 1, 2)
// Setup minimal mock library data for working tests
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{
{ID: 1, Name: "Test Library 1", Path: "/music/library1"},
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
})
// Setup mock artist data
mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo)
mockArtistRepo.SetData(model.Artists{
{ID: "1", Name: "Test Artist 1"},
{ID: "2", Name: "Test Artist 2"},
})
// Create request without musicFolderId
r := httptest.NewRequest("GET", "/rest/getArtists", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetArtists(r)
// Should succeed and use first accessible library (1)
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
Expect(response.Artist).ToNot(BeNil())
})
})
})

View File

@@ -0,0 +1,182 @@
package filter
import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
)
type Options = model.QueryOptions
var defaultFilters = Eq{"missing": false}
func addDefaultFilters(options Options) Options {
if options.Filters == nil {
options.Filters = defaultFilters
} else {
options.Filters = And{defaultFilters, options.Filters}
}
return options
}
func AlbumsByNewest() Options {
return addDefaultFilters(addDefaultFilters(Options{Sort: "recently_added", Order: "desc"}))
}
func AlbumsByRecent() Options {
return addDefaultFilters(Options{Sort: "playDate", Order: "desc", Filters: Gt{"play_date": time.Time{}}})
}
func AlbumsByFrequent() Options {
return addDefaultFilters(Options{Sort: "playCount", Order: "desc", Filters: Gt{"play_count": 0}})
}
func AlbumsByRandom() Options {
return addDefaultFilters(Options{Sort: "random"})
}
func AlbumsByName() Options {
return addDefaultFilters(Options{Sort: "name"})
}
func AlbumsByArtist() Options {
return addDefaultFilters(Options{Sort: "artist"})
}
func AlbumsByArtistID(artistId string) Options {
filters := []Sqlizer{
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}),
}
if conf.Server.Subsonic.ArtistParticipations {
filters = append(filters,
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}),
)
}
return addDefaultFilters(Options{
Sort: "max_year",
Filters: Or(filters),
})
}
func AlbumsByYear(fromYear, toYear int) Options {
orderOption := ""
if fromYear > toYear {
fromYear, toYear = toYear, fromYear
orderOption = "desc"
}
return addDefaultFilters(Options{
Sort: "max_year",
Order: orderOption,
Filters: Or{
And{
GtOrEq{"min_year": fromYear},
LtOrEq{"min_year": toYear},
},
And{
GtOrEq{"max_year": fromYear},
LtOrEq{"max_year": toYear},
},
},
})
}
func SongsByAlbum(albumId string) Options {
return addDefaultFilters(Options{
Filters: Eq{"album_id": albumId},
Sort: "album",
})
}
func SongsByRandom(genre string, fromYear, toYear int) Options {
options := Options{
Sort: "random",
}
ff := And{}
if genre != "" {
ff = append(ff, filterByGenre(genre))
}
if fromYear != 0 {
ff = append(ff, GtOrEq{"year": fromYear})
}
if toYear != 0 {
ff = append(ff, LtOrEq{"year": toYear})
}
options.Filters = ff
return addDefaultFilters(options)
}
func SongsByArtistTitleWithLyricsFirst(artist, title string) Options {
return addDefaultFilters(Options{
Sort: "lyrics, updated_at",
Order: "desc",
Max: 1,
Filters: And{
Eq{"title": title},
Or{
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
},
},
})
}
func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options {
if len(musicFolderIds) == 0 {
return opts
}
libraryFilter := Eq{"library_id": musicFolderIds}
if opts.Filters == nil {
opts.Filters = libraryFilter
} else {
opts.Filters = And{opts.Filters, libraryFilter}
}
return opts
}
// ApplyArtistLibraryFilter applies a filter to the given Options to ensure that only artists
// that are associated with the specified music folders are included in the results.
func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options {
if len(musicFolderIds) == 0 {
return opts
}
artistLibraryFilter := Eq{"library_artist.library_id": musicFolderIds}
if opts.Filters == nil {
opts.Filters = artistLibraryFilter
} else {
opts.Filters = And{opts.Filters, artistLibraryFilter}
}
return opts
}
func ByGenre(genre string) Options {
return addDefaultFilters(Options{
Sort: "name",
Filters: filterByGenre(genre),
})
}
func filterByGenre(genre string) Sqlizer {
return persistence.Exists(`json_tree(tags, "$.genre")`, And{
Like{"value": genre},
NotEq{"atom": nil},
})
}
func ByRating() Options {
return addDefaultFilters(Options{Sort: "rating", Order: "desc", Filters: Gt{"rating": 0}})
}
func ByStarred() Options {
return addDefaultFilters(Options{Sort: "starred_at", Order: "desc", Filters: Eq{"starred": true}})
}
func ArtistsByStarred() Options {
return Options{Sort: "starred_at", Order: "desc", Filters: Eq{"starred": true}}
}

515
server/subsonic/helpers.go Normal file
View File

@@ -0,0 +1,515 @@
package subsonic
import (
"cmp"
"context"
"errors"
"fmt"
"mime"
"net/http"
"slices"
"sort"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/number"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
func newResponse() *responses.Subsonic {
return &responses.Subsonic{
Status: responses.StatusOK,
Version: Version,
Type: consts.AppName,
ServerVersion: consts.Version,
OpenSubsonic: true,
}
}
type subError struct {
code int32
messages []interface{}
}
func newError(code int32, message ...interface{}) error {
return subError{
code: code,
messages: message,
}
}
// errSubsonic and Unwrap are used to allow `errors.Is(err, errSubsonic)` to work
var errSubsonic = errors.New("subsonic API error")
func (e subError) Unwrap() error {
return fmt.Errorf("%w: %d", errSubsonic, e.code)
}
func (e subError) Error() string {
var msg string
if len(e.messages) == 0 {
msg = responses.ErrorMsg(e.code)
} else {
msg = fmt.Sprintf(e.messages[0].(string), e.messages[1:]...)
}
return msg
}
func getUser(ctx context.Context) model.User {
user, ok := request.UserFrom(ctx)
if ok {
return user
}
return model.User{}
}
func sortName(sortName, orderName string) string {
if conf.Server.PreferSortTags {
return cmp.Or(
sortName,
orderName,
)
}
return orderName
}
func getArtistAlbumCount(a *model.Artist) int32 {
// If ArtistParticipations are set, then `getArtist` will return albums
// where the artist is an album artist OR artist. Use the custom stat
// main credit for this calculation.
// Otherwise, return just the roles as album artist (precise)
if conf.Server.Subsonic.ArtistParticipations {
mainCreditStats := a.Stats[model.RoleMainCredit]
return int32(mainCreditStats.AlbumCount)
} else {
albumStats := a.Stats[model.RoleAlbumArtist]
return int32(albumStats.AlbumCount)
}
}
func toArtist(r *http.Request, a model.Artist) responses.Artist {
artist := responses.Artist{
Id: a.ID,
Name: a.Name,
UserRating: int32(a.Rating),
CoverArt: a.CoverArtID().String(),
ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600),
}
if a.Starred {
artist.Starred = a.StarredAt
}
return artist
}
func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 {
artist := responses.ArtistID3{
Id: a.ID,
Name: a.Name,
AlbumCount: getArtistAlbumCount(&a),
CoverArt: a.CoverArtID().String(),
ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600),
UserRating: int32(a.Rating),
}
if a.Starred {
artist.Starred = a.StarredAt
}
artist.OpenSubsonicArtistID3 = toOSArtistID3(r.Context(), a)
return artist
}
func toOSArtistID3(ctx context.Context, a model.Artist) *responses.OpenSubsonicArtistID3 {
player, _ := request.PlayerFrom(ctx)
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
return nil
}
artist := responses.OpenSubsonicArtistID3{
MusicBrainzId: a.MbzArtistID,
SortName: sortName(a.SortArtistName, a.OrderArtistName),
}
artist.Roles = slice.Map(a.Roles(), func(r model.Role) string { return r.String() })
return &artist
}
func toGenres(genres model.Genres) *responses.Genres {
response := make([]responses.Genre, len(genres))
for i, g := range genres {
response[i] = responses.Genre{
Name: g.Name,
SongCount: int32(g.SongCount),
AlbumCount: int32(g.AlbumCount),
}
}
return &responses.Genres{Genre: response}
}
func toItemGenres(genres model.Genres) []responses.ItemGenre {
itemGenres := make([]responses.ItemGenre, len(genres))
for i, g := range genres {
itemGenres[i] = responses.ItemGenre{Name: g.Name}
}
return itemGenres
}
func getTranscoding(ctx context.Context) (format string, bitRate int) {
if trc, ok := request.TranscodingFrom(ctx); ok {
format = trc.TargetFormat
}
if plr, ok := request.PlayerFrom(ctx); ok {
bitRate = plr.MaxBitRate
}
return
}
func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
child := responses.Child{}
child.Id = mf.ID
child.Title = mf.FullTitle()
child.IsDir = false
child.Parent = mf.AlbumID
child.Album = mf.Album
child.Year = int32(mf.Year)
child.Artist = mf.Artist
child.Genre = mf.Genre
child.Track = int32(mf.TrackNumber)
child.Duration = int32(mf.Duration)
child.Size = mf.Size
child.Suffix = mf.Suffix
child.BitRate = int32(mf.BitRate)
child.CoverArt = mf.CoverArtID().String()
child.ContentType = mf.ContentType()
player, ok := request.PlayerFrom(ctx)
if ok && player.ReportRealPath {
child.Path = mf.AbsolutePath()
} else {
child.Path = fakePath(mf)
}
child.DiscNumber = int32(mf.DiscNumber)
child.Created = &mf.BirthTime
child.AlbumId = mf.AlbumID
child.ArtistId = mf.ArtistID
child.Type = "music"
child.PlayCount = mf.PlayCount
if mf.Starred {
child.Starred = mf.StarredAt
}
child.UserRating = int32(mf.Rating)
format, _ := getTranscoding(ctx)
if mf.Suffix != "" && format != "" && mf.Suffix != format {
child.TranscodedSuffix = format
child.TranscodedContentType = mime.TypeByExtension("." + format)
}
child.BookmarkPosition = mf.BookmarkPosition
child.OpenSubsonicChild = osChildFromMediaFile(ctx, mf)
return child
}
func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.OpenSubsonicChild {
player, _ := request.PlayerFrom(ctx)
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
return nil
}
child := responses.OpenSubsonicChild{}
if mf.PlayCount > 0 {
child.Played = mf.PlayDate
}
child.Comment = mf.Comment
child.SortName = sortName(mf.SortTitle, mf.OrderTitle)
child.BPM = int32(mf.BPM)
child.MediaType = responses.MediaTypeSong
child.MusicBrainzId = mf.MbzRecordingID
child.Isrc = mf.Tags.Values(model.TagISRC)
child.ReplayGain = responses.ReplayGain{
TrackGain: mf.RGTrackGain,
AlbumGain: mf.RGAlbumGain,
TrackPeak: mf.RGTrackPeak,
AlbumPeak: mf.RGAlbumPeak,
}
child.ChannelCount = int32(mf.Channels)
child.SamplingRate = int32(mf.SampleRate)
child.BitDepth = int32(mf.BitDepth)
child.Genres = toItemGenres(mf.Genres)
child.Moods = mf.Tags.Values(model.TagMood)
child.DisplayArtist = mf.Artist
child.Artists = artistRefs(mf.Participants[model.RoleArtist])
child.DisplayAlbumArtist = mf.AlbumArtist
child.AlbumArtists = artistRefs(mf.Participants[model.RoleAlbumArtist])
var contributors []responses.Contributor
child.DisplayComposer = mf.Participants[model.RoleComposer].Join(consts.ArtistJoiner)
for role, participants := range mf.Participants {
if role == model.RoleArtist || role == model.RoleAlbumArtist {
continue
}
for _, participant := range participants {
contributors = append(contributors, responses.Contributor{
Role: role.String(),
SubRole: participant.SubRole,
Artist: responses.ArtistID3Ref{
Id: participant.ID,
Name: participant.Name,
},
})
}
}
child.Contributors = contributors
child.ExplicitStatus = mapExplicitStatus(mf.ExplicitStatus)
return &child
}
func artistRefs(participants model.ParticipantList) []responses.ArtistID3Ref {
return slice.Map(participants, func(p model.Participant) responses.ArtistID3Ref {
return responses.ArtistID3Ref{
Id: p.ID,
Name: p.Name,
}
})
}
func fakePath(mf model.MediaFile) string {
builder := strings.Builder{}
builder.WriteString(fmt.Sprintf("%s/%s/", sanitizeSlashes(mf.AlbumArtist), sanitizeSlashes(mf.Album)))
if mf.DiscNumber != 0 {
builder.WriteString(fmt.Sprintf("%02d-", mf.DiscNumber))
}
if mf.TrackNumber != 0 {
builder.WriteString(fmt.Sprintf("%02d - ", mf.TrackNumber))
}
builder.WriteString(fmt.Sprintf("%s.%s", sanitizeSlashes(mf.FullTitle()), mf.Suffix))
return builder.String()
}
func sanitizeSlashes(target string) string {
return strings.ReplaceAll(target, "/", "_")
}
func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
child := responses.Child{}
child.Id = al.ID
child.IsDir = true
child.Title = al.Name
child.Name = al.Name
child.Album = al.Name
child.Artist = al.AlbumArtist
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
child.Genre = al.Genre
child.CoverArt = al.CoverArtID().String()
child.Created = &al.CreatedAt
child.Parent = al.AlbumArtistID
child.ArtistId = al.AlbumArtistID
child.Duration = int32(al.Duration)
child.SongCount = int32(al.SongCount)
if al.Starred {
child.Starred = al.StarredAt
}
child.PlayCount = al.PlayCount
child.UserRating = int32(al.Rating)
child.OpenSubsonicChild = osChildFromAlbum(ctx, al)
return child
}
func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubsonicChild {
player, _ := request.PlayerFrom(ctx)
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
return nil
}
child := responses.OpenSubsonicChild{}
if al.PlayCount > 0 {
child.Played = al.PlayDate
}
child.MediaType = responses.MediaTypeAlbum
child.MusicBrainzId = al.MbzAlbumID
child.Genres = toItemGenres(al.Genres)
child.Moods = al.Tags.Values(model.TagMood)
child.DisplayArtist = al.AlbumArtist
child.Artists = artistRefs(al.Participants[model.RoleAlbumArtist])
child.DisplayAlbumArtist = al.AlbumArtist
child.AlbumArtists = artistRefs(al.Participants[model.RoleAlbumArtist])
child.ExplicitStatus = mapExplicitStatus(al.ExplicitStatus)
child.SortName = sortName(al.SortAlbumName, al.OrderAlbumName)
return &child
}
// toItemDate converts a string date in the formats 'YYYY-MM-DD', 'YYYY-MM' or 'YYYY' to an OS ItemDate
func toItemDate(date string) responses.ItemDate {
itemDate := responses.ItemDate{}
if date == "" {
return itemDate
}
parts := strings.Split(date, "-")
if len(parts) > 2 {
itemDate.Day = number.ParseInt[int32](parts[2])
}
if len(parts) > 1 {
itemDate.Month = number.ParseInt[int32](parts[1])
}
itemDate.Year = number.ParseInt[int32](parts[0])
return itemDate
}
func buildDiscSubtitles(a model.Album) []responses.DiscTitle {
if len(a.Discs) == 0 {
return nil
}
var discTitles []responses.DiscTitle
for num, title := range a.Discs {
discTitles = append(discTitles, responses.DiscTitle{Disc: int32(num), Title: title})
}
if len(discTitles) == 1 && discTitles[0].Title == "" {
return nil
}
sort.Slice(discTitles, func(i, j int) bool {
return discTitles[i].Disc < discTitles[j].Disc
})
return discTitles
}
func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
dir := responses.AlbumID3{}
dir.Id = album.ID
dir.Name = album.Name
dir.Artist = album.AlbumArtist
dir.ArtistId = album.AlbumArtistID
dir.CoverArt = album.CoverArtID().String()
dir.SongCount = int32(album.SongCount)
dir.Duration = int32(album.Duration)
dir.PlayCount = album.PlayCount
dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear))
dir.Genre = album.Genre
if !album.CreatedAt.IsZero() {
dir.Created = &album.CreatedAt
}
if album.Starred {
dir.Starred = album.StarredAt
}
dir.OpenSubsonicAlbumID3 = buildOSAlbumID3(ctx, album)
return dir
}
func buildOSAlbumID3(ctx context.Context, album model.Album) *responses.OpenSubsonicAlbumID3 {
player, _ := request.PlayerFrom(ctx)
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
return nil
}
dir := responses.OpenSubsonicAlbumID3{}
if album.PlayCount > 0 {
dir.Played = album.PlayDate
}
dir.UserRating = int32(album.Rating)
dir.RecordLabels = slice.Map(album.Tags.Values(model.TagRecordLabel), func(s string) responses.RecordLabel {
return responses.RecordLabel{Name: s}
})
dir.MusicBrainzId = album.MbzAlbumID
dir.Genres = toItemGenres(album.Genres)
dir.Artists = artistRefs(album.Participants[model.RoleAlbumArtist])
dir.DisplayArtist = album.AlbumArtist
dir.ReleaseTypes = album.Tags.Values(model.TagReleaseType)
dir.Moods = album.Tags.Values(model.TagMood)
dir.SortName = sortName(album.SortAlbumName, album.OrderAlbumName)
dir.OriginalReleaseDate = toItemDate(album.OriginalDate)
dir.ReleaseDate = toItemDate(album.ReleaseDate)
dir.IsCompilation = album.Compilation
dir.DiscTitles = buildDiscSubtitles(album)
dir.ExplicitStatus = mapExplicitStatus(album.ExplicitStatus)
if len(album.Tags.Values(model.TagAlbumVersion)) > 0 {
dir.Version = album.Tags.Values(model.TagAlbumVersion)[0]
}
return &dir
}
func mapExplicitStatus(explicitStatus string) string {
switch explicitStatus {
case "c":
return "clean"
case "e":
return "explicit"
}
return ""
}
func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric {
lines := make([]responses.Line, len(lyrics.Line))
for i, line := range lyrics.Line {
lines[i] = responses.Line{
Start: line.Start,
Value: line.Value,
}
}
structured := responses.StructuredLyric{
DisplayArtist: lyrics.DisplayArtist,
DisplayTitle: lyrics.DisplayTitle,
Lang: lyrics.Lang,
Line: lines,
Offset: lyrics.Offset,
Synced: lyrics.Synced,
}
if structured.DisplayArtist == "" {
structured.DisplayArtist = mf.Artist
}
if structured.DisplayTitle == "" {
structured.DisplayTitle = mf.Title
}
return structured
}
func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList {
lyricList := make(responses.StructuredLyrics, len(lyricsList))
for i, lyrics := range lyricsList {
lyricList[i] = buildStructuredLyric(mf, lyrics)
}
res := &responses.LyricsList{
StructuredLyrics: lyricList,
}
return res
}
// getUserAccessibleLibraries returns the list of libraries the current user has access to.
func getUserAccessibleLibraries(ctx context.Context) []model.Library {
user := getUser(ctx)
return user.Libraries
}
// selectedMusicFolderIds retrieves the music folder IDs from the request parameters.
// If no IDs are provided, it returns all libraries the user has access to (based on the user found in the context).
// If the parameter is required and not present, it returns an error.
// If any of the provided library IDs are invalid (don't exist or user doesn't have access), returns ErrorDataNotFound.
func selectedMusicFolderIds(r *http.Request, required bool) ([]int, error) {
p := req.Params(r)
musicFolderIds, err := p.Ints("musicFolderId")
// If the parameter is not present, it returns an error if it is required.
if errors.Is(err, req.ErrMissingParam) && required {
return nil, err
}
// Get user's accessible libraries for validation
libraries := getUserAccessibleLibraries(r.Context())
accessibleLibraryIds := slice.Map(libraries, func(lib model.Library) int { return lib.ID })
if len(musicFolderIds) > 0 {
// Validate all provided library IDs - if any are invalid, return an error
for _, id := range musicFolderIds {
if !slices.Contains(accessibleLibraryIds, id) {
return nil, newError(responses.ErrorDataNotFound, "Library %d not found or not accessible", id)
}
}
return musicFolderIds, nil
}
// If no musicFolderId is provided, return all libraries the user has access to.
return accessibleLibraryIds, nil
}

View File

@@ -0,0 +1,275 @@
package subsonic
import (
"context"
"net/http/httptest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("helpers", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
Describe("fakePath", func() {
var mf model.MediaFile
BeforeEach(func() {
mf.AlbumArtist = "Brock Berrigan"
mf.Album = "Point Pleasant"
mf.Title = "Split Decision"
mf.Suffix = "flac"
})
When("TrackNumber is not available", func() {
It("does not add any number to the filename", func() {
Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/Split Decision.flac"))
})
})
When("TrackNumber is available", func() {
It("adds the trackNumber to the path", func() {
mf.TrackNumber = 4
Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/04 - Split Decision.flac"))
})
})
When("TrackNumber and DiscNumber are available", func() {
It("adds the trackNumber to the path", func() {
mf.TrackNumber = 4
mf.DiscNumber = 1
Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/01-04 - Split Decision.flac"))
})
})
})
Describe("sanitizeSlashes", func() {
It("maps / to _", func() {
Expect(sanitizeSlashes("AC/DC")).To(Equal("AC_DC"))
})
})
Describe("sortName", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
When("PreferSortTags is false", func() {
BeforeEach(func() {
conf.Server.PreferSortTags = false
})
It("returns the order name even if sort name is provided", func() {
Expect(sortName("Sort Album Name", "Order Album Name")).To(Equal("Order Album Name"))
})
It("returns the order name if sort name is empty", func() {
Expect(sortName("", "Order Album Name")).To(Equal("Order Album Name"))
})
})
When("PreferSortTags is true", func() {
BeforeEach(func() {
conf.Server.PreferSortTags = true
})
It("returns the sort name if provided", func() {
Expect(sortName("Sort Album Name", "Order Album Name")).To(Equal("Sort Album Name"))
})
It("returns the order name if sort name is empty", func() {
Expect(sortName("", "Order Album Name")).To(Equal("Order Album Name"))
})
})
It("returns an empty string if both sort name and order name are empty", func() {
Expect(sortName("", "")).To(Equal(""))
})
})
Describe("buildDiscTitles", func() {
It("should return nil when album has no discs", func() {
album := model.Album{}
Expect(buildDiscSubtitles(album)).To(BeNil())
})
It("should return nil when album has only one disc without title", func() {
album := model.Album{
Discs: map[int]string{
1: "",
},
}
Expect(buildDiscSubtitles(album)).To(BeNil())
})
It("should return the disc title for a single disc", func() {
album := model.Album{
Discs: map[int]string{
1: "Special Edition",
},
}
Expect(buildDiscSubtitles(album)).To(Equal([]responses.DiscTitle{{Disc: 1, Title: "Special Edition"}}))
})
It("should return correct disc titles when album has discs with valid disc numbers", func() {
album := model.Album{
Discs: map[int]string{
1: "Disc 1",
2: "Disc 2",
},
}
expected := []responses.DiscTitle{
{Disc: 1, Title: "Disc 1"},
{Disc: 2, Title: "Disc 2"},
}
Expect(buildDiscSubtitles(album)).To(Equal(expected))
})
})
DescribeTable("toItemDate",
func(date string, expected responses.ItemDate) {
Expect(toItemDate(date)).To(Equal(expected))
},
Entry("1994-02-04", "1994-02-04", responses.ItemDate{Year: 1994, Month: 2, Day: 4}),
Entry("1994-02", "1994-02", responses.ItemDate{Year: 1994, Month: 2}),
Entry("1994", "1994", responses.ItemDate{Year: 1994}),
Entry("19940201", "", responses.ItemDate{}),
Entry("", "", responses.ItemDate{}),
)
DescribeTable("mapExplicitStatus",
func(explicitStatus string, expected string) {
Expect(mapExplicitStatus(explicitStatus)).To(Equal(expected))
},
Entry("returns \"clean\" when the db value is \"c\"", "c", "clean"),
Entry("returns \"explicit\" when the db value is \"e\"", "e", "explicit"),
Entry("returns an empty string when the db value is \"\"", "", ""),
Entry("returns an empty string when there are unexpected values on the db", "abc", ""))
Describe("getArtistAlbumCount", func() {
artist := model.Artist{
Stats: map[model.Role]model.ArtistStats{
model.RoleAlbumArtist: {
AlbumCount: 3,
},
model.RoleMainCredit: {
AlbumCount: 4,
},
},
}
It("Handles album count without artist participations", func() {
conf.Server.Subsonic.ArtistParticipations = false
result := getArtistAlbumCount(&artist)
Expect(result).To(Equal(int32(3)))
})
It("Handles album count without with participations", func() {
conf.Server.Subsonic.ArtistParticipations = true
result := getArtistAlbumCount(&artist)
Expect(result).To(Equal(int32(4)))
})
})
Describe("selectedMusicFolderIds", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
Context("when musicFolderId parameter is provided", func() {
It("should return the specified musicFolderId values", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=3", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 3}))
})
It("should ignore invalid musicFolderId parameter values", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=2", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{2})) // Only valid ID is returned
})
It("should return error when any library ID is not accessible", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=5&musicFolderId=2&musicFolderId=99", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Library 5 not found or not accessible"))
Expect(ids).To(BeNil())
})
})
Context("when musicFolderId parameter is not provided", func() {
Context("and required is false", func() {
It("should return all user's library IDs", func() {
r := httptest.NewRequest("GET", "/test", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 2, 3}))
})
It("should return empty slice when user has no libraries", func() {
userWithoutLibs := model.User{ID: "no-libs-user", Libraries: []model.Library{}}
ctxWithoutLibs := request.WithUser(context.Background(), userWithoutLibs)
r := httptest.NewRequest("GET", "/test", nil)
r = r.WithContext(ctxWithoutLibs)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{}))
})
})
Context("and required is true", func() {
It("should return ErrMissingParam error", func() {
r := httptest.NewRequest("GET", "/test", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, true)
Expect(err).To(MatchError(req.ErrMissingParam))
Expect(ids).To(BeNil())
})
})
})
Context("when musicFolderId parameter is empty", func() {
It("should return all user's library IDs even when empty parameter is provided", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 2, 3}))
})
})
Context("when all musicFolderId parameters are invalid", func() {
It("should return all user libraries when all musicFolderId parameters are invalid", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=notanumber", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 2, 3})) // Falls back to all user libraries
})
})
})
})

136
server/subsonic/jukebox.go Normal file
View File

@@ -0,0 +1,136 @@
package subsonic
import (
"net/http"
"strconv"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
const (
ActionGet = "get"
ActionStatus = "status"
ActionSet = "set"
ActionStart = "start"
ActionStop = "stop"
ActionSkip = "skip"
ActionAdd = "add"
ActionClear = "clear"
ActionRemove = "remove"
ActionShuffle = "shuffle"
ActionSetGain = "setGain"
)
func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
user := getUser(ctx)
p := req.Params(r)
if !conf.Server.Jukebox.Enabled {
return nil, newError(responses.ErrorGeneric, "Jukebox is disabled")
}
if conf.Server.Jukebox.AdminOnly && !user.IsAdmin {
return nil, newError(responses.ErrorAuthorizationFail, "Jukebox is admin only")
}
actionString, err := p.String("action")
if err != nil {
return nil, err
}
pb, err := api.playback.GetDeviceForUser(user.UserName)
if err != nil {
return nil, err
}
log.Info(ctx, "JukeboxControl request received", "action", actionString)
switch actionString {
case ActionGet:
mediafiles, status, err := pb.Get(ctx)
if err != nil {
return nil, err
}
playlist := responses.JukeboxPlaylist{
JukeboxStatus: *deviceStatusToJukeboxStatus(status),
Entry: slice.MapWithArg(mediafiles, ctx, childFromMediaFile),
}
response := newResponse()
response.JukeboxPlaylist = &playlist
return response, nil
case ActionStatus:
return createResponse(pb.Status(ctx))
case ActionSet:
ids, _ := p.Strings("id")
return createResponse(pb.Set(ctx, ids))
case ActionStart:
return createResponse(pb.Start(ctx))
case ActionStop:
return createResponse(pb.Stop(ctx))
case ActionSkip:
index, err := p.Int("index")
if err != nil {
return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
}
offset := p.IntOr("offset", 0)
return createResponse(pb.Skip(ctx, index, offset))
case ActionAdd:
ids, _ := p.Strings("id")
return createResponse(pb.Add(ctx, ids))
case ActionClear:
return createResponse(pb.Clear(ctx))
case ActionRemove:
index, err := p.Int("index")
if err != nil {
return nil, err
}
return createResponse(pb.Remove(ctx, index))
case ActionShuffle:
return createResponse(pb.Shuffle(ctx))
case ActionSetGain:
gainStr, err := p.String("gain")
if err != nil {
return nil, newError(responses.ErrorMissingParameter, "missing parameter gain, err: %s", err)
}
gain, err := strconv.ParseFloat(gainStr, 32)
if err != nil {
return nil, newError(responses.ErrorMissingParameter, "error parsing gain float value, err: %s", err)
}
return createResponse(pb.SetGain(ctx, float32(gain)))
default:
return nil, newError(responses.ErrorMissingParameter, "Unknown action: %s", actionString)
}
}
// createResponse is to shorten the case-switch in the JukeboxController
func createResponse(status playback.DeviceStatus, err error) (*responses.Subsonic, error) {
if err != nil {
return nil, err
}
return statusResponse(status), nil
}
func statusResponse(status playback.DeviceStatus) *responses.Subsonic {
response := newResponse()
response.JukeboxStatus = deviceStatusToJukeboxStatus(status)
return response
}
func deviceStatusToJukeboxStatus(status playback.DeviceStatus) *responses.JukeboxStatus {
return &responses.JukeboxStatus{
CurrentIndex: int32(status.CurrentIndex),
Playing: status.Playing,
Gain: status.Gain,
Position: int32(status.Position),
}
}

View File

@@ -0,0 +1,103 @@
package subsonic
import (
"fmt"
"net/http"
"slices"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
)
func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
status, err := api.scanner.Status(ctx)
if err != nil {
log.Error(ctx, "Error retrieving Scanner status", err)
return nil, newError(responses.ErrorGeneric, "Internal Error")
}
response := newResponse()
response.ScanStatus = &responses.ScanStatus{
Scanning: status.Scanning,
Count: int64(status.Count),
FolderCount: int64(status.FolderCount),
LastScan: &status.LastScan,
Error: status.LastError,
ScanType: status.ScanType,
ElapsedTime: int64(status.ElapsedTime),
}
return response, nil
}
func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
loggedUser, ok := request.UserFrom(ctx)
if !ok {
return nil, newError(responses.ErrorGeneric, "Internal error")
}
if !loggedUser.IsAdmin {
return nil, newError(responses.ErrorAuthorizationFail)
}
p := req.Params(r)
fullScan := p.BoolOr("fullScan", false)
// Parse optional target parameters for selective scanning
var targets []model.ScanTarget
if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 {
targets, err = model.ParseTargets(targetParams)
if err != nil {
return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err))
}
// Validate all libraries in targets exist and user has access to them
userLibraries, err := api.ds.User(ctx).GetUserLibraries(loggedUser.ID)
if err != nil {
return nil, newError(responses.ErrorGeneric, "Internal error")
}
// Check each target library
for _, target := range targets {
if !slices.ContainsFunc(userLibraries, func(lib model.Library) bool { return lib.ID == target.LibraryID }) {
return nil, newError(responses.ErrorDataNotFound, fmt.Sprintf("Library with ID %d not found", target.LibraryID))
}
}
// Special case: if single library with empty path and it's the only library in DB, call ScanAll
if len(targets) == 1 && targets[0].FolderPath == "" {
allLibs, err := api.ds.Library(ctx).GetAll()
if err != nil {
return nil, newError(responses.ErrorGeneric, "Internal error")
}
if len(allLibs) == 1 {
targets = nil // This will trigger ScanAll below
}
}
}
go func() {
start := time.Now()
var err error
if len(targets) > 0 {
log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "targets", len(targets), "user", loggedUser.UserName)
_, err = api.scanner.ScanFolders(ctx, fullScan, targets)
} else {
log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "user", loggedUser.UserName)
_, err = api.scanner.ScanAll(ctx, fullScan)
}
if err != nil {
log.Error(ctx, "Error scanning", err)
return
}
log.Info(ctx, "On-demand scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start))
}()
return api.GetScanStatus(r)
}

View File

@@ -0,0 +1,396 @@
package subsonic
import (
"context"
"errors"
"net/http/httptest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LibraryScanning", func() {
var api *Router
var ms *tests.MockScanner
BeforeEach(func() {
ms = tests.NewMockScanner()
api = &Router{scanner: ms}
})
Describe("StartScan", func() {
It("requires admin authentication", func() {
// Create non-admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "user-id",
IsAdmin: false,
})
// Create request
r := httptest.NewRequest("GET", "/rest/startScan", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should return authorization error
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
var subErr subError
ok := errors.As(err, &subErr)
Expect(ok).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail))
})
It("triggers a full scan with no parameters", func() {
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
// Create request with no parameters
r := httptest.NewRequest("GET", "/rest/startScan", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should succeed
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
// Verify ScanAll was called (eventually, since it's in a goroutine)
Eventually(func() int {
return ms.GetScanAllCallCount()
}).Should(BeNumerically(">", 0))
calls := ms.GetScanAllCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].FullScan).To(BeFalse())
})
It("triggers a full scan with fullScan=true", func() {
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
// Create request with fullScan parameter
r := httptest.NewRequest("GET", "/rest/startScan?fullScan=true", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should succeed
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
// Verify ScanAll was called with fullScan=true
Eventually(func() int {
return ms.GetScanAllCallCount()
}).Should(BeNumerically(">", 0))
calls := ms.GetScanAllCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].FullScan).To(BeTrue())
})
It("triggers a selective scan with single target parameter", func() {
// Setup mocks
mockUserRepo := tests.CreateMockUserRepo()
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
api.ds = mockDS
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
// Create request with single target parameter
r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Rock", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should succeed
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
// Verify ScanFolders was called with correct targets
Eventually(func() int {
return ms.GetScanFoldersCallCount()
}).Should(BeNumerically(">", 0))
calls := ms.GetScanFoldersCalls()
Expect(calls).To(HaveLen(1))
targets := calls[0].Targets
Expect(targets).To(HaveLen(1))
Expect(targets[0].LibraryID).To(Equal(1))
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
})
It("triggers a selective scan with multiple target parameters", func() {
// Setup mocks
mockUserRepo := tests.CreateMockUserRepo()
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
api.ds = mockDS
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
// Create request with multiple target parameters
r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Reggae&target=2:Classical/Bach", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should succeed
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
// Verify ScanFolders was called with correct targets
Eventually(func() int {
return ms.GetScanFoldersCallCount()
}).Should(BeNumerically(">", 0))
calls := ms.GetScanFoldersCalls()
Expect(calls).To(HaveLen(1))
targets := calls[0].Targets
Expect(targets).To(HaveLen(2))
Expect(targets[0].LibraryID).To(Equal(1))
Expect(targets[0].FolderPath).To(Equal("Music/Reggae"))
Expect(targets[1].LibraryID).To(Equal(2))
Expect(targets[1].FolderPath).To(Equal("Classical/Bach"))
})
It("triggers a selective full scan with target and fullScan parameters", func() {
// Setup mocks
mockUserRepo := tests.CreateMockUserRepo()
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1})
mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
api.ds = mockDS
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
// Create request with target and fullScan parameters
r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Jazz&fullScan=true", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should succeed
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
// Verify ScanFolders was called with fullScan=true
Eventually(func() int {
return ms.GetScanFoldersCallCount()
}).Should(BeNumerically(">", 0))
calls := ms.GetScanFoldersCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].FullScan).To(BeTrue())
targets := calls[0].Targets
Expect(targets).To(HaveLen(1))
})
It("returns error for invalid target format", func() {
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
// Create request with invalid target format (missing colon)
r := httptest.NewRequest("GET", "/rest/startScan?target=1MusicRock", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should return error
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
var subErr subError
ok := errors.As(err, &subErr)
Expect(ok).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
})
It("returns error for invalid library ID in target", func() {
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
// Create request with invalid library ID
r := httptest.NewRequest("GET", "/rest/startScan?target=0:Music/Rock", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should return error
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
var subErr subError
ok := errors.As(err, &subErr)
Expect(ok).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
})
It("returns error when library does not exist", func() {
// Setup mocks - user has access to library 1 and 2 only
mockUserRepo := tests.CreateMockUserRepo()
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
api.ds = mockDS
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
// Create request with library ID that doesn't exist
r := httptest.NewRequest("GET", "/rest/startScan?target=999:Music/Rock", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should return ErrorDataNotFound
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
var subErr subError
ok := errors.As(err, &subErr)
Expect(ok).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
})
It("calls ScanAll when single library with empty path and only one library exists", func() {
// Setup mocks - single library in DB
mockUserRepo := tests.CreateMockUserRepo()
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1})
mockLibraryRepo := &tests.MockLibraryRepo{}
mockLibraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Music Library", Path: "/music"},
})
mockDS := &tests.MockDataStore{
MockedUser: mockUserRepo,
MockedLibrary: mockLibraryRepo,
}
api.ds = mockDS
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
// Create request with single library and empty path
r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should succeed
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
// Verify ScanAll was called instead of ScanFolders
Eventually(func() int {
return ms.GetScanAllCallCount()
}).Should(BeNumerically(">", 0))
Expect(ms.GetScanFoldersCallCount()).To(Equal(0))
})
It("calls ScanFolders when single library with empty path but multiple libraries exist", func() {
// Setup mocks - multiple libraries in DB
mockUserRepo := tests.CreateMockUserRepo()
_ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
mockLibraryRepo := &tests.MockLibraryRepo{}
mockLibraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Music Library", Path: "/music"},
{ID: 2, Name: "Audiobooks", Path: "/audiobooks"},
})
mockDS := &tests.MockDataStore{
MockedUser: mockUserRepo,
MockedLibrary: mockLibraryRepo,
}
api.ds = mockDS
// Create admin user
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
// Create request with single library and empty path
r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.StartScan(r)
// Should succeed
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
// Verify ScanFolders was called (not ScanAll)
Eventually(func() int {
return ms.GetScanFoldersCallCount()
}).Should(BeNumerically(">", 0))
calls := ms.GetScanFoldersCalls()
Expect(calls).To(HaveLen(1))
targets := calls[0].Targets
Expect(targets).To(HaveLen(1))
Expect(targets[0].LibraryID).To(Equal(1))
Expect(targets[0].FolderPath).To(Equal(""))
})
})
Describe("GetScanStatus", func() {
It("returns scan status", func() {
// Setup mock scanner status
ms.SetStatusResponse(&model.ScannerStatus{
Scanning: false,
Count: 100,
FolderCount: 10,
})
// Create request
ctx := context.Background()
r := httptest.NewRequest("GET", "/rest/getScanStatus", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetScanStatus(r)
// Should succeed
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
Expect(response.ScanStatus).ToNot(BeNil())
Expect(response.ScanStatus.Scanning).To(BeFalse())
Expect(response.ScanStatus.Count).To(Equal(int64(100)))
Expect(response.ScanStatus.FolderCount).To(Equal(int64(10)))
})
})
})

View File

@@ -0,0 +1,222 @@
package subsonic
import (
"context"
"fmt"
"net/http"
"time"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
)
func (api *Router) SetRating(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
rating, err := p.Int("rating")
if err != nil {
return nil, err
}
log.Debug(r, "Setting rating", "rating", rating, "id", id)
err = api.setRating(r.Context(), id, rating)
if err != nil {
log.Error(r, err)
return nil, err
}
return newResponse(), nil
}
func (api *Router) setRating(ctx context.Context, id string, rating int) error {
var repo model.AnnotatedRepository
var resource string
entity, err := model.GetEntityByID(ctx, api.ds, id)
if err != nil {
return err
}
switch entity.(type) {
case *model.Artist:
repo = api.ds.Artist(ctx)
resource = "artist"
case *model.Album:
repo = api.ds.Album(ctx)
resource = "album"
default:
repo = api.ds.MediaFile(ctx)
resource = "song"
}
err = repo.SetRating(rating, id)
if err != nil {
return err
}
event := &events.RefreshResource{}
api.broker.SendMessage(ctx, event.With(resource, id))
return nil
}
func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, _ := p.Strings("id")
albumIds, _ := p.Strings("albumId")
artistIds, _ := p.Strings("artistId")
if len(ids)+len(albumIds)+len(artistIds) == 0 {
return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing")
}
ids = append(ids, albumIds...)
ids = append(ids, artistIds...)
err := api.setStar(r.Context(), true, ids...)
if err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, _ := p.Strings("id")
albumIds, _ := p.Strings("albumId")
artistIds, _ := p.Strings("artistId")
if len(ids)+len(albumIds)+len(artistIds) == 0 {
return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing")
}
ids = append(ids, albumIds...)
ids = append(ids, artistIds...)
err := api.setStar(r.Context(), false, ids...)
if err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error {
if len(ids) == 0 {
return nil
}
log.Debug(ctx, "Changing starred", "ids", ids, "starred", star)
if len(ids) == 0 {
log.Warn(ctx, "Cannot star/unstar an empty list of ids")
return nil
}
event := &events.RefreshResource{}
err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
for _, id := range ids {
exist, err := tx.Album(ctx).Exists(id)
if err != nil {
return err
}
if exist {
err = tx.Album(ctx).SetStar(star, id)
if err != nil {
return err
}
event = event.With("album", id)
continue
}
exist, err = tx.Artist(ctx).Exists(id)
if err != nil {
return err
}
if exist {
err = tx.Artist(ctx).SetStar(star, id)
if err != nil {
return err
}
event = event.With("artist", id)
continue
}
err = tx.MediaFile(ctx).SetStar(star, id)
if err != nil {
return err
}
event = event.With("song", id)
}
api.broker.SendMessage(ctx, event)
return nil
})
if err != nil {
log.Error(ctx, err)
return err
}
return nil
}
func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, err := p.Strings("id")
if err != nil {
return nil, err
}
times, _ := p.Times("time")
if len(times) > 0 && len(times) != len(ids) {
return nil, newError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids))
}
submission := p.BoolOr("submission", true)
position := p.IntOr("position", 0)
ctx := r.Context()
if submission {
err := api.scrobblerSubmit(ctx, ids, times)
if err != nil {
log.Error(ctx, "Error registering scrobbles", "ids", ids, "times", times, err)
}
} else {
err := api.scrobblerNowPlaying(ctx, ids[0], position)
if err != nil {
log.Error(ctx, "Error setting NowPlaying", "id", ids[0], err)
}
}
return newResponse(), nil
}
func (api *Router) scrobblerSubmit(ctx context.Context, ids []string, times []time.Time) error {
var submissions []scrobbler.Submission
log.Debug(ctx, "Scrobbling tracks", "ids", ids, "times", times)
for i, id := range ids {
var t time.Time
if len(times) > 0 {
t = times[i]
} else {
t = time.Now()
}
submissions = append(submissions, scrobbler.Submission{TrackID: id, Timestamp: t})
}
return api.scrobbler.Submit(ctx, submissions)
}
func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, position int) error {
mf, err := api.ds.MediaFile(ctx).Get(trackId)
if err != nil {
return err
}
if mf == nil {
return fmt.Errorf(`ID "%s" not found`, trackId)
}
player, _ := request.PlayerFrom(ctx)
username, _ := request.UsernameFrom(ctx)
client, _ := request.ClientFrom(ctx)
clientId, ok := request.ClientUniqueIdFrom(ctx)
if !ok {
clientId = player.ID
}
log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position)
err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position)
return err
}

View File

@@ -0,0 +1,145 @@
package subsonic
import (
"context"
"fmt"
"net/http"
"time"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("MediaAnnotationController", func() {
var router *Router
var ds model.DataStore
var playTracker *fakePlayTracker
var eventBroker *fakeEventBroker
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
ds = &tests.MockDataStore{}
playTracker = &fakePlayTracker{}
eventBroker = &fakeEventBroker{}
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
})
Describe("Scrobble", func() {
It("submit all scrobbles with only the id", func() {
submissionTime := time.Now()
r := newGetRequest("id=12", "id=34")
_, err := router.Scrobble(r)
Expect(err).ToNot(HaveOccurred())
Expect(playTracker.Submissions).To(HaveLen(2))
Expect(playTracker.Submissions[0].Timestamp).To(BeTemporally(">", submissionTime))
Expect(playTracker.Submissions[0].TrackID).To(Equal("12"))
Expect(playTracker.Submissions[1].Timestamp).To(BeTemporally(">", submissionTime))
Expect(playTracker.Submissions[1].TrackID).To(Equal("34"))
})
It("submit all scrobbles with respective times", func() {
time1 := time.Now().Add(-20 * time.Minute)
t1 := time1.UnixMilli()
time2 := time.Now().Add(-10 * time.Minute)
t2 := time2.UnixMilli()
r := newGetRequest("id=12", "id=34", fmt.Sprintf("time=%d", t1), fmt.Sprintf("time=%d", t2))
_, err := router.Scrobble(r)
Expect(err).ToNot(HaveOccurred())
Expect(playTracker.Submissions).To(HaveLen(2))
Expect(playTracker.Submissions[0].Timestamp).To(BeTemporally("~", time1))
Expect(playTracker.Submissions[0].TrackID).To(Equal("12"))
Expect(playTracker.Submissions[1].Timestamp).To(BeTemporally("~", time2))
Expect(playTracker.Submissions[1].TrackID).To(Equal("34"))
})
It("checks if number of ids match number of times", func() {
r := newGetRequest("id=12", "id=34", "time=1111")
_, err := router.Scrobble(r)
Expect(err).To(HaveOccurred())
Expect(playTracker.Submissions).To(BeEmpty())
})
Context("submission=false", func() {
var req *http.Request
BeforeEach(func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "12"})
ctx = request.WithPlayer(ctx, model.Player{ID: "player-1"})
req = newGetRequest("id=12", "submission=false")
req = req.WithContext(ctx)
})
It("does not scrobble", func() {
_, err := router.Scrobble(req)
Expect(err).ToNot(HaveOccurred())
Expect(playTracker.Submissions).To(BeEmpty())
})
It("registers a NowPlaying", func() {
_, err := router.Scrobble(req)
Expect(err).ToNot(HaveOccurred())
Expect(playTracker.Playing).To(HaveLen(1))
Expect(playTracker.Playing).To(HaveKey("player-1"))
})
})
})
})
type fakePlayTracker struct {
Submissions []scrobbler.Submission
Playing map[string]string
Error error
}
func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error {
if f.Error != nil {
return f.Error
}
if f.Playing == nil {
f.Playing = make(map[string]string)
}
f.Playing[playerId] = trackId
return nil
}
func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) {
return nil, f.Error
}
func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Submission) error {
if f.Error != nil {
return f.Error
}
f.Submissions = append(f.Submissions, submissions...)
return nil
}
var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil)
type fakeEventBroker struct {
http.Handler
Events []events.Event
}
func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event)
}
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event)
}
var _ events.Broker = (*fakeEventBroker)(nil)

View File

@@ -0,0 +1,153 @@
package subsonic
import (
"context"
"errors"
"io"
"net/http"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/server/subsonic/filter"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/gravatar"
"github.com/navidrome/navidrome/utils/req"
)
func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
if !conf.Server.EnableGravatar {
return api.getPlaceHolderAvatar(w, r)
}
p := req.Params(r)
username, err := p.String("username")
if err != nil {
return nil, err
}
ctx := r.Context()
u, err := api.ds.User(ctx).FindByUsername(username)
if err != nil {
return nil, err
}
if u.Email == "" {
log.Warn(ctx, "User needs an email for gravatar to work", "username", username)
return api.getPlaceHolderAvatar(w, r)
}
http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound)
return nil, nil
}
func (api *Router) getPlaceHolderAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
f, err := resources.FS().Open(consts.PlaceholderAvatar)
if err != nil {
log.Error(r, "Image not found", err)
return nil, newError(responses.ErrorDataNotFound, "Avatar image not found")
}
defer f.Close()
_, _ = io.Copy(w, f)
return nil, nil
}
func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
// If context is already canceled, discard request without further processing
if r.Context().Err() != nil {
return nil, nil //nolint:nilerr
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
p := req.Params(r)
id, _ := p.String("id")
size := p.IntOr("size", 0)
square := p.BoolOr("square", false)
imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size, square)
switch {
case errors.Is(err, context.Canceled):
return nil, nil
case errors.Is(err, model.ErrNotFound):
log.Warn(r, "Couldn't find coverArt", "id", id, err)
return nil, newError(responses.ErrorDataNotFound, "Artwork not found")
case err != nil:
log.Error(r, "Error retrieving coverArt", "id", id, err)
return nil, err
}
defer imgReader.Close()
w.Header().Set("cache-control", "public, max-age=315360000")
w.Header().Set("last-modified", lastUpdate.Format(time.RFC1123))
cnt, err := io.Copy(w, imgReader)
if err != nil {
log.Warn(ctx, "Error sending image", "count", cnt, err)
}
return nil, err
}
func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
artist, _ := p.String("artist")
title, _ := p.String("title")
response := newResponse()
lyricsResponse := responses.Lyrics{}
response.Lyrics = &lyricsResponse
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title))
if err != nil {
return nil, err
}
if len(mediaFiles) == 0 {
return response, nil
}
structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0])
if err != nil {
return nil, err
}
if len(structuredLyrics) == 0 {
return response, nil
}
lyricsResponse.Artist = artist
lyricsResponse.Title = title
lyricsText := ""
for _, line := range structuredLyrics[0].Line {
lyricsText += line.Value + "\n"
}
lyricsResponse.Value = lyricsText
return response, nil
}
func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, error) {
id, err := req.Params(r).String("id")
if err != nil {
return nil, err
}
mediaFile, err := api.ds.MediaFile(r.Context()).Get(id)
if err != nil {
return nil, err
}
structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile)
if err != nil {
return nil, err
}
response := newResponse()
response.LyricsList = buildLyricsList(mediaFile, structuredLyrics)
return response, nil
}

View File

@@ -0,0 +1,379 @@
package subsonic
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"io"
"net/http/httptest"
"slices"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("MediaRetrievalController", func() {
var router *Router
var ds model.DataStore
mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}}
var artwork *fakeArtwork
var w *httptest.ResponseRecorder
BeforeEach(func() {
ds = &tests.MockDataStore{
MockedMediaFile: mockRepo,
}
artwork = &fakeArtwork{data: "image data"}
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"
})
Describe("GetCoverArt", func() {
It("should return data for that id", func() {
r := newGetRequest("id=34", "size=128", "square=true")
_, err := router.GetCoverArt(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(artwork.recvSize).To(Equal(128))
Expect(artwork.recvSquare).To(BeTrue())
Expect(w.Body.String()).To(Equal(artwork.data))
})
It("should return placeholder if id parameter is missing (mimicking Subsonic)", func() {
r := newGetRequest() // No id parameter
_, err := router.GetCoverArt(w, r)
Expect(err).To(BeNil())
Expect(artwork.recvId).To(BeEmpty())
Expect(w.Body.String()).To(Equal(artwork.data))
})
It("should fail when the file is not found", func() {
artwork.err = model.ErrNotFound
r := newGetRequest("id=34", "size=128", "square=true")
_, err := router.GetCoverArt(w, r)
Expect(err).To(MatchError("Artwork not found"))
})
It("should fail when there is an unknown error", func() {
artwork.err = errors.New("weird error")
r := newGetRequest("id=34", "size=128")
_, err := router.GetCoverArt(w, r)
Expect(err).To(MatchError("weird error"))
})
When("client disconnects (context is cancelled)", func() {
It("should not call the service if cancelled before the call", func() {
// Create a request
ctx, cancel := context.WithCancel(context.Background())
r := newGetRequest("id=34", "size=128", "square=true")
r = r.WithContext(ctx)
cancel() // Cancel the context before the call
// Call the GetCoverArt method
_, err := router.GetCoverArt(w, r)
// Expect no error and no call to the artwork service
Expect(err).ToNot(HaveOccurred())
Expect(artwork.recvId).To(Equal(""))
Expect(artwork.recvSize).To(Equal(0))
Expect(artwork.recvSquare).To(BeFalse())
Expect(w.Body.String()).To(BeEmpty())
})
It("should not return data if cancelled during the call", func() {
// Create a request with a context that will be cancelled
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // Ensure the context is cancelled after the test (best practices)
r := newGetRequest("id=34", "size=128", "square=true")
r = r.WithContext(ctx)
artwork.ctxCancelFunc = cancel // Set the cancel function to simulate cancellation in the service
// Call the GetCoverArt method
_, err := router.GetCoverArt(w, r)
// Expect no error and the service to have been called
Expect(err).ToNot(HaveOccurred())
Expect(artwork.recvId).To(Equal("34"))
Expect(artwork.recvSize).To(Equal(128))
Expect(artwork.recvSquare).To(BeTrue())
Expect(w.Body.String()).To(BeEmpty())
})
})
})
Describe("GetLyrics", func() {
It("should return data for given artist & title", func() {
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")
lyricsJson, err := json.Marshal(model.LyricList{
*lyrics,
})
Expect(err).ToNot(HaveOccurred())
baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
mockRepo.SetData(model.MediaFiles{
{
ID: "2",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer
},
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older
},
{
ID: "3",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: "[]",
UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest
},
})
response, err := router.GetLyrics(r)
Expect(err).ToNot(HaveOccurred())
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
})
It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() {
r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa")
mockRepo.SetData(model.MediaFiles{})
response, err := router.GetLyrics(r)
Expect(err).ToNot(HaveOccurred())
Expect(response.Lyrics.Artist).To(Equal(""))
Expect(response.Lyrics.Title).To(Equal(""))
Expect(response.Lyrics.Value).To(Equal(""))
})
It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() {
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
mockRepo.SetData(model.MediaFiles{
{
Path: "tests/fixtures/test.mp3",
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
},
{
Path: "tests/fixtures/test.mp3",
ID: "2",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
},
})
response, err := router.GetLyrics(r)
Expect(err).ToNot(HaveOccurred())
Expect(response.Lyrics.Artist).To(Equal("Rick Astley"))
Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up"))
Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n"))
})
})
Describe("GetLyricsBySongId", func() {
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]"
var times = []int64{18800, 22801}
compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) {
Expect(actual).ToNot(BeNil())
Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics)))
for i, realLyric := range actual.StructuredLyrics {
expectedLyric := expected.StructuredLyrics[i]
Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist))
Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle))
Expect(realLyric.Lang).To(Equal(expectedLyric.Lang))
Expect(realLyric.Synced).To(Equal(expectedLyric.Synced))
if expectedLyric.Offset == nil {
Expect(realLyric.Offset).To(BeNil())
} else {
Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset))
}
Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line)))
for j, realLine := range realLyric.Line {
expectedLine := expectedLyric.Line[j]
Expect(realLine.Value).To(Equal(expectedLine.Value))
if expectedLine.Start == nil {
Expect(realLine.Start).To(BeNil())
} else {
Expect(*realLine.Start).To(Equal(*expectedLine.Start))
}
}
}
}
It("should return mixed lyrics", func() {
r := newGetRequest("id=1")
synced, _ := model.ToLyrics("eng", syncedLyrics)
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
lyricsJson, err := json.Marshal(model.LyricList{
*synced, *unsynced,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
Lang: "eng",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
},
{
Lang: "xxx",
DisplayArtist: "Rick Astley",
DisplayTitle: "Never Gonna Give You Up",
Synced: false,
Line: []responses.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
},
},
})
})
It("should parse lrc metadata", func() {
r := newGetRequest("id=1")
synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics)
lyricsJson, err := json.Marshal(model.LyricList{
*synced,
})
Expect(err).ToNot(HaveOccurred())
mockRepo.SetData(model.MediaFiles{
{
ID: "1",
Artist: "Rick Astley",
Title: "Never Gonna Give You Up",
Lyrics: string(lyricsJson),
},
})
response, err := router.GetLyricsBySongId(r)
Expect(err).ToNot(HaveOccurred())
offset := int64(-100)
compareResponses(response.LyricsList, responses.LyricsList{
StructuredLyrics: responses.StructuredLyrics{
{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Synced: true,
Line: []responses.Line{
{
Start: &times[0],
Value: "We're no strangers to love",
},
{
Start: &times[1],
Value: "You know the rules and so do I",
},
},
Offset: &offset,
},
},
})
})
})
})
type fakeArtwork struct {
artwork.Artwork
data string
err error
ctxCancelFunc func()
recvId string
recvSize int
recvSquare bool
}
func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
if c.err != nil {
return nil, time.Time{}, c.err
}
c.recvId = id
c.recvSize = size
c.recvSquare = square
if c.ctxCancelFunc != nil {
c.ctxCancelFunc() // Simulate context cancellation
return nil, time.Time{}, context.Canceled
}
return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil
}
type mockedMediaFile struct {
tests.MockMediaFileRepo
}
func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, error) {
data, err := m.MockMediaFileRepo.GetAll(opts...)
if err != nil {
return nil, err
}
if len(opts) == 0 || opts[0].Sort != "lyrics, updated_at" {
return data, nil
}
// Hardcoded support for lyrics sorting
result := slices.Clone(data)
// Sort by presence of lyrics, then by updated_at. Respect the order specified in opts.
slices.SortFunc(result, func(a, b model.MediaFile) int {
diff := cmp.Or(
cmp.Compare(a.Lyrics, b.Lyrics),
cmp.Compare(a.UpdatedAt.Unix(), b.UpdatedAt.Unix()),
)
if opts[0].Order == "desc" {
return -diff
}
return diff
})
return result, nil
}

View File

@@ -0,0 +1,272 @@
package subsonic
import (
"cmp"
"context"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
ua "github.com/mileusna/useragent"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/req"
)
func postFormToQueryParams(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
sendError(w, r, newError(responses.ErrorGeneric, err.Error()))
}
var parts []string
for key, values := range r.Form {
for _, v := range values {
parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v))
}
}
r.URL.RawQuery = strings.Join(parts, "&")
next.ServeHTTP(w, r)
})
}
func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
username := server.InternalAuth(r)
// If the username comes from internal auth, do not also do reverse proxy auth, as
// the request will have no reverse proxy IP
if username != "" {
return username, true
}
return server.UsernameFromExtAuthHeader(r), false
}
func checkRequiredParameters(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var requiredParameters []string
username, _ := fromInternalOrProxyAuth(r)
if username != "" {
requiredParameters = []string{"v", "c"}
} else {
requiredParameters = []string{"u", "v", "c"}
}
p := req.Params(r)
for _, param := range requiredParameters {
if _, err := p.String(param); err != nil {
log.Warn(r, err)
sendError(w, r, err)
return
}
}
if username == "" {
username, _ = p.String("u")
}
client, _ := p.String("c")
version, _ := p.String("v")
ctx := r.Context()
ctx = request.WithUsername(ctx, username)
ctx = request.WithClient(ctx, client)
ctx = request.WithVersion(ctx, version)
log.Debug(ctx, "API: New request "+r.URL.Path, "username", username, "client", client, "version", version)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var usr *model.User
var err error
username, isInternalAuth := fromInternalOrProxyAuth(r)
if username != "" {
authType := If(isInternalAuth, "internal", "reverse-proxy")
usr, err = ds.User(ctx).FindByUsername(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
return
}
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
} else if err != nil {
log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
}
} else {
p := req.Params(r)
username, _ := p.String("u")
pass, _ := p.String("p")
token, _ := p.String("t")
salt, _ := p.String("s")
jwt, _ := p.String("jwt")
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
return
}
switch {
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
case err != nil:
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
default:
err = validateCredentials(usr, pass, token, salt, jwt)
if err != nil {
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
}
}
}
if err != nil {
sendError(w, r, newError(responses.ErrorAuthenticationFail))
return
}
ctx = request.WithUser(ctx, *usr)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func validateCredentials(user *model.User, pass, token, salt, jwt string) error {
valid := false
switch {
case jwt != "":
claims, err := auth.Validate(jwt)
valid = err == nil && claims["sub"] == user.UserName
case pass != "":
if strings.HasPrefix(pass, "enc:") {
if dec, err := hex.DecodeString(pass[4:]); err == nil {
pass = string(dec)
}
}
valid = pass == user.Password
case token != "":
t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt)))
valid = t == token
}
if !valid {
return model.ErrInvalidAuth
}
return nil
}
func getPlayer(players core.Players) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userName, _ := request.UsernameFrom(ctx)
client, _ := request.ClientFrom(ctx)
playerId := playerIDFromCookie(r, userName)
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
userAgent := canonicalUserAgent(r)
player, trc, err := players.Register(ctx, playerId, client, userAgent, ip)
if err != nil {
log.Error(ctx, "Could not register player", "username", userName, "client", client, err)
} else {
ctx = request.WithPlayer(ctx, *player)
if trc != nil {
ctx = request.WithTranscoding(ctx, *trc)
}
r = r.WithContext(ctx)
cookie := &http.Cookie{
Name: playerIDCookieName(userName),
Value: player.ID,
MaxAge: consts.CookieExpiry,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Path: cmp.Or(conf.Server.BasePath, "/"),
}
http.SetCookie(w, cookie)
}
next.ServeHTTP(w, r)
})
}
}
func canonicalUserAgent(r *http.Request) string {
u := ua.Parse(r.Header.Get("user-agent"))
userAgent := u.Name
if u.OS != "" {
userAgent = userAgent + "/" + u.OS
}
return userAgent
}
func playerIDFromCookie(r *http.Request, userName string) string {
cookieName := playerIDCookieName(userName)
var playerId string
if c, err := r.Cookie(cookieName); err == nil {
playerId = c.Value
log.Trace(r, "playerId found in cookies", "playerId", playerId)
}
return playerId
}
func playerIDCookieName(userName string) string {
cookieName := fmt.Sprintf("nd-player-%x", userName)
return cookieName
}
const subsonicErrorPointer = "subsonicErrorPointer"
func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
status := int32(-1)
contextWithStatus := context.WithValue(r.Context(), subsonicErrorPointer, &status)
start := time.Now()
defer func() {
elapsed := time.Since(start).Milliseconds()
// We want to get the client name (even if not present for certain endpoints)
p := req.Params(r)
client, _ := p.String("c")
// If there is no Subsonic status (e.g., HTTP 501 not implemented), fallback to HTTP
if status == -1 {
status = int32(ww.Status())
}
shortPath := strings.Replace(r.URL.Path, ".view", "", 1)
metrics.RecordRequest(r.Context(), shortPath, r.Method, client, status, elapsed)
}()
next.ServeHTTP(ww, r.WithContext(contextWithStatus))
}
return http.HandlerFunc(fn)
}
}

View File

@@ -0,0 +1,501 @@
package subsonic
import (
"context"
"crypto/md5"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func newGetRequest(queryParams ...string) *http.Request {
r := httptest.NewRequest("GET", "/ping?"+strings.Join(queryParams, "&"), nil)
ctx := r.Context()
return r.WithContext(log.NewContext(ctx))
}
func newPostRequest(queryParam string, formFields ...string) *http.Request {
r, err := http.NewRequest("POST", "/ping?"+queryParam, strings.NewReader(strings.Join(formFields, "&")))
if err != nil {
panic(err)
}
r.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
ctx := r.Context()
return r.WithContext(log.NewContext(ctx))
}
var _ = Describe("Middlewares", func() {
var next *mockHandler
var w *httptest.ResponseRecorder
var ds model.DataStore
BeforeEach(func() {
next = &mockHandler{}
w = httptest.NewRecorder()
ds = &tests.MockDataStore{}
})
Describe("ParsePostForm", func() {
It("converts any filed in a x-www-form-urlencoded POST into query params", func() {
r := newPostRequest("a=abc", "u=user", "v=1.15", "c=test")
cp := postFormToQueryParams(next)
cp.ServeHTTP(w, r)
Expect(next.req.URL.Query().Get("a")).To(Equal("abc"))
Expect(next.req.URL.Query().Get("u")).To(Equal("user"))
Expect(next.req.URL.Query().Get("v")).To(Equal("1.15"))
Expect(next.req.URL.Query().Get("c")).To(Equal("test"))
})
It("adds repeated params", func() {
r := newPostRequest("a=abc", "id=1", "id=2")
cp := postFormToQueryParams(next)
cp.ServeHTTP(w, r)
Expect(next.req.URL.Query().Get("a")).To(Equal("abc"))
Expect(next.req.URL.Query()["id"]).To(ConsistOf("1", "2"))
})
It("overrides query params with same key", func() {
r := newPostRequest("a=query", "a=body")
cp := postFormToQueryParams(next)
cp.ServeHTTP(w, r)
Expect(next.req.URL.Query().Get("a")).To(Equal("body"))
})
})
Describe("CheckParams", func() {
It("passes when all required params are available (subsonicauth case)", func() {
r := newGetRequest("u=user", "v=1.15", "c=test")
cp := checkRequiredParameters(next)
cp.ServeHTTP(w, r)
username, _ := request.UsernameFrom(next.req.Context())
Expect(username).To(Equal("user"))
version, _ := request.VersionFrom(next.req.Context())
Expect(version).To(Equal("1.15"))
client, _ := request.ClientFrom(next.req.Context())
Expect(client).To(Equal("test"))
Expect(next.called).To(BeTrue())
})
It("passes when all required params are available (reverse-proxy case)", func() {
conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
conf.Server.ExtAuth.UserHeader = "Remote-User"
r := newGetRequest("v=1.15", "c=test")
r.Header.Add("Remote-User", "user")
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "127.0.0.234"))
cp := checkRequiredParameters(next)
cp.ServeHTTP(w, r)
username, _ := request.UsernameFrom(next.req.Context())
Expect(username).To(Equal("user"))
version, _ := request.VersionFrom(next.req.Context())
Expect(version).To(Equal("1.15"))
client, _ := request.ClientFrom(next.req.Context())
Expect(client).To(Equal("test"))
Expect(next.called).To(BeTrue())
})
It("fails when user is missing", func() {
r := newGetRequest("v=1.15", "c=test")
cp := checkRequiredParameters(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="10"`))
Expect(next.called).To(BeFalse())
})
It("fails when version is missing", func() {
r := newGetRequest("u=user", "c=test")
cp := checkRequiredParameters(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="10"`))
Expect(next.called).To(BeFalse())
})
It("fails when client is missing", func() {
r := newGetRequest("u=user", "v=1.15")
cp := checkRequiredParameters(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="10"`))
Expect(next.called).To(BeFalse())
})
})
Describe("Authenticate", func() {
BeforeEach(func() {
ur := ds.User(context.TODO())
_ = ur.Put(&model.User{
UserName: "admin",
NewPassword: "wordpass",
})
})
When("using password authentication", func() {
It("passes authentication with correct credentials", func() {
r := newGetRequest("u=admin", "p=wordpass")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
user, _ := request.UserFrom(next.req.Context())
Expect(user.UserName).To(Equal("admin"))
})
It("fails authentication with invalid user", func() {
r := newGetRequest("u=invalid", "p=wordpass")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
It("fails authentication with invalid password", func() {
r := newGetRequest("u=admin", "p=INVALID")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
})
When("using token authentication", func() {
var salt = "12345"
It("passes authentication with correct token", func() {
token := fmt.Sprintf("%x", md5.Sum([]byte("wordpass"+salt)))
r := newGetRequest("u=admin", "t="+token, "s="+salt)
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
user, _ := request.UserFrom(next.req.Context())
Expect(user.UserName).To(Equal("admin"))
})
It("fails authentication with invalid token", func() {
r := newGetRequest("u=admin", "t=INVALID", "s="+salt)
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
It("fails authentication with empty password", func() {
// Token generated with random Salt, empty password
token := fmt.Sprintf("%x", md5.Sum([]byte(""+salt)))
r := newGetRequest("u=NON_EXISTENT_USER", "t="+token, "s="+salt)
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
})
When("using JWT authentication", func() {
var validToken string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
auth.Init(ds)
})
It("passes authentication with correct token", func() {
usr := &model.User{UserName: "admin"}
var err error
validToken, err = auth.CreateToken(usr)
Expect(err).NotTo(HaveOccurred())
r := newGetRequest("u=admin", "jwt="+validToken)
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
user, _ := request.UserFrom(next.req.Context())
Expect(user.UserName).To(Equal("admin"))
})
It("fails authentication with invalid token", func() {
r := newGetRequest("u=admin", "jwt=INVALID_TOKEN")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
})
When("using reverse proxy authentication", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("passes authentication with correct IP and header", func() {
r := newGetRequest("u=admin")
r.Header.Add("Remote-User", "admin")
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1"))
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
user, _ := request.UserFrom(next.req.Context())
Expect(user.UserName).To(Equal("admin"))
})
It("fails authentication with wrong IP", func() {
r := newGetRequest("u=admin")
r.Header.Add("Remote-User", "admin")
r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.2.1"))
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
})
When("using internal authentication", func() {
It("passes authentication with correct internal credentials", func() {
// Simulate internal authentication by setting the context with WithInternalAuth
r := newGetRequest()
r = r.WithContext(request.WithInternalAuth(r.Context(), "admin"))
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
user, _ := request.UserFrom(next.req.Context())
Expect(user.UserName).To(Equal("admin"))
})
It("fails authentication with missing internal context", func() {
r := newGetRequest("u=admin")
// Do not set the internal auth context
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
// Internal auth requires the context, so this should fail
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
})
})
Describe("GetPlayer", func() {
var mockedPlayers *mockPlayers
var r *http.Request
BeforeEach(func() {
mockedPlayers = &mockPlayers{}
r = newGetRequest()
ctx := request.WithUsername(r.Context(), "someone")
ctx = request.WithClient(ctx, "client")
r = r.WithContext(ctx)
})
It("returns a new player in the cookies when none is specified", func() {
gp := getPlayer(mockedPlayers)(next)
gp.ServeHTTP(w, r)
cookieStr := w.Header().Get("Set-Cookie")
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone")))
})
It("does not add the cookie if there was an error", func() {
ctx := request.WithClient(r.Context(), "error")
r = r.WithContext(ctx)
gp := getPlayer(mockedPlayers)(next)
gp.ServeHTTP(w, r)
cookieStr := w.Header().Get("Set-Cookie")
Expect(cookieStr).To(BeEmpty())
})
Context("PlayerId specified in Cookies", func() {
BeforeEach(func() {
cookie := &http.Cookie{
Name: playerIDCookieName("someone"),
Value: "123",
MaxAge: consts.CookieExpiry,
}
r.AddCookie(cookie)
gp := getPlayer(mockedPlayers)(next)
gp.ServeHTTP(w, r)
})
It("stores the player in the context", func() {
Expect(next.called).To(BeTrue())
player, _ := request.PlayerFrom(next.req.Context())
Expect(player.ID).To(Equal("123"))
_, ok := request.TranscodingFrom(next.req.Context())
Expect(ok).To(BeFalse())
})
It("returns the playerId in the cookie", func() {
cookieStr := w.Header().Get("Set-Cookie")
Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone") + "=123"))
})
})
Context("Player has transcoding configured", func() {
BeforeEach(func() {
cookie := &http.Cookie{
Name: playerIDCookieName("someone"),
Value: "123",
MaxAge: consts.CookieExpiry,
}
r.AddCookie(cookie)
mockedPlayers.transcoding = &model.Transcoding{ID: "12"}
gp := getPlayer(mockedPlayers)(next)
gp.ServeHTTP(w, r)
})
It("stores the player in the context", func() {
player, _ := request.PlayerFrom(next.req.Context())
Expect(player.ID).To(Equal("123"))
transcoding, _ := request.TranscodingFrom(next.req.Context())
Expect(transcoding.ID).To(Equal("12"))
})
})
})
Describe("validateCredentials", func() {
var usr *model.User
BeforeEach(func() {
ur := ds.User(context.TODO())
_ = ur.Put(&model.User{
UserName: "admin",
NewPassword: "wordpass",
})
var err error
usr, err = ur.FindByUsernameWithPassword("admin")
if err != nil {
panic(err)
}
})
Context("Plaintext password", func() {
It("authenticates with plaintext password ", func() {
err := validateCredentials(usr, "wordpass", "", "", "")
Expect(err).NotTo(HaveOccurred())
})
It("fails authentication with wrong password", func() {
err := validateCredentials(usr, "INVALID", "", "", "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Context("Encoded password", func() {
It("authenticates with simple encoded password ", func() {
err := validateCredentials(usr, "enc:776f726470617373", "", "", "")
Expect(err).NotTo(HaveOccurred())
})
})
Context("Token based authentication", func() {
It("authenticates with token based authentication", func() {
err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "")
Expect(err).NotTo(HaveOccurred())
})
It("fails if salt is missing", func() {
err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "", "")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
Context("JWT based authentication", func() {
var usr *model.User
var validToken string
BeforeEach(func() {
conf.Server.SessionTimeout = time.Minute
auth.Init(ds)
usr = &model.User{UserName: "admin"}
var err error
validToken, err = auth.CreateToken(usr)
if err != nil {
panic(err)
}
})
It("authenticates with JWT token based authentication", func() {
err := validateCredentials(usr, "", "", "", validToken)
Expect(err).NotTo(HaveOccurred())
})
It("fails if JWT token is invalid", func() {
err := validateCredentials(usr, "", "", "", "invalid.token")
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
It("fails if JWT token sub is different than username", func() {
u := &model.User{UserName: "hacker"}
validToken, _ = auth.CreateToken(u)
err := validateCredentials(usr, "", "", "", validToken)
Expect(err).To(MatchError(model.ErrInvalidAuth))
})
})
})
})
type mockHandler struct {
req *http.Request
called bool
}
func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
mh.req = r
mh.called = true
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}
type mockPlayers struct {
core.Players
transcoding *model.Transcoding
}
func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) {
return &model.Player{ID: playerId}, nil
}
func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) {
if client == "error" {
return nil, nil, errors.New(client)
}
return &model.Player{ID: id}, mp.transcoding, nil
}

View File

@@ -0,0 +1,18 @@
package subsonic
import (
"net/http"
"github.com/navidrome/navidrome/server/subsonic/responses"
)
func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) {
response := newResponse()
response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{
{Name: "transcodeOffset", Versions: []int32{1}},
{Name: "formPost", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1}},
{Name: "indexBasedQueue", Versions: []int32{1}},
}
return response, nil
}

View File

@@ -0,0 +1,45 @@
package subsonic_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/server/subsonic"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GetOpenSubsonicExtensions", func() {
var (
router *subsonic.Router
w *httptest.ResponseRecorder
r *http.Request
)
BeforeEach(func() {
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
})
It("should return the correct OpenSubsonicExtensions", func() {
router.ServeHTTP(w, r)
// Make sure the endpoint is public, by not passing any authentication
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var response responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(4),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
))
})
})

View File

@@ -0,0 +1,172 @@
package subsonic
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
allPls, err := api.ds.Playlist(ctx).GetAll(model.QueryOptions{Sort: "name"})
if err != nil {
log.Error(r, err)
return nil, err
}
response := newResponse()
response.Playlists = &responses.Playlists{
Playlist: slice.Map(allPls, api.buildPlaylist),
}
return response, nil
}
func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
return api.getPlaylist(ctx, id)
}
func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subsonic, error) {
pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true, false)
if errors.Is(err, model.ErrNotFound) {
log.Error(ctx, err.Error(), "id", id)
return nil, newError(responses.ErrorDataNotFound, "playlist not found")
}
if err != nil {
log.Error(ctx, err)
return nil, err
}
response := newResponse()
response.Playlist = &responses.PlaylistWithSongs{
Playlist: api.buildPlaylist(*pls),
}
response.Playlist.Entry = slice.MapWithArg(pls.MediaFiles(), ctx, childFromMediaFile)
return response, nil
}
func (api *Router) create(ctx context.Context, playlistId, name string, ids []string) (string, error) {
err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
owner := getUser(ctx)
var pls *model.Playlist
var err error
if playlistId != "" {
pls, err = tx.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
if owner.ID != pls.OwnerID {
return model.ErrNotAuthorized
}
} else {
pls = &model.Playlist{Name: name}
pls.OwnerID = owner.ID
}
pls.Tracks = nil
pls.AddMediaFilesByID(ids)
err = tx.Playlist(ctx).Put(pls)
playlistId = pls.ID
return err
})
return playlistId, err
}
func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
p := req.Params(r)
songIds, _ := p.Strings("songId")
playlistId, _ := p.String("playlistId")
name, _ := p.String("name")
if playlistId == "" && name == "" {
return nil, errors.New("required parameter name is missing")
}
id, err := api.create(ctx, playlistId, name, songIds)
if err != nil {
log.Error(r, err)
return nil, err
}
return api.getPlaylist(ctx, id)
}
func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
err = api.ds.Playlist(r.Context()).Delete(id)
if errors.Is(err, model.ErrNotAuthorized) {
return nil, newError(responses.ErrorAuthorizationFail)
}
if err != nil {
log.Error(r, err)
return nil, err
}
return newResponse(), nil
}
func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
playlistId, err := p.String("playlistId")
if err != nil {
return nil, err
}
songsToAdd, _ := p.Strings("songIdToAdd")
songIndexesToRemove, _ := p.Ints("songIndexToRemove")
var plsName *string
if s, err := p.String("name"); err == nil {
plsName = &s
}
comment := p.StringPtr("comment")
public := p.BoolPtr("public")
log.Debug(r, "Updating playlist", "id", playlistId)
if plsName != nil {
log.Trace(r, fmt.Sprintf("-- New Name: '%s'", *plsName))
}
log.Trace(r, fmt.Sprintf("-- Adding: '%v'", songsToAdd))
log.Trace(r, fmt.Sprintf("-- Removing: '%v'", songIndexesToRemove))
err = api.playlists.Update(r.Context(), playlistId, plsName, comment, public, songsToAdd, songIndexesToRemove)
if errors.Is(err, model.ErrNotAuthorized) {
return nil, newError(responses.ErrorAuthorizationFail)
}
if err != nil {
log.Error(r, "Error updating playlist", "id", playlistId, err)
return nil, err
}
return newResponse(), nil
}
func (api *Router) buildPlaylist(p model.Playlist) responses.Playlist {
pls := responses.Playlist{}
pls.Id = p.ID
pls.Name = p.Name
pls.Comment = p.Comment
pls.SongCount = int32(p.SongCount)
pls.Owner = p.OwnerName
pls.Duration = int32(p.Duration)
pls.Public = p.Public
pls.Created = p.CreatedAt
pls.CoverArt = p.CoverArtID().String()
if p.IsSmartPlaylist() {
pls.Changed = time.Now()
} else {
pls.Changed = p.UpdatedAt
}
return pls
}

View File

@@ -0,0 +1,88 @@
package subsonic
import (
"context"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ core.Playlists = (*fakePlaylists)(nil)
var _ = Describe("UpdatePlaylist", func() {
var router *Router
var ds model.DataStore
var playlists *fakePlaylists
BeforeEach(func() {
ds = &tests.MockDataStore{}
playlists = &fakePlaylists{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
})
It("clears the comment when parameter is empty", func() {
r := newGetRequest("playlistId=123", "comment=")
_, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(playlists.lastPlaylistID).To(Equal("123"))
Expect(playlists.lastComment).ToNot(BeNil())
Expect(*playlists.lastComment).To(Equal(""))
})
It("leaves comment unchanged when parameter is missing", func() {
r := newGetRequest("playlistId=123")
_, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(playlists.lastPlaylistID).To(Equal("123"))
Expect(playlists.lastComment).To(BeNil())
})
It("sets public to true when parameter is 'true'", func() {
r := newGetRequest("playlistId=123", "public=true")
_, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(playlists.lastPlaylistID).To(Equal("123"))
Expect(playlists.lastPublic).ToNot(BeNil())
Expect(*playlists.lastPublic).To(BeTrue())
})
It("sets public to false when parameter is 'false'", func() {
r := newGetRequest("playlistId=123", "public=false")
_, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(playlists.lastPlaylistID).To(Equal("123"))
Expect(playlists.lastPublic).ToNot(BeNil())
Expect(*playlists.lastPublic).To(BeFalse())
})
It("leaves public unchanged when parameter is missing", func() {
r := newGetRequest("playlistId=123")
_, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(playlists.lastPlaylistID).To(Equal("123"))
Expect(playlists.lastPublic).To(BeNil())
})
})
type fakePlaylists struct {
core.Playlists
lastPlaylistID string
lastName *string
lastComment *string
lastPublic *bool
lastAdd []string
lastRemove []int
}
func (f *fakePlaylists) Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error {
f.lastPlaylistID = playlistID
f.lastName = name
f.lastComment = comment
f.lastPublic = public
f.lastAdd = idsToAdd
f.lastRemove = idxToRemove
return nil
}

111
server/subsonic/radio.go Normal file
View File

@@ -0,0 +1,111 @@
package subsonic
import (
"net/http"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
)
func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
streamUrl, err := p.String("streamUrl")
if err != nil {
return nil, err
}
name, err := p.String("name")
if err != nil {
return nil, err
}
homepageUrl, _ := p.String("homepageUrl")
ctx := r.Context()
radio := &model.Radio{
StreamUrl: streamUrl,
HomePageUrl: homepageUrl,
Name: name,
}
err = api.ds.Radio(ctx).Put(radio)
if err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
err = api.ds.Radio(r.Context()).Delete(id)
if err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
radios, err := api.ds.Radio(ctx).GetAll(model.QueryOptions{Sort: "name"})
if err != nil {
return nil, err
}
res := make([]responses.Radio, len(radios))
for i, g := range radios {
res[i] = responses.Radio{
ID: g.ID,
Name: g.Name,
StreamUrl: g.StreamUrl,
HomepageUrl: g.HomePageUrl,
}
}
response := newResponse()
response.InternetRadioStations = &responses.InternetRadioStations{
Radios: res,
}
return response, nil
}
func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
streamUrl, err := p.String("streamUrl")
if err != nil {
return nil, err
}
name, err := p.String("name")
if err != nil {
return nil, err
}
homepageUrl, _ := p.String("homepageUrl")
ctx := r.Context()
radio := &model.Radio{
ID: id,
StreamUrl: streamUrl,
HomePageUrl: homepageUrl,
Name: name,
}
err = api.ds.Radio(ctx).Put(radio)
if err != nil {
return nil, err
}
return newResponse(), nil
}

View File

@@ -0,0 +1,15 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"albumInfo": {
"notes": "Believe is the twenty-third studio album by American singer-actress Cher...",
"musicBrainzId": "03c91c40-49a6-44a7-90e7-a700edf97a62",
"lastFmUrl": "https://www.last.fm/music/Cher/Believe",
"smallImageUrl": "https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png",
"mediumImageUrl": "https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png",
"largeImageUrl": "https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png"
}
}

View File

@@ -0,0 +1,10 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumInfo>
<notes>Believe is the twenty-third studio album by American singer-actress Cher...</notes>
<musicBrainzId>03c91c40-49a6-44a7-90e7-a700edf97a62</musicBrainzId>
<lastFmUrl>https://www.last.fm/music/Cher/Believe</lastFmUrl>
<smallImageUrl>https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png</smallImageUrl>
<mediumImageUrl>https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png</mediumImageUrl>
<largeImageUrl>https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png</largeImageUrl>
</albumInfo>
</subsonic-response>

View File

@@ -0,0 +1,8 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"albumInfo": {}
}

View File

@@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumInfo></albumInfo>
</subsonic-response>

View File

@@ -0,0 +1,63 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"albumList": {
"album": [
{
"id": "1",
"isDir": false,
"isVideo": false,
"bpm": 0,
"comment": "",
"sortName": "sort name",
"mediaType": "album",
"musicBrainzId": "00000000-0000-0000-0000-000000000000",
"isrc": [],
"genres": [
{
"name": "Genre 1"
},
{
"name": "Genre 2"
}
],
"replayGain": {},
"channelCount": 0,
"samplingRate": 0,
"bitDepth": 0,
"moods": [
"mood1",
"mood2"
],
"artists": [
{
"id": "artist-1",
"name": "Artist 1"
},
{
"id": "artist-2",
"name": "Artist 2"
}
],
"displayArtist": "Display artist",
"albumArtists": [
{
"id": "album-artist-1",
"name": "Artist 1"
},
{
"id": "album-artist-2",
"name": "Artist 2"
}
],
"displayAlbumArtist": "Display album artist",
"contributors": [],
"displayComposer": "",
"explicitStatus": "explicit"
}
]
}
}

View File

@@ -0,0 +1,14 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumList>
<album id="1" isDir="false" isVideo="false" sortName="sort name" mediaType="album" musicBrainzId="00000000-0000-0000-0000-000000000000" displayArtist="Display artist" displayAlbumArtist="Display album artist" explicitStatus="explicit">
<genres name="Genre 1"></genres>
<genres name="Genre 2"></genres>
<moods>mood1</moods>
<moods>mood2</moods>
<artists id="artist-1" name="Artist 1"></artists>
<artists id="artist-2" name="Artist 2"></artists>
<albumArtists id="album-artist-1" name="Artist 1"></albumArtists>
<albumArtists id="album-artist-2" name="Artist 2"></albumArtists>
</album>
</albumList>
</subsonic-response>

View File

@@ -0,0 +1,17 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"albumList": {
"album": [
{
"id": "1",
"isDir": false,
"title": "title",
"isVideo": false
}
]
}
}

View File

@@ -0,0 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumList>
<album id="1" isDir="false" title="title" isVideo="false"></album>
</albumList>
</subsonic-response>

View File

@@ -0,0 +1,8 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"albumList": {}
}

View File

@@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<albumList></albumList>
</subsonic-response>

View File

@@ -0,0 +1,218 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"album": {
"id": "1",
"name": "album",
"artist": "artist",
"genre": "rock",
"userRating": 4,
"genres": [
{
"name": "rock"
},
{
"name": "progressive"
}
],
"musicBrainzId": "1234",
"isCompilation": true,
"sortName": "sorted album",
"discTitles": [
{
"disc": 1,
"title": "disc 1"
},
{
"disc": 2,
"title": "disc 2"
},
{
"disc": 3,
"title": ""
}
],
"originalReleaseDate": {
"year": 1994,
"month": 2,
"day": 4
},
"releaseDate": {
"year": 2000,
"month": 5,
"day": 10
},
"releaseTypes": [
"album",
"live"
],
"recordLabels": [
{
"name": "label1"
},
{
"name": "label2"
}
],
"moods": [
"happy",
"sad"
],
"artists": [
{
"id": "1",
"name": "artist1"
},
{
"id": "2",
"name": "artist2"
}
],
"displayArtist": "artist1 \u0026 artist2",
"explicitStatus": "clean",
"version": "Deluxe Edition",
"song": [
{
"id": "1",
"isDir": true,
"title": "title",
"album": "album",
"artist": "artist",
"track": 1,
"year": 1985,
"genre": "Rock",
"coverArt": "1",
"size": 8421341,
"contentType": "audio/flac",
"suffix": "flac",
"starred": "2016-03-02T20:30:00Z",
"transcodedContentType": "audio/mpeg",
"transcodedSuffix": "mp3",
"duration": 146,
"bitRate": 320,
"isVideo": false,
"bpm": 127,
"comment": "a comment",
"sortName": "sorted song",
"mediaType": "song",
"musicBrainzId": "4321",
"isrc": [
"ISRC-1"
],
"genres": [
{
"name": "rock"
},
{
"name": "progressive"
}
],
"replayGain": {
"trackGain": 1,
"albumGain": 2,
"trackPeak": 3,
"albumPeak": 4,
"baseGain": 5,
"fallbackGain": 6
},
"channelCount": 2,
"samplingRate": 44100,
"bitDepth": 16,
"moods": [
"happy",
"sad"
],
"artists": [
{
"id": "1",
"name": "artist1"
},
{
"id": "2",
"name": "artist2"
}
],
"displayArtist": "artist1 \u0026 artist2",
"albumArtists": [
{
"id": "1",
"name": "album artist1"
},
{
"id": "2",
"name": "album artist2"
}
],
"displayAlbumArtist": "album artist1 \u0026 album artist2",
"contributors": [
{
"role": "role1",
"artist": {
"id": "1",
"name": "artist1"
}
},
{
"role": "role2",
"subRole": "subrole4",
"artist": {
"id": "2",
"name": "artist2"
}
}
],
"displayComposer": "composer 1 \u0026 composer 2",
"explicitStatus": "clean"
},
{
"id": "2",
"isDir": true,
"title": "title",
"album": "album",
"artist": "artist",
"track": 1,
"year": 1985,
"genre": "Rock",
"coverArt": "1",
"size": 8421341,
"contentType": "audio/flac",
"suffix": "flac",
"starred": "2016-03-02T20:30:00Z",
"transcodedContentType": "audio/mpeg",
"transcodedSuffix": "mp3",
"duration": 146,
"bitRate": 320,
"isVideo": false,
"bpm": 0,
"comment": "",
"sortName": "",
"mediaType": "",
"musicBrainzId": "",
"isrc": [],
"genres": [],
"replayGain": {
"trackGain": 0,
"albumGain": 0,
"trackPeak": 0,
"albumPeak": 0,
"baseGain": 0,
"fallbackGain": 0
},
"channelCount": 0,
"samplingRate": 0,
"bitDepth": 0,
"moods": [],
"artists": [],
"displayArtist": "",
"albumArtists": [],
"displayAlbumArtist": "",
"contributors": [],
"displayComposer": "",
"explicitStatus": ""
}
]
}
}

View File

@@ -0,0 +1,40 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<album id="1" name="album" artist="artist" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 &amp; artist2" explicitStatus="clean" version="Deluxe Edition">
<genres name="rock"></genres>
<genres name="progressive"></genres>
<discTitles disc="1" title="disc 1"></discTitles>
<discTitles disc="2" title="disc 2"></discTitles>
<discTitles disc="3" title=""></discTitles>
<originalReleaseDate year="1994" month="2" day="4"></originalReleaseDate>
<releaseDate year="2000" month="5" day="10"></releaseDate>
<releaseTypes>album</releaseTypes>
<releaseTypes>live</releaseTypes>
<recordLabels name="label1"></recordLabels>
<recordLabels name="label2"></recordLabels>
<moods>happy</moods>
<moods>sad</moods>
<artists id="1" name="artist1"></artists>
<artists id="2" name="artist2"></artists>
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 &amp; artist2" displayAlbumArtist="album artist1 &amp; album artist2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean">
<isrc>ISRC-1</isrc>
<genres name="rock"></genres>
<genres name="progressive"></genres>
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>
<moods>happy</moods>
<moods>sad</moods>
<artists id="1" name="artist1"></artists>
<artists id="2" name="artist2"></artists>
<albumArtists id="1" name="album artist1"></albumArtists>
<albumArtists id="2" name="album artist2"></albumArtists>
<contributors role="role1">
<artist id="1" name="artist1"></artist>
</contributors>
<contributors role="role2" subRole="subrole4">
<artist id="2" name="artist2"></artist>
</contributors>
</song>
<song id="2" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false">
<replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain>
</song>
</album>
</subsonic-response>

View File

@@ -0,0 +1,11 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"album": {
"id": "",
"name": ""
}
}

View File

@@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<album id="" name=""></album>
</subsonic-response>

View File

@@ -0,0 +1,26 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"album": {
"id": "",
"name": "",
"userRating": 0,
"genres": [],
"musicBrainzId": "",
"isCompilation": false,
"sortName": "",
"discTitles": [],
"originalReleaseDate": {},
"releaseDate": {},
"releaseTypes": [],
"recordLabels": [],
"moods": [],
"artists": [],
"displayArtist": "",
"explicitStatus": "",
"version": ""
}
}

View File

@@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<album id="" name=""></album>
</subsonic-response>

View File

@@ -0,0 +1,32 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"artists": {
"index": [
{
"name": "A",
"artist": [
{
"id": "111",
"name": "aaa",
"albumCount": 2,
"starred": "2016-03-02T20:30:00Z",
"userRating": 3,
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
"musicBrainzId": "1234",
"sortName": "sort name",
"roles": [
"role1",
"role2"
]
}
]
}
],
"lastModified": 1,
"ignoredArticles": "A"
}
}

View File

@@ -0,0 +1,10 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<artists lastModified="1" ignoredArticles="A">
<index name="A">
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="1234" sortName="sort name">
<roles>role1</roles>
<roles>role2</roles>
</artist>
</index>
</artists>
</subsonic-response>

View File

@@ -0,0 +1,32 @@
{
"status": "ok",
"version": "1.8.0",
"type": "navidrome",
"serverVersion": "v0.0.0",
"openSubsonic": true,
"artists": {
"index": [
{
"name": "A",
"artist": [
{
"id": "111",
"name": "aaa",
"albumCount": 2,
"starred": "2016-03-02T20:30:00Z",
"userRating": 3,
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
"musicBrainzId": "1234",
"sortName": "sort name",
"roles": [
"role1",
"role2"
]
}
]
}
],
"lastModified": 1,
"ignoredArticles": "A"
}
}

View File

@@ -0,0 +1,10 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<artists lastModified="1" ignoredArticles="A">
<index name="A">
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="1234" sortName="sort name">
<roles>role1</roles>
<roles>role2</roles>
</artist>
</index>
</artists>
</subsonic-response>

View File

@@ -0,0 +1,26 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"artists": {
"index": [
{
"name": "A",
"artist": [
{
"id": "111",
"name": "aaa",
"albumCount": 2,
"starred": "2016-03-02T20:30:00Z",
"userRating": 3,
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"
}
]
}
],
"lastModified": 1,
"ignoredArticles": "A"
}
}

View File

@@ -0,0 +1,7 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<artists lastModified="1" ignoredArticles="A">
<index name="A">
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"></artist>
</index>
</artists>
</subsonic-response>

View File

@@ -0,0 +1,11 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"artists": {
"lastModified": 1,
"ignoredArticles": "A"
}
}

View File

@@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<artists lastModified="1" ignoredArticles="A"></artists>
</subsonic-response>

View File

@@ -0,0 +1,29 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"artistInfo": {
"biography": "Black Sabbath is an English \u003ca target='_blank' href=\"https://www.last.fm/tag/heavy%20metal\" class=\"bbcode_tag\" rel=\"tag\"\u003eheavy metal\u003c/a\u003e band",
"musicBrainzId": "5182c1d9-c7d2-4dad-afa0-ccfeada921a8",
"lastFmUrl": "https://www.last.fm/music/Black+Sabbath",
"smallImageUrl": "https://userserve-ak.last.fm/serve/64/27904353.jpg",
"mediumImageUrl": "https://userserve-ak.last.fm/serve/126/27904353.jpg",
"largeImageUrl": "https://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg",
"similarArtist": [
{
"id": "22",
"name": "Accept"
},
{
"id": "101",
"name": "Bruce Dickinson"
},
{
"id": "26",
"name": "Aerosmith"
}
]
}
}

View File

@@ -0,0 +1,13 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<artistInfo>
<biography>Black Sabbath is an English &lt;a target=&#39;_blank&#39; href=&#34;https://www.last.fm/tag/heavy%20metal&#34; class=&#34;bbcode_tag&#34; rel=&#34;tag&#34;&gt;heavy metal&lt;/a&gt; band</biography>
<musicBrainzId>5182c1d9-c7d2-4dad-afa0-ccfeada921a8</musicBrainzId>
<lastFmUrl>https://www.last.fm/music/Black+Sabbath</lastFmUrl>
<smallImageUrl>https://userserve-ak.last.fm/serve/64/27904353.jpg</smallImageUrl>
<mediumImageUrl>https://userserve-ak.last.fm/serve/126/27904353.jpg</mediumImageUrl>
<largeImageUrl>https://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg</largeImageUrl>
<similarArtist id="22" name="Accept"></similarArtist>
<similarArtist id="101" name="Bruce Dickinson"></similarArtist>
<similarArtist id="26" name="Aerosmith"></similarArtist>
</artistInfo>
</subsonic-response>

View File

@@ -0,0 +1,8 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"artistInfo": {}
}

View File

@@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<artistInfo></artistInfo>
</subsonic-response>

View File

@@ -0,0 +1,24 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"bookmarks": {
"bookmark": [
{
"entry": {
"id": "1",
"isDir": false,
"title": "title",
"isVideo": false
},
"position": 123,
"username": "user2",
"comment": "a comment",
"created": "0001-01-01T00:00:00Z",
"changed": "0001-01-01T00:00:00Z"
}
]
}
}

View File

@@ -0,0 +1,7 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<bookmarks>
<bookmark position="123" username="user2" comment="a comment" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z">
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
</bookmark>
</bookmarks>
</subsonic-response>

View File

@@ -0,0 +1,8 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"bookmarks": {}
}

View File

@@ -0,0 +1,3 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<bookmarks></bookmarks>
</subsonic-response>

View File

@@ -0,0 +1,151 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"directory": {
"child": [
{
"id": "1",
"isDir": true,
"title": "title",
"album": "album",
"artist": "artist",
"track": 1,
"year": 1985,
"genre": "Rock",
"coverArt": "1",
"size": 8421341,
"contentType": "audio/flac",
"suffix": "flac",
"starred": "2016-03-02T20:30:00Z",
"transcodedContentType": "audio/mpeg",
"transcodedSuffix": "mp3",
"duration": 146,
"bitRate": 320,
"isVideo": false,
"bpm": 127,
"comment": "a comment",
"sortName": "sorted title",
"mediaType": "song",
"musicBrainzId": "4321",
"isrc": [
"ISRC-1",
"ISRC-2"
],
"genres": [
{
"name": "rock"
},
{
"name": "progressive"
}
],
"replayGain": {
"trackGain": 1,
"albumGain": 2,
"trackPeak": 3,
"albumPeak": 4,
"baseGain": 5,
"fallbackGain": 6
},
"channelCount": 2,
"samplingRate": 44100,
"bitDepth": 16,
"moods": [
"happy",
"sad"
],
"artists": [
{
"id": "1",
"name": "artist1"
},
{
"id": "2",
"name": "artist2"
}
],
"displayArtist": "artist 1 \u0026 artist 2",
"albumArtists": [
{
"id": "1",
"name": "album artist1"
},
{
"id": "2",
"name": "album artist2"
}
],
"displayAlbumArtist": "album artist 1 \u0026 album artist 2",
"contributors": [
{
"role": "role1",
"subRole": "subrole3",
"artist": {
"id": "1",
"name": "artist1"
}
},
{
"role": "role2",
"artist": {
"id": "2",
"name": "artist2"
}
},
{
"role": "composer",
"artist": {
"id": "3",
"name": "composer1"
}
},
{
"role": "composer",
"artist": {
"id": "4",
"name": "composer2"
}
}
],
"displayComposer": "composer 1 \u0026 composer 2",
"explicitStatus": "clean"
},
{
"id": "",
"isDir": false,
"isVideo": false,
"bpm": 0,
"comment": "",
"sortName": "",
"mediaType": "",
"musicBrainzId": "",
"isrc": [],
"genres": [],
"replayGain": {
"trackGain": 0,
"albumGain": 0,
"trackPeak": 0,
"albumPeak": 0,
"baseGain": 0,
"fallbackGain": 0
},
"channelCount": 0,
"samplingRate": 0,
"bitDepth": 0,
"moods": [],
"artists": [],
"displayArtist": "",
"albumArtists": [],
"displayAlbumArtist": "",
"contributors": [],
"displayComposer": "",
"explicitStatus": ""
}
],
"id": "1",
"name": "N"
}
}

View File

@@ -0,0 +1,32 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<directory id="1" name="N">
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 &amp; artist 2" displayAlbumArtist="album artist 1 &amp; album artist 2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean">
<isrc>ISRC-1</isrc>
<isrc>ISRC-2</isrc>
<genres name="rock"></genres>
<genres name="progressive"></genres>
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>
<moods>happy</moods>
<moods>sad</moods>
<artists id="1" name="artist1"></artists>
<artists id="2" name="artist2"></artists>
<albumArtists id="1" name="album artist1"></albumArtists>
<albumArtists id="2" name="album artist2"></albumArtists>
<contributors role="role1" subRole="subrole3">
<artist id="1" name="artist1"></artist>
</contributors>
<contributors role="role2">
<artist id="2" name="artist2"></artist>
</contributors>
<contributors role="composer">
<artist id="3" name="composer1"></artist>
</contributors>
<contributors role="composer">
<artist id="4" name="composer2"></artist>
</contributors>
</child>
<child id="" isDir="false" isVideo="false">
<replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain>
</child>
</directory>
</subsonic-response>

View File

@@ -0,0 +1,18 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"directory": {
"child": [
{
"id": "1",
"isDir": false,
"isVideo": false
}
],
"id": "",
"name": ""
}
}

Some files were not shown because too many files have changed in this diff Show More