From 593b5db8e9b24256c7d34a6ca9878a0db34318ae Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 8 Nov 2025 15:50:43 -0500 Subject: [PATCH] refactor: extract missing files deletion into reusable service layer Extracted inline deletion logic from server/nativeapi/missing.go into a new core.MissingFiles service interface and implementation. This provides better separation of concerns and testability. The MissingFiles service handles: - Deletion of specific or all missing files via transaction - Garbage collection after deletion - Extraction of affected album IDs from missing files - Background refresh of artist and album statistics The deleteMissingFiles HTTP handler now simply delegates to the service, removing 70+ lines of inline logic. All deletion, transaction, and stat refresh logic is now centralized in core/missing_files.go. Updated dependency injection to provide MissingFiles service to the native API router. Renamed receiver variable from 'n' to 'api' throughout native_api.go for consistency. --- cmd/wire_gen.go | 3 +- core/missing_files.go | 124 +++++++++++ core/missing_files_test.go | 262 +++++++++++++++++++++++ core/wire_providers.go | 1 + server/nativeapi/config_test.go | 2 +- server/nativeapi/library.go | 6 +- server/nativeapi/library_test.go | 2 +- server/nativeapi/missing.go | 109 ++-------- server/nativeapi/native_api.go | 127 ++++++----- server/nativeapi/native_api_song_test.go | 2 +- 10 files changed, 475 insertions(+), 163 deletions(-) create mode 100644 core/missing_files.go create mode 100644 core/missing_files_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 187ab488d..31388780e 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -72,7 +72,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, scannerScanner) library := core.NewLibrary(dataStore, scannerScanner, watcher, broker) - router := nativeapi.New(dataStore, share, playlists, insights, library) + missingFiles := core.NewMissingFiles(dataStore) + router := nativeapi.New(dataStore, share, playlists, insights, library, missingFiles) return router } diff --git a/core/missing_files.go b/core/missing_files.go new file mode 100644 index 000000000..19b440f4f --- /dev/null +++ b/core/missing_files.go @@ -0,0 +1,124 @@ +package core + +import ( + "context" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +type MissingFiles interface { + // DeleteMissingFiles deletes specific missing files by their IDs + DeleteMissingFiles(ctx context.Context, ids []string) error + // DeleteAllMissingFiles deletes all files marked as missing + DeleteAllMissingFiles(ctx context.Context) error +} + +type missingFilesService struct { + ds model.DataStore +} + +func NewMissingFiles(ds model.DataStore) MissingFiles { + return &missingFilesService{ + ds: ds, + } +} + +func (s *missingFilesService) DeleteMissingFiles(ctx context.Context, ids []string) error { + return s.deleteMissing(ctx, ids) +} + +func (s *missingFilesService) DeleteAllMissingFiles(ctx context.Context) error { + return s.deleteMissing(ctx, nil) +} + +// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations +func (s *missingFilesService) deleteMissing(ctx context.Context, ids []string) error { + // Track affected album IDs before deletion for refresh + affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids) + if err != nil { + log.Warn(ctx, "Error tracking affected albums for refresh", err) + // Don't fail the operation, just log the warning + } + + // Delete missing files within a transaction + err = s.ds.WithTx(func(tx model.DataStore) error { + if len(ids) == 0 { + _, err := tx.MediaFile(ctx).DeleteAllMissing() + return err + } + return tx.MediaFile(ctx).DeleteMissing(ids) + }) + if err != nil { + log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err) + return err + } + + // Run garbage collection to clean up orphaned records + if err := s.ds.GC(ctx); err != nil { + log.Error(ctx, "Error running GC after deleting missing tracks", err) + return err + } + + // Refresh statistics in background + s.refreshStatsAsync(ctx, affectedAlbumIDs) + + return nil +} + +// getAffectedAlbumIDs returns distinct album IDs from missing media files +func (s *missingFilesService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) { + var filters squirrel.Sqlizer = squirrel.Eq{"missing": true} + if len(ids) > 0 { + filters = squirrel.And{ + squirrel.Eq{"missing": true}, + squirrel.Eq{"id": ids}, + } + } + + mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: filters, + }) + if err != nil { + return nil, err + } + + // Extract unique album IDs + albumIDMap := make(map[string]struct{}, len(mfs)) + for _, mf := range mfs { + if mf.AlbumID != "" { + albumIDMap[mf.AlbumID] = struct{}{} + } + } + + albumIDs := make([]string, 0, len(albumIDMap)) + for id := range albumIDMap { + albumIDs = append(albumIDs, id) + } + + return albumIDs, nil +} + +// refreshStatsAsync refreshes artist and album statistics in background goroutines +func (s *missingFilesService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) { + // Refresh artist stats in background + go func() { + bgCtx := request.AddValues(context.Background(), ctx) + if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil { + log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) + } else { + log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files") + } + + // Refresh album stats in background if we have affected albums + if len(affectedAlbumIDs) > 0 { + if err := s.ds.Album(bgCtx).RefreshAlbums(affectedAlbumIDs); err != nil { + log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err) + } else { + log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs)) + } + } + }() +} diff --git a/core/missing_files_test.go b/core/missing_files_test.go new file mode 100644 index 000000000..3ea6316f3 --- /dev/null +++ b/core/missing_files_test.go @@ -0,0 +1,262 @@ +package core + +import ( + "context" + "errors" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MissingFiles", func() { + var ds *testDataStore + var service MissingFiles + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true}) + + ds = &testDataStore{ + mfRepo: &testMediaFileRepo{}, + albumRepo: &testAlbumRepo{}, + artistRepo: &testArtistRepo{}, + } + + service = NewMissingFiles(ds) + }) + + Describe("DeleteMissingFiles", func() { + Context("with specific IDs", func() { + It("deletes specific missing files", func() { + // Setup: mock missing files with album IDs + ds.mfRepo.files = model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + {ID: "mf2", AlbumID: "album2", Missing: true}, + } + + err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"}) + + Expect(err).ToNot(HaveOccurred()) + Expect(ds.mfRepo.deleteMissingCalled).To(BeTrue()) + Expect(ds.mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"})) + Expect(ds.gcCalled).To(BeTrue()) + }) + + It("returns error if deletion fails", func() { + ds.mfRepo.deleteMissingError = errors.New("delete failed") + + err := service.DeleteMissingFiles(ctx, []string{"mf1"}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("delete failed")) + }) + + It("continues even if album tracking fails", func() { + ds.mfRepo.getAllError = errors.New("tracking failed") + + err := service.DeleteMissingFiles(ctx, []string{"mf1"}) + + // Should not fail, just log warning + Expect(err).ToNot(HaveOccurred()) + Expect(ds.mfRepo.deleteMissingCalled).To(BeTrue()) + }) + + It("returns error if GC fails", func() { + ds.mfRepo.files = model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + } + ds.gcError = errors.New("gc failed") + + err := service.DeleteMissingFiles(ctx, []string{"mf1"}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("gc failed")) + }) + }) + + Context("album ID extraction", func() { + It("extracts unique album IDs from missing files", func() { + ds.mfRepo.files = model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + {ID: "mf2", AlbumID: "album1", Missing: true}, + {ID: "mf3", AlbumID: "album2", Missing: true}, + } + + err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"}) + + Expect(err).ToNot(HaveOccurred()) + Expect(ds.mfRepo.getAllCalled).To(BeTrue()) + }) + + It("skips files without album IDs", func() { + ds.mfRepo.files = model.MediaFiles{ + {ID: "mf1", AlbumID: "", Missing: true}, + {ID: "mf2", AlbumID: "album1", Missing: true}, + } + + err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"}) + + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + Describe("DeleteAllMissingFiles", func() { + It("deletes all missing files", func() { + ds.mfRepo.files = model.MediaFiles{ + {ID: "mf1", AlbumID: "album1", Missing: true}, + {ID: "mf2", AlbumID: "album2", Missing: true}, + {ID: "mf3", AlbumID: "album3", Missing: true}, + } + + err := service.DeleteAllMissingFiles(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(ds.mfRepo.deleteAllMissingCalled).To(BeTrue()) + Expect(ds.gcCalled).To(BeTrue()) + }) + + It("returns error if deletion fails", func() { + ds.mfRepo.deleteAllMissingError = errors.New("delete all failed") + + err := service.DeleteAllMissingFiles(ctx) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("delete all failed")) + }) + + It("handles empty result gracefully", func() { + ds.mfRepo.files = model.MediaFiles{} + + err := service.DeleteAllMissingFiles(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(ds.mfRepo.deleteAllMissingCalled).To(BeTrue()) + }) + }) +}) + +// Test implementations +type testDataStore struct { + tests.MockDataStore + mfRepo *testMediaFileRepo + albumRepo *testAlbumRepo + artistRepo *testArtistRepo + gcCalled bool + gcError error +} + +func (ds *testDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + return ds.mfRepo +} + +func (ds *testDataStore) Album(ctx context.Context) model.AlbumRepository { + return ds.albumRepo +} + +func (ds *testDataStore) Artist(ctx context.Context) model.ArtistRepository { + return ds.artistRepo +} + +func (ds *testDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { + return block(ds) +} + +func (ds *testDataStore) GC(ctx context.Context) error { + ds.gcCalled = true + return ds.gcError +} + +type testMediaFileRepo struct { + tests.MockMediaFileRepo + files model.MediaFiles + getAllCalled bool + getAllError error + deleteMissingCalled bool + deletedIDs []string + deleteMissingError error + deleteAllMissingCalled bool + deleteAllMissingError error +} + +func (m *testMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + m.getAllCalled = true + if m.getAllError != nil { + return nil, m.getAllError + } + + if len(options) == 0 { + return m.files, nil + } + + // Filter based on the query options + opt := options[0] + if filters, ok := opt.Filters.(squirrel.And); ok { + // Check for ID filter + for _, filter := range filters { + if eq, ok := filter.(squirrel.Eq); ok { + if ids, exists := eq["id"]; exists { + // Filter files by IDs + idList := ids.([]string) + var filtered model.MediaFiles + for _, f := range m.files { + for _, id := range idList { + if f.ID == id { + filtered = append(filtered, f) + break + } + } + } + return filtered, nil + } + } + } + } + return m.files, nil +} + +func (m *testMediaFileRepo) DeleteMissing(ids []string) error { + m.deleteMissingCalled = true + m.deletedIDs = ids + return m.deleteMissingError +} + +func (m *testMediaFileRepo) DeleteAllMissing() (int64, error) { + m.deleteAllMissingCalled = true + if m.deleteAllMissingError != nil { + return 0, m.deleteAllMissingError + } + return int64(len(m.files)), nil +} + +type testAlbumRepo struct { + tests.MockAlbumRepo + refreshAlbumsCalled bool + refreshAlbumsIDs []string + refreshAlbumsError error +} + +func (m *testAlbumRepo) RefreshAlbums(albumIDs []string) error { + m.refreshAlbumsCalled = true + m.refreshAlbumsIDs = albumIDs + return m.refreshAlbumsError +} + +type testArtistRepo struct { + tests.MockArtistRepo + refreshStatsCalled bool + refreshStatsError error +} + +func (m *testArtistRepo) RefreshStats(allArtists bool) (int64, error) { + m.refreshStatsCalled = true + if m.refreshStatsError != nil { + return 0, m.refreshStatsError + } + return 1, nil +} diff --git a/core/wire_providers.go b/core/wire_providers.go index ae365156a..fb0b11d8b 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -18,6 +18,7 @@ var Set = wire.NewSet( NewShare, NewPlaylists, NewLibrary, + NewMissingFiles, agents.GetAgents, external.NewProvider, wire.Bind(new(external.Agents), new(*agents.Agents)), diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index 60f7c3394..d9c722955 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -29,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, core.NewMockLibraryService()) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/library.go b/server/nativeapi/library.go index f081eca78..1636e1dbb 100644 --- a/server/nativeapi/library.go +++ b/server/nativeapi/library.go @@ -13,11 +13,11 @@ import ( ) // User-library association endpoints (admin only) -func (n *Router) addUserLibraryRoute(r chi.Router) { +func (api *Router) addUserLibraryRoute(r chi.Router) { r.Route("/user/{id}/library", func(r chi.Router) { r.Use(parseUserIDMiddleware) - r.Get("/", getUserLibraries(n.libs)) - r.Put("/", setUserLibraries(n.libs)) + r.Get("/", getUserLibraries(api.libs)) + r.Put("/", setUserLibraries(api.libs)) }) } diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 4e6d34582..950338492 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -30,7 +30,7 @@ var _ = Describe("Library API", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService()) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 0c7b2688f..d9109bb0d 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -8,9 +8,9 @@ import ( "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/model/request" "github.com/navidrome/navidrome/utils/req" ) @@ -63,107 +63,32 @@ func (r *missingRepository) EntityName() string { return "missing_files" } -func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - p := req.Params(r) - ids, _ := p.Strings("id") +func deleteMissingFiles(missingFiles core.MissingFiles) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() - // Track affected album IDs before deletion for refresh - var affectedAlbumIDs []string - var trackErr error - if len(ids) == 0 { - // Get all album IDs from missing files - affectedAlbumIDs, trackErr = getAlbumIDsFromMissing(ctx, ds, nil) - } else { - // Get album IDs from specific missing file IDs - affectedAlbumIDs, trackErr = getAlbumIDsFromMissing(ctx, ds, ids) - } - if trackErr != nil { - log.Warn(ctx, "Error tracking affected albums for refresh", trackErr) - // Don't fail the operation, just log the warning - } + p := req.Params(r) + ids, _ := p.Strings("id") - err := ds.WithTx(func(tx model.DataStore) error { + var err error if len(ids) == 0 { - _, err := tx.MediaFile(ctx).DeleteAllMissing() - return err - } - return tx.MediaFile(ctx).DeleteMissing(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 { - log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - err = ds.GC(ctx) - if err != nil { - log.Error(ctx, "Error running GC after deleting missing tracks", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // Refresh artist stats in background after deleting missing files - go func() { - bgCtx := request.AddValues(context.Background(), r.Context()) - if _, err := ds.Artist(bgCtx).RefreshStats(true); err != nil { - log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) + err = missingFiles.DeleteAllMissingFiles(ctx) } else { - log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files") + err = missingFiles.DeleteMissingFiles(ctx, ids) } - }() - // Refresh album stats in background after deleting missing files - if len(affectedAlbumIDs) > 0 { - go func() { - bgCtx := request.AddValues(context.Background(), r.Context()) - if err := ds.Album(bgCtx).RefreshAlbums(affectedAlbumIDs); err != nil { - log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err) - } else { - log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs)) - } - }() - } - - writeDeleteManyResponse(w, r, ids) -} - -// getAlbumIDsFromMissing returns distinct album IDs from missing media files -// Uses batch query for efficiency -func getAlbumIDsFromMissing(ctx context.Context, ds model.DataStore, ids []string) ([]string, error) { - var filters squirrel.Sqlizer = squirrel.Eq{"missing": true} - if len(ids) > 0 { - filters = squirrel.And{ - squirrel.Eq{"missing": true}, - squirrel.Eq{"id": 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 } - } - - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: filters, - }) - if err != nil { - return nil, err - } - - // Extract unique album IDs - albumIDMap := make(map[string]struct{}, len(mfs)) - for _, mf := range mfs { - if mf.AlbumID != "" { - albumIDMap[mf.AlbumID] = struct{}{} + if err != nil { + http.Error(w, "failed to delete missing files", http.StatusInternalServerError) + return } - } - albumIDs := make([]string, 0, len(albumIDMap)) - for id := range albumIDMap { - albumIDs = append(albumIDs, id) + writeDeleteManyResponse(w, r, ids) } - - return albumIDs, nil } var _ model.ResourceRepository = &missingRepository{} diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 370bdbd1e..92c4423fd 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -22,70 +22,71 @@ import ( type Router struct { http.Handler - ds model.DataStore - share core.Share - playlists core.Playlists - insights metrics.Insights - libs core.Library + ds model.DataStore + share core.Share + playlists core.Playlists + insights metrics.Insights + libs core.Library + missingFiles core.MissingFiles } -func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService} +func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, missingFiles core.MissingFiles) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, missingFiles: missingFiles} r.Handler = r.routes() return r } -func (n *Router) routes() http.Handler { +func (api *Router) routes() http.Handler { r := chi.NewRouter() // Public - n.RX(r, "/translation", newTranslationRepository, false) + api.RX(r, "/translation", newTranslationRepository, false) // Protected r.Group(func(r chi.Router) { - r.Use(server.Authenticator(n.ds)) + r.Use(server.Authenticator(api.ds)) r.Use(server.JWTRefresher) - r.Use(server.UpdateLastAccessMiddleware(n.ds)) - n.R(r, "/user", model.User{}, true) - n.R(r, "/song", model.MediaFile{}, false) - n.R(r, "/album", model.Album{}, false) - n.R(r, "/artist", model.Artist{}, false) - n.R(r, "/genre", model.Genre{}, false) - n.R(r, "/player", model.Player{}, true) - n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) - n.R(r, "/radio", model.Radio{}, true) - n.R(r, "/tag", model.Tag{}, true) + 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 { - n.RX(r, "/share", n.share.NewRepository, true) + api.RX(r, "/share", api.share.NewRepository, true) } - n.addPlaylistRoute(r) - n.addPlaylistTrackRoute(r) - n.addSongPlaylistsRoute(r) - n.addQueueRoute(r) - n.addMissingFilesRoute(r) - n.addKeepAliveRoute(r) - n.addInsightsRoute(r) + 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) { - n.addInspectRoute(r) - n.addConfigRoute(r) - n.addUserLibraryRoute(r) - n.RX(r, "/library", n.libs.NewRepository, true) + api.addInspectRoute(r) + api.addConfigRoute(r) + api.addUserLibraryRoute(r) + api.RX(r, "/library", api.libs.NewRepository, true) }) }) return r } -func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) { +func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) { constructor := func(ctx context.Context) rest.Repository { - return n.ds.Resource(ctx, model) + return api.ds.Resource(ctx, model) } - n.RX(r, pathPrefix, constructor, persistable) + api.RX(r, pathPrefix, constructor, persistable) } -func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) { +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 { @@ -102,9 +103,9 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository }) } -func (n *Router) addPlaylistRoute(r chi.Router) { +func (api *Router) addPlaylistRoute(r chi.Router) { constructor := func(ctx context.Context) rest.Repository { - return n.ds.Resource(ctx, model.Playlist{}) + return api.ds.Resource(ctx, model.Playlist{}) } r.Route("/playlist", func(r chi.Router) { @@ -114,7 +115,7 @@ func (n *Router) addPlaylistRoute(r chi.Router) { rest.Post(constructor)(w, r) return } - createPlaylistFromM3U(n.playlists)(w, r) + createPlaylistFromM3U(api.playlists)(w, r) }) r.Route("/{id}", func(r chi.Router) { @@ -126,55 +127,53 @@ func (n *Router) addPlaylistRoute(r chi.Router) { }) } -func (n *Router) addPlaylistTrackRoute(r chi.Router) { +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(n.ds)(w, r) + getPlaylist(api.ds)(w, r) }) r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) { r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteFromPlaylist(n.ds)(w, r) + deleteFromPlaylist(api.ds)(w, r) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { - addToPlaylist(n.ds)(w, r) + 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(n.ds)(w, r) + getPlaylistTrack(api.ds)(w, r) }) r.Put("/", func(w http.ResponseWriter, r *http.Request) { - reorderItem(n.ds)(w, r) + reorderItem(api.ds)(w, r) }) r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteFromPlaylist(n.ds)(w, r) + deleteFromPlaylist(api.ds)(w, r) }) }) }) } -func (n *Router) addSongPlaylistsRoute(r chi.Router) { +func (api *Router) addSongPlaylistsRoute(r chi.Router) { r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) { - getSongPlaylists(n.ds)(w, r) + getSongPlaylists(api.ds)(w, r) }) } -func (n *Router) addQueueRoute(r chi.Router) { +func (api *Router) addQueueRoute(r chi.Router) { r.Route("/queue", func(r chi.Router) { - r.Get("/", getQueue(n.ds)) - r.Post("/", saveQueue(n.ds)) - r.Put("/", updateQueue(n.ds)) - r.Delete("/", clearQueue(n.ds)) + r.Get("/", getQueue(api.ds)) + r.Post("/", saveQueue(api.ds)) + r.Put("/", updateQueue(api.ds)) + r.Delete("/", clearQueue(api.ds)) }) } -func (n *Router) addMissingFilesRoute(r chi.Router) { +func (api *Router) addMissingFilesRoute(r chi.Router) { r.Route("/missing", func(r chi.Router) { - n.RX(r, "/", newMissingRepository(n.ds), false) - r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteMissingFiles(n.ds, w, r) - }) + api.RX(r, "/", newMissingRepository(api.ds), false) + r.Delete("/", deleteMissingFiles(api.missingFiles)) }) } @@ -198,7 +197,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin } } -func (n *Router) addInspectRoute(r chi.Router) { +func (api *Router) addInspectRoute(r chi.Router) { if conf.Server.Inspect.Enabled { r.Group(func(r chi.Router) { if conf.Server.Inspect.MaxRequests > 0 { @@ -207,26 +206,26 @@ func (n *Router) addInspectRoute(r chi.Router) { 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(n.ds)) + r.Get("/inspect", inspect(api.ds)) }) } } -func (n *Router) addConfigRoute(r chi.Router) { +func (api *Router) addConfigRoute(r chi.Router) { if conf.Server.DevUIShowConfig { r.Get("/config/*", getConfig) } } -func (n *Router) addKeepAliveRoute(r chi.Router) { +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 (n *Router) addInsightsRoute(r chi.Router) { +func (api *Router) addInsightsRoute(r chi.Router) { r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) { - last, success := n.insights.LastRun(r.Context()) + 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 { diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index d7209a164..b52042643 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -95,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, core.NewMockLibraryService()) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() })