refactor library service to use REST repository pattern and remove CRUD operations

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-06-06 18:47:21 -04:00
commit d2a2c43ef5
11 changed files with 476 additions and 406 deletions

View file

@ -2,9 +2,12 @@ package core
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -12,17 +15,11 @@ import (
// Library provides business logic for library management and user-library associations
type Library interface {
// Library CRUD operations
GetAll(ctx context.Context) (model.Libraries, error)
Get(ctx context.Context, id int) (*model.Library, error)
Create(ctx context.Context, library *model.Library) error
Update(ctx context.Context, library *model.Library) error
Delete(ctx context.Context, id int) error
// User-library association operations
GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error)
SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error
ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error
NewRepository(ctx context.Context) rest.Repository
}
type libraryService struct {
@ -34,46 +31,6 @@ func NewLibrary(ds model.DataStore) Library {
return &libraryService{ds: ds}
}
// Library CRUD operations
func (s *libraryService) GetAll(ctx context.Context) (model.Libraries, error) {
return s.ds.Library(ctx).GetAll()
}
func (s *libraryService) Get(ctx context.Context, id int) (*model.Library, error) {
return s.ds.Library(ctx).Get(id)
}
func (s *libraryService) Create(ctx context.Context, library *model.Library) error {
if err := s.validateLibrary(library); err != nil {
return err
}
return s.ds.Library(ctx).Put(library)
}
func (s *libraryService) Update(ctx context.Context, library *model.Library) error {
if err := s.validateLibrary(library); err != nil {
return err
}
// Verify library exists
if _, err := s.ds.Library(ctx).Get(library.ID); err != nil {
return err
}
return s.ds.Library(ctx).Put(library)
}
func (s *libraryService) Delete(ctx context.Context, id int) error {
// Verify library exists
if _, err := s.ds.Library(ctx).Get(id); err != nil {
return err
}
return s.ds.Library(ctx).Delete(id)
}
// User-library association operations
func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
@ -139,20 +96,110 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin
return fmt.Errorf("%w: user does not have access to library %d", model.ErrNotAuthorized, libraryID)
}
// REST repository wrapper
func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
repo := s.ds.Library(ctx)
wrapper := &libraryRepositoryWrapper{
ctx: ctx,
LibraryRepository: repo,
Repository: repo.(rest.Repository),
ds: s.ds,
service: s,
}
return wrapper
}
type libraryRepositoryWrapper struct {
rest.Repository
model.LibraryRepository
ctx context.Context
ds model.DataStore
service *libraryService
}
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
lib := entity.(*model.Library)
if err := r.validateLibrary(lib); err != nil {
return "", err
}
err := r.LibraryRepository.Put(lib)
if err != nil {
return "", r.mapError(err)
}
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error {
lib := entity.(*model.Library)
libID, err := strconv.Atoi(id)
if err != nil {
return fmt.Errorf("invalid library ID: %s", id)
}
lib.ID = libID
if err := r.validateLibrary(lib); err != nil {
return err
}
// Verify library exists
if _, err := r.Get(libID); err != nil {
return r.mapError(err)
}
err = r.LibraryRepository.Put(lib)
return r.mapError(err)
}
func (r *libraryRepositoryWrapper) Delete(id string) error {
libID, err := strconv.Atoi(id)
if err != nil {
return rest.ValidationError{Errors: map[string]string{
"id": "invalid library ID format",
}}
}
err = r.LibraryRepository.Delete(libID)
return r.mapError(err)
}
// Helper methods
func (s *libraryService) validateLibrary(library *model.Library) error {
func (r *libraryRepositoryWrapper) mapError(err error) error {
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
}
func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error {
if library.Name == "" {
return fmt.Errorf("%w: library name is required", model.ErrValidation)
return rest.ValidationError{Errors: map[string]string{
"name": "library name is required",
}}
}
if library.Path == "" {
return fmt.Errorf("%w: library path is required", model.ErrValidation)
return rest.ValidationError{Errors: map[string]string{
"path": "library path is required",
}}
}
return nil
}
func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error {
// Use CountAll with IN filter to efficiently check if all library IDs exist
if len(libraryIDs) == 0 {
return nil
}
// Use CountAll to efficiently validate library IDs exist
count, err := s.ds.Library(ctx).CountAll(model.QueryOptions{
Filters: squirrel.Eq{"id": libraryIDs},
})
@ -161,24 +208,7 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
}
if int(count) != len(libraryIDs) {
// Find which library IDs don't exist for better error message
existingLibraries, err := s.ds.Library(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"id": libraryIDs},
})
if err != nil {
return fmt.Errorf("error getting libraries: %w", err)
}
libraryMap := make(map[int]bool)
for _, lib := range existingLibraries {
libraryMap[lib.ID] = true
}
for _, libID := range libraryIDs {
if !libraryMap[libID] {
return fmt.Errorf("%w: library ID %d does not exist", model.ErrNotFound, libID)
}
}
return fmt.Errorf("%w: one or more library IDs are invalid", model.ErrValidation)
}
return nil

View file

@ -1,9 +1,11 @@
package core
package core_test
import (
"context"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
@ -12,7 +14,7 @@ import (
)
var _ = Describe("Library Service", func() {
var service Library
var service core.Library
var ds *tests.MockDataStore
var libraryRepo *tests.MockLibraryRepo
var userRepo *tests.MockedUserRepo
@ -27,60 +29,23 @@ var _ = Describe("Library Service", func() {
ds.MockedLibrary = libraryRepo
ds.MockedUser = userRepo
service = NewLibrary(ds)
service = core.NewLibrary(ds)
ctx = context.Background()
})
Describe("Library CRUD Operations", func() {
Describe("GetAll", func() {
It("returns all libraries", func() {
libraries := model.Libraries{
{ID: 1, Name: "Library 1", Path: "/music1"},
{ID: 2, Name: "Library 2", Path: "/music2"},
}
libraryRepo.SetData(libraries)
var repo rest.Persistable
result, err := service.GetAll(ctx)
Expect(err).NotTo(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(result[0].Name).To(Equal("Library 1"))
Expect(result[1].Name).To(Equal("Library 2"))
})
It("returns empty list when no libraries exist", func() {
result, err := service.GetAll(ctx)
Expect(err).NotTo(HaveOccurred())
Expect(result).To(HaveLen(0))
})
})
Describe("Get", func() {
It("returns a specific library", func() {
library := &model.Library{ID: 1, Name: "Test Library", Path: "/music"}
libraryRepo.SetData(model.Libraries{*library})
result, err := service.Get(ctx, 1)
Expect(err).NotTo(HaveOccurred())
Expect(result.Name).To(Equal("Test Library"))
Expect(result.Path).To(Equal("/music"))
})
It("returns error when library not found", func() {
_, err := service.Get(ctx, 999)
Expect(err).To(HaveOccurred())
Expect(err).To(Equal(model.ErrNotFound))
})
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
})
Describe("Create", func() {
It("creates a new library successfully", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: "/new/music"}
err := service.Create(ctx, library)
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data[1].Name).To(Equal("New Library"))
@ -90,7 +55,7 @@ var _ = Describe("Library Service", func() {
It("fails when library name is empty", func() {
library := &model.Library{Path: "/music"}
err := service.Create(ctx, library)
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("library name is required"))
@ -99,7 +64,7 @@ var _ = Describe("Library Service", func() {
It("fails when library path is empty", func() {
library := &model.Library{Name: "Test"}
err := service.Create(ctx, library)
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("library path is required"))
@ -116,7 +81,7 @@ var _ = Describe("Library Service", func() {
It("updates an existing library successfully", func() {
library := &model.Library{ID: 1, Name: "Updated Library", Path: "/updated"}
err := service.Update(ctx, library)
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library"))
@ -126,7 +91,7 @@ var _ = Describe("Library Service", func() {
It("fails when library doesn't exist", func() {
library := &model.Library{ID: 999, Name: "Non-existent", Path: "/path"}
err := service.Update(ctx, library)
err := repo.Update("999", library)
Expect(err).To(HaveOccurred())
Expect(err).To(Equal(model.ErrNotFound))
@ -135,7 +100,7 @@ var _ = Describe("Library Service", func() {
It("fails when library name is empty", func() {
library := &model.Library{ID: 1, Path: "/music"}
err := service.Update(ctx, library)
err := repo.Update("1", library)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("library name is required"))
@ -150,14 +115,14 @@ var _ = Describe("Library Service", func() {
})
It("deletes an existing library successfully", func() {
err := service.Delete(ctx, 1)
err := repo.Delete("1")
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data).To(HaveLen(0))
})
It("fails when library doesn't exist", func() {
err := service.Delete(ctx, 999)
err := repo.Delete("999")
Expect(err).To(HaveOccurred())
Expect(err).To(Equal(model.ErrNotFound))
@ -238,14 +203,14 @@ var _ = Describe("Library Service", func() {
err := service.SetUserLibraries(ctx, "user1", []int{999})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("library ID 999 does not exist"))
Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid"))
})
It("fails when some libraries don't exist", func() {
err := service.SetUserLibraries(ctx, "user1", []int{1, 999, 2})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("library ID 999 does not exist"))
Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid"))
})
})

View file

@ -0,0 +1,234 @@
package core
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
)
// MockLibraryService provides a mock implementation of core.Library interface
// that can be used in tests to prevent nil pointer panics
type MockLibraryService struct {
Library
Err error
Libraries model.Libraries
}
// NewMockLibraryService creates a new mock library service with some default test data
func NewMockLibraryService() *MockLibraryService {
return &MockLibraryService{
Libraries: model.Libraries{
{ID: 1, Name: "Test Library 1", Path: "/music/library1"},
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
},
}
}
func (m *MockLibraryService) GetAll(ctx context.Context) (model.Libraries, error) {
if m.Err != nil {
return nil, m.Err
}
return m.Libraries, nil
}
func (m *MockLibraryService) Get(ctx context.Context, id int) (*model.Library, error) {
if m.Err != nil {
return nil, m.Err
}
for _, lib := range m.Libraries {
if lib.ID == id {
return &lib, nil
}
}
return nil, model.ErrNotFound
}
func (m *MockLibraryService) Create(ctx context.Context, library *model.Library) error {
if m.Err != nil {
return m.Err
}
if library.Name == "" {
return fmt.Errorf("%w: library name is required", model.ErrValidation)
}
if library.Path == "" {
return fmt.Errorf("%w: library path is required", model.ErrValidation)
}
// Add to mock data
library.ID = len(m.Libraries) + 1
m.Libraries = append(m.Libraries, *library)
return nil
}
func (m *MockLibraryService) Update(ctx context.Context, library *model.Library) error {
if m.Err != nil {
return m.Err
}
if library.Name == "" {
return fmt.Errorf("%w: library name is required", model.ErrValidation)
}
if library.Path == "" {
return fmt.Errorf("%w: library path is required", model.ErrValidation)
}
// Find and update in mock data
for i, lib := range m.Libraries {
if lib.ID == library.ID {
m.Libraries[i] = *library
return nil
}
}
return model.ErrNotFound
}
func (m *MockLibraryService) Delete(ctx context.Context, id int) error {
if m.Err != nil {
return m.Err
}
// Find and remove from mock data
for i, lib := range m.Libraries {
if lib.ID == id {
m.Libraries = append(m.Libraries[:i], m.Libraries[i+1:]...)
return nil
}
}
return model.ErrNotFound
}
func (m *MockLibraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
if m.Err != nil {
return nil, m.Err
}
if userID == "non-existent" {
return nil, model.ErrNotFound
}
// Return all libraries for simplicity in tests
return m.Libraries, nil
}
func (m *MockLibraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
if m.Err != nil {
return m.Err
}
if userID == "non-existent" {
return model.ErrNotFound
}
if userID == "admin-1" {
return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation)
}
if len(libraryIDs) == 0 {
return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation)
}
// Validate all library IDs exist
for _, id := range libraryIDs {
found := false
for _, lib := range m.Libraries {
if lib.ID == id {
found = true
break
}
}
if !found {
return fmt.Errorf("%w: library ID %d does not exist", model.ErrValidation, id)
}
}
return nil
}
func (m *MockLibraryService) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error {
if m.Err != nil {
return m.Err
}
// For testing purposes, allow access to all libraries
return nil
}
func (m *MockLibraryService) NewRepository(ctx context.Context) rest.Repository {
return &mockLibraryRepository{service: m, ctx: ctx}
}
// mockLibraryRepository provides a REST repository wrapper for the mock service
type mockLibraryRepository struct {
service *MockLibraryService
ctx context.Context
}
func (r *mockLibraryRepository) Count(options ...rest.QueryOptions) (int64, error) {
libs, err := r.service.GetAll(r.ctx)
if err != nil {
return 0, err
}
return int64(len(libs)), nil
}
func (r *mockLibraryRepository) Read(id string) (interface{}, error) {
idInt, err := strconv.Atoi(id)
if err != nil {
return nil, rest.ErrNotFound
}
lib, err := r.service.Get(r.ctx, idInt)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return lib, err
}
func (r *mockLibraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.service.GetAll(r.ctx)
}
func (r *mockLibraryRepository) EntityName() string {
return "library"
}
func (r *mockLibraryRepository) NewInstance() interface{} {
return &model.Library{}
}
func (r *mockLibraryRepository) Save(entity interface{}) (string, error) {
lib := entity.(*model.Library)
err := r.service.Create(r.ctx, lib)
if errors.Is(err, model.ErrValidation) {
return "", &rest.ValidationError{Errors: map[string]string{"validation": err.Error()}}
}
if err != nil {
return "", err
}
return strconv.Itoa(lib.ID), nil
}
func (r *mockLibraryRepository) Update(id string, entity interface{}, cols ...string) error {
lib := entity.(*model.Library)
idInt, err := strconv.Atoi(id)
if err != nil {
return &rest.ValidationError{Errors: map[string]string{"id": "invalid library ID format"}}
}
lib.ID = idInt
err = r.service.Update(r.ctx, lib)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
if errors.Is(err, model.ErrValidation) {
return &rest.ValidationError{Errors: map[string]string{"validation": err.Error()}}
}
return err
}
func (r *mockLibraryRepository) Delete(id string) error {
idInt, err := strconv.Atoi(id)
if err != nil {
return &rest.ValidationError{Errors: map[string]string{"id": "invalid library ID format"}}
}
err = r.service.Delete(r.ctx, idInt)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
}
var _ Library = (*MockLibraryService)(nil)
var _ rest.Repository = (*mockLibraryRepository)(nil)

View file

@ -5,23 +5,22 @@ import (
)
type Library struct {
ID int
Name string
Path string
RemotePath string
LastScanAt time.Time
LastScanStartedAt time.Time
FullScanInProgress bool
UpdatedAt time.Time
CreatedAt time.Time
TotalSongs int
TotalAlbums int
TotalArtists int
TotalFolders int
TotalFiles int
TotalMissingFiles int
TotalSize int64
ID int `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
RemotePath string `json:"remotePath"`
LastScanAt time.Time `json:"lastScanAt"`
LastScanStartedAt time.Time `json:"lastScanStartedAt"`
FullScanInProgress bool `json:"fullScanInProgress"`
UpdatedAt time.Time `json:"updatedAt"`
CreatedAt time.Time `json:"createdAt"`
TotalSongs int `json:"totalSongs"`
TotalAlbums int `json:"totalAlbums"`
TotalArtists int `json:"totalArtists"`
TotalFolders int `json:"totalFolders"`
TotalFiles int `json:"totalFiles"`
TotalMissingFiles int `json:"totalMissingFiles"`
TotalSize int64 `json:"totalSize"`
}
type Libraries []Library

View file

@ -3,6 +3,7 @@ package persistence
import (
"context"
"fmt"
"strconv"
"sync"
"time"
@ -85,22 +86,26 @@ func (r *libraryRepository) Put(l *model.Library) error {
remote_path = excluded.remote_path, updated_at = excluded.updated_at`)
_, err := r.executeSQL(sq)
if err != nil {
libLock.Lock()
defer libLock.Unlock()
libCache[l.ID] = l.Path
return err
}
// Auto-assign library to all admin users
if err == nil {
sql := Expr(
"INSERT INTO user_library (user_id, library_id) SELECT id, ? FROM user WHERE is_admin = true ON CONFLICT (user_id, library_id) DO NOTHING",
l.ID,
)
if _, addErr := r.executeSQL(sql); addErr != nil {
return fmt.Errorf("failed to assign library to admin users: %w", addErr)
}
// Auto-assign all libraries to all admin users
sql := Expr(`
INSERT INTO user_library (user_id, library_id)
SELECT u.id, l.id
FROM user u
CROSS JOIN library l
WHERE u.is_admin = true
ON CONFLICT (user_id, library_id) DO NOTHING;`,
)
if _, err = r.executeSQL(sql); err != nil {
return fmt.Errorf("failed to assign library to admin users: %w", err)
}
return err
libLock.Lock()
defer libLock.Unlock()
libCache[l.ID] = l.Path
return nil
}
const hardCodedMusicFolderID = 1
@ -214,7 +219,7 @@ func (r *libraryRepository) RefreshStats(id int) error {
func (r *libraryRepository) Delete(id int) error {
if !isAdmin(r.ctx) {
return rest.ErrPermissionDenied
return model.ErrNotAuthorized
}
err := r.delete(Eq{"id": id})
@ -256,4 +261,53 @@ func (r *libraryRepository) GetUsersWithLibraryAccess(libraryID int) (model.User
return res, err
}
// REST interface methods
func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *libraryRepository) Read(id string) (interface{}, error) {
idInt, err := strconv.Atoi(id)
if err != nil {
log.Trace(r.ctx, "invalid library id: %s", id, err)
return nil, rest.ErrNotFound
}
return r.Get(idInt)
}
func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *libraryRepository) EntityName() string {
return "library"
}
func (r *libraryRepository) NewInstance() interface{} {
return &model.Library{}
}
func (r *libraryRepository) Save(entity interface{}) (string, error) {
lib := entity.(*model.Library)
lib.ID = 0 // Reset ID to ensure we create a new library
err := r.Put(lib)
if err != nil {
return "", err
}
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepository) Update(id string, entity interface{}, cols ...string) error {
lib := entity.(*model.Library)
idInt, err := strconv.Atoi(id)
if err != nil {
return fmt.Errorf("invalid library ID: %s", id)
}
lib.ID = idInt
return r.Put(lib)
}
var _ model.LibraryRepository = (*libraryRepository)(nil)
var _ rest.Repository = (*libraryRepository)(nil)

View file

@ -10,6 +10,7 @@ import (
"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"
@ -28,7 +29,7 @@ var _ = Describe("Config API", func() {
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
router = server.JWTVerifier(nativeRouter)
// Create test users

View file

@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core"
@ -13,20 +12,6 @@ import (
"github.com/navidrome/navidrome/model"
)
// Library management endpoints (admin only)
func (n *Router) addLibraryRoute(r chi.Router) {
r.Route("/library", func(r chi.Router) {
r.Get("/", getLibraries(n.libs))
r.Post("/", createLibrary(n.libs))
r.Route("/{id}", func(r chi.Router) {
r.Use(parseIDMiddleware)
r.Get("/", getLibrary(n.libs))
r.Put("/", updateLibrary(n.libs))
r.Delete("/", deleteLibrary(n.libs))
})
})
}
// User-library association endpoints (admin only)
func (n *Router) addUserLibraryRoute(r chi.Router) {
r.Route("/user/{id}/library", func(r chi.Router) {
@ -36,20 +21,6 @@ func (n *Router) addUserLibraryRoute(r chi.Router) {
})
}
// Middleware to parse library ID from URL
func parseIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid library ID", http.StatusBadRequest)
return
}
ctx := context.WithValue(r.Context(), "libraryID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Middleware to parse user ID from URL
func parseUserIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -63,126 +34,6 @@ func parseUserIDMiddleware(next http.Handler) http.Handler {
})
}
// Library CRUD handlers
func getLibraries(service core.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraries, err := service.GetAll(r.Context())
if err != nil {
log.Error(r.Context(), "Error getting libraries", 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 libraries response", err)
}
}
}
func getLibrary(service core.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraryID := r.Context().Value("libraryID").(int)
lib, err := service.Get(r.Context(), libraryID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "Library not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting library", "libraryID", libraryID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(lib); err != nil {
log.Error(r.Context(), "Error encoding library response", err)
}
}
}
func createLibrary(service core.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var lib model.Library
if err := json.NewDecoder(r.Body).Decode(&lib); err != nil {
log.Error(r.Context(), "Error decoding library request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := service.Create(r.Context(), &lib); err != nil {
log.Error(r.Context(), "Error creating library", err)
if errors.Is(err, model.ErrValidation) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, "Failed to create library", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(lib); err != nil {
log.Error(r.Context(), "Error encoding library response", err)
}
}
}
func updateLibrary(service core.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraryID := r.Context().Value("libraryID").(int)
var lib model.Library
if err := json.NewDecoder(r.Body).Decode(&lib); err != nil {
log.Error(r.Context(), "Error decoding library request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Ensure the ID matches the URL parameter
lib.ID = libraryID
if err := service.Update(r.Context(), &lib); err != nil {
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "Library not found", http.StatusNotFound)
return
}
if errors.Is(err, model.ErrValidation) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Error(r.Context(), "Error updating library", "libraryID", libraryID, err)
http.Error(w, "Failed to update library", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(lib); err != nil {
log.Error(r.Context(), "Error encoding library response", err)
}
}
}
func deleteLibrary(service core.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
libraryID := r.Context().Value("libraryID").(int)
if err := service.Delete(r.Context(), libraryID); err != nil {
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "Library not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error deleting library", "libraryID", libraryID, err)
http.Error(w, "Failed to delete library", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// User-library association handlers
func getUserLibraries(service core.Library) http.HandlerFunc {

View file

@ -29,7 +29,7 @@ var _ = Describe("Library API", func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, &mockLibraryService{})
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
router = server.JWTVerifier(nativeRouter)
// Create test users
@ -126,7 +126,7 @@ var _ = Describe("Library API", func() {
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
@ -142,13 +142,7 @@ var _ = Describe("Library API", func() {
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusCreated))
var created model.Library
err := json.Unmarshal(w.Body.Bytes(), &created)
Expect(err).ToNot(HaveOccurred())
Expect(created.Name).To(Equal("New Library"))
Expect(created.Path).To(Equal("/music/new"))
Expect(w.Code).To(Equal(http.StatusOK))
})
It("validates required fields", func() {
@ -226,7 +220,7 @@ var _ = Describe("Library API", func() {
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(w.Code).To(Equal(http.StatusOK))
})
It("returns 404 for non-existent library", func() {
@ -441,91 +435,3 @@ func Split(s, sep string) []string {
result = append(result, s[start:])
return result
}
// Mock library service for testing
type mockLibraryService struct {
core.Library
}
func (m *mockLibraryService) GetAll(ctx context.Context) (model.Libraries, error) {
return model.Libraries{
{ID: 1, Name: "Test Library 1", Path: "/music/library1"},
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
}, nil
}
func (m *mockLibraryService) Get(ctx context.Context, id int) (*model.Library, error) {
if id == 999 {
return nil, model.ErrNotFound
}
return &model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}, nil
}
func (m *mockLibraryService) Create(ctx context.Context, library *model.Library) error {
if library.Name == "" {
return fmt.Errorf("%w: library name is required", model.ErrValidation)
}
if library.Path == "" {
return fmt.Errorf("%w: library path is required", model.ErrValidation)
}
return nil
}
func (m *mockLibraryService) Update(ctx context.Context, library *model.Library) error {
if library.ID == 999 {
return model.ErrNotFound
}
if library.Name == "" {
return fmt.Errorf("%w: library name is required", model.ErrValidation)
}
if library.Path == "" {
return fmt.Errorf("%w: library path is required", model.ErrValidation)
}
return nil
}
func (m *mockLibraryService) Delete(ctx context.Context, id int) error {
if id == 999 {
return model.ErrNotFound
}
return nil
}
func (m *mockLibraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
if userID == "non-existent" {
return nil, model.ErrNotFound
}
// If setting multiple libraries, return all requested ones
if userID == "user-1" {
return model.Libraries{
{ID: 1, Name: "Test Library 1", Path: "/music/library1"},
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
}, nil
}
return model.Libraries{
{ID: 1, Name: "Test Library 1", Path: "/music/library1"},
}, nil
}
func (m *mockLibraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
if userID == "non-existent" {
return model.ErrNotFound
}
if userID == "admin-1" {
return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation)
}
if len(libraryIDs) == 0 {
return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation)
}
for _, id := range libraryIDs {
if id == 999 {
return fmt.Errorf("%w: library ID 999 does not exist", model.ErrValidation)
}
}
return nil
}
func (m *mockLibraryService) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error {
// For testing purposes, allow admin access and check basic user access
return nil
}

View file

@ -70,8 +70,8 @@ func (n *Router) routes() http.Handler {
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
n.addInspectRoute(r)
n.addConfigRoute(r)
n.addLibraryRoute(r)
n.addUserLibraryRoute(r)
n.RX(r, "/library", n.libs.NewRepository, true)
})
})

View file

@ -11,6 +11,7 @@ import (
"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"
@ -94,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
mfRepo.SetData(testSongs)
// Create the native API router and wrap it with the JWTVerifier middleware
nativeRouter := New(ds, nil, nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})

View file

@ -1,9 +1,12 @@
package tests
import (
"errors"
"slices"
"strconv"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
)
@ -155,4 +158,30 @@ func (m *MockLibraryRepo) GetUsersWithLibraryAccess(libraryID int) (model.Users,
return model.Users{}, nil
}
var _ model.LibraryRepository = &MockLibraryRepo{}
func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) {
return m.CountAll()
}
func (m *MockLibraryRepo) Read(id string) (interface{}, error) {
idInt, _ := strconv.Atoi(id)
mf, err := m.Get(idInt)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return mf, err
}
func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return m.GetAll()
}
func (m *MockLibraryRepo) EntityName() string {
return "library"
}
func (m *MockLibraryRepo) NewInstance() interface{} {
return &model.Library{}
}
var _ model.LibraryRepository = (*MockLibraryRepo)(nil)
var _ model.ResourceRepository = (*MockLibraryRepo)(nil)