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

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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