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
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:
371
server/auth.go
Normal file
371
server/auth.go
Normal 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
345
server/auth_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
140
server/backgrounds/handler.go
Normal file
140
server/backgrounds/handler.go
Normal 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
89
server/events/events.go
Normal 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)
|
||||
}
|
||||
17
server/events/events_suite_test.go
Normal file
17
server/events/events_suite_test.go
Normal 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")
|
||||
}
|
||||
46
server/events/events_test.go
Normal file
46
server/events/events_test.go
Normal 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
291
server/events/sse.go
Normal 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
61
server/events/sse_test.go
Normal 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
101
server/initial_setup.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
36
server/initial_setup_test.go
Normal file
36
server/initial_setup_test.go
Normal 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
329
server/middlewares.go
Normal 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
409
server/middlewares_test.go
Normal 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
132
server/nativeapi/config.go
Normal 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)
|
||||
}
|
||||
}
|
||||
227
server/nativeapi/config_test.go
Normal file
227
server/nativeapi/config_test.go
Normal 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(®ularUser)).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(®ularUser)
|
||||
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
|
||||
}
|
||||
67
server/nativeapi/inspect.go
Normal file
67
server/nativeapi/inspect.go
Normal 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
101
server/nativeapi/library.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
424
server/nativeapi/library_test.go
Normal file
424
server/nativeapi/library_test.go
Normal 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(®ularUser)).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(®ularUser)
|
||||
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(®ularUser)
|
||||
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
|
||||
}
|
||||
94
server/nativeapi/missing.go
Normal file
94
server/nativeapi/missing.go
Normal 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{}
|
||||
247
server/nativeapi/native_api.go
Normal file
247
server/nativeapi/native_api.go
Normal 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)
|
||||
})
|
||||
}
|
||||
431
server/nativeapi/native_api_song_test.go
Normal file
431
server/nativeapi/native_api_song_test.go
Normal 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(®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create JWT token for regular user
|
||||
token, err := auth.CreateToken(®ularUser)
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
17
server/nativeapi/native_api_suite_test.go
Normal file
17
server/nativeapi/native_api_suite_test.go
Normal 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")
|
||||
}
|
||||
244
server/nativeapi/playlists.go
Normal file
244
server/nativeapi/playlists.go
Normal 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
214
server/nativeapi/queue.go
Normal 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)
|
||||
}
|
||||
}
|
||||
282
server/nativeapi/queue_test.go
Normal file
282
server/nativeapi/queue_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
123
server/nativeapi/translations.go
Normal file
123
server/nativeapi/translations.go
Normal 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)
|
||||
47
server/nativeapi/translations_test.go
Normal file
47
server/nativeapi/translations_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
71
server/public/encode_id.go
Normal file
71
server/public/encode_id.go
Normal 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
|
||||
}
|
||||
39
server/public/encode_id_test.go
Normal file
39
server/public/encode_id_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
18
server/public/handle_downloads.go
Normal file
18
server/public/handle_downloads.go
Normal 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)
|
||||
}
|
||||
67
server/public/handle_images.go
Normal file
67
server/public/handle_images.go
Normal 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)
|
||||
}
|
||||
}
|
||||
94
server/public/handle_shares.go
Normal file
94
server/public/handle_shares.go
Normal 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
|
||||
}
|
||||
105
server/public/handle_streams.go
Normal file
105
server/public/handle_streams.go
Normal 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
85
server/public/public.go
Normal 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)
|
||||
}
|
||||
17
server/public/public_suite_test.go
Normal file
17
server/public/public_suite_test.go
Normal 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")
|
||||
}
|
||||
56
server/public/public_test.go
Normal file
56
server/public/public_test.go
Normal 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
183
server/serve_index.go
Normal 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
331
server/serve_index_test.go
Normal 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
314
server/server.go
Normal 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
|
||||
}
|
||||
17
server/server_suite_test.go
Normal file
17
server/server_suite_test.go
Normal 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
259
server/server_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
285
server/subsonic/album_lists.go
Normal file
285
server/subsonic/album_lists.go
Normal 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)
|
||||
}
|
||||
542
server/subsonic/album_lists_test.go
Normal file
542
server/subsonic/album_lists_test.go
Normal 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
361
server/subsonic/api.go
Normal 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)
|
||||
}
|
||||
}
|
||||
17
server/subsonic/api_suite_test.go
Normal file
17
server/subsonic/api_suite_test.go
Normal 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
127
server/subsonic/api_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
208
server/subsonic/bookmarks.go
Normal file
208
server/subsonic/bookmarks.go
Normal 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
470
server/subsonic/browsing.go
Normal 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
|
||||
}
|
||||
160
server/subsonic/browsing_test.go
Normal file
160
server/subsonic/browsing_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
182
server/subsonic/filter/filters.go
Normal file
182
server/subsonic/filter/filters.go
Normal 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
515
server/subsonic/helpers.go
Normal 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
|
||||
}
|
||||
275
server/subsonic/helpers_test.go
Normal file
275
server/subsonic/helpers_test.go
Normal 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
136
server/subsonic/jukebox.go
Normal 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),
|
||||
}
|
||||
}
|
||||
103
server/subsonic/library_scanning.go
Normal file
103
server/subsonic/library_scanning.go
Normal 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)
|
||||
}
|
||||
396
server/subsonic/library_scanning_test.go
Normal file
396
server/subsonic/library_scanning_test.go
Normal 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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
222
server/subsonic/media_annotation.go
Normal file
222
server/subsonic/media_annotation.go
Normal 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
|
||||
}
|
||||
145
server/subsonic/media_annotation_test.go
Normal file
145
server/subsonic/media_annotation_test.go
Normal 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)
|
||||
153
server/subsonic/media_retrieval.go
Normal file
153
server/subsonic/media_retrieval.go
Normal 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
|
||||
}
|
||||
379
server/subsonic/media_retrieval_test.go
Normal file
379
server/subsonic/media_retrieval_test.go
Normal 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: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[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: ×[0],
|
||||
Value: "We're no strangers to love",
|
||||
},
|
||||
{
|
||||
Start: ×[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
|
||||
}
|
||||
272
server/subsonic/middlewares.go
Normal file
272
server/subsonic/middlewares.go
Normal 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)
|
||||
}
|
||||
}
|
||||
501
server/subsonic/middlewares_test.go
Normal file
501
server/subsonic/middlewares_test.go
Normal 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
|
||||
}
|
||||
18
server/subsonic/opensubsonic.go
Normal file
18
server/subsonic/opensubsonic.go
Normal 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
|
||||
}
|
||||
45
server/subsonic/opensubsonic_test.go
Normal file
45
server/subsonic/opensubsonic_test.go
Normal 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}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
172
server/subsonic/playlists.go
Normal file
172
server/subsonic/playlists.go
Normal 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
|
||||
}
|
||||
88
server/subsonic/playlists_test.go
Normal file
88
server/subsonic/playlists_test.go
Normal 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
111
server/subsonic/radio.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"albumInfo": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"albumList": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 & 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 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & 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>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"album": {
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"artists": {
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 <a target='_blank' href="https://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> 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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"artistInfo": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"bookmarks": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & 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>
|
||||
@@ -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
Reference in New Issue
Block a user