From 940dbe4c0a98583bd1e16edfc0010a50d48c176d Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 19:02:14 -0400 Subject: [PATCH] feat(album): add cover upload/delete API endpoints --- server/nativeapi/albums.go | 64 +++++++++++++++++++++++++++++++++ server/nativeapi/albums_test.go | 53 +++++++++++++++++++++++++++ server/nativeapi/native_api.go | 2 +- 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 server/nativeapi/albums.go create mode 100644 server/nativeapi/albums_test.go diff --git a/server/nativeapi/albums.go b/server/nativeapi/albums.go new file mode 100644 index 000000000..ed63e5a3b --- /dev/null +++ b/server/nativeapi/albums.go @@ -0,0 +1,64 @@ +package nativeapi + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addAlbumRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Album{}) + } + r.Route("/album", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Post("/image", api.uploadAlbumImage()) + r.Delete("/image", api.deleteAlbumImage()) + }) + }) +} + +func (api *Router) uploadAlbumImage() http.HandlerFunc { + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + albumID := chi.URLParamFromCtx(ctx, "id") + al, err := api.ds.Album(ctx).Get(albumID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + filename, err := api.imgUpload.SetImage(ctx, consts.EntityAlbum, al.ID, al.Name, al.UploadedImagePath(), reader, ext) + if err != nil { + return err + } + return api.ds.Album(ctx).UpdateImage(al.ID, filename) + }) +} + +func (api *Router) deleteAlbumImage() http.HandlerFunc { + return handleImageDelete(func(ctx context.Context) error { + albumID := chi.URLParamFromCtx(ctx, "id") + al, err := api.ds.Album(ctx).Get(albumID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + if err := api.imgUpload.RemoveImage(ctx, al.UploadedImagePath()); err != nil { + return err + } + return api.ds.Album(ctx).UpdateImage(al.ID, "") + }) +} diff --git a/server/nativeapi/albums_test.go b/server/nativeapi/albums_test.go new file mode 100644 index 000000000..9fe7ce542 --- /dev/null +++ b/server/nativeapi/albums_test.go @@ -0,0 +1,53 @@ +package nativeapi + +import ( + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "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" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Album Image Endpoints", func() { + var api *Router + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + api = &Router{ds: &tests.MockDataStore{}, imgUpload: core.NewImageUploadService()} + }) + + DescribeTable("uploadAlbumImage guard", + func(enableArtworkUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableArtworkUpload = enableArtworkUpload + req := httptest.NewRequest("POST", "/album/al-1/image", nil) + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin}) + w := httptest.NewRecorder() + api.uploadAlbumImage().ServeHTTP(w, req.WithContext(ctx)) + Expect(w.Code).To(Equal(expectedStatus)) + }, + Entry("enabled, regular user passes guard", true, false, http.StatusBadRequest), + Entry("enabled, admin passes guard", true, true, http.StatusBadRequest), + Entry("disabled, admin passes guard", false, true, http.StatusBadRequest), + Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden), + ) + + DescribeTable("deleteAlbumImage guard", + func(enableArtworkUpload, isAdmin bool, expectedStatus int) { + conf.Server.EnableArtworkUpload = enableArtworkUpload + req := httptest.NewRequest("DELETE", "/album/al-1/image", nil) + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin}) + w := httptest.NewRecorder() + api.deleteAlbumImage().ServeHTTP(w, req.WithContext(ctx)) + Expect(w.Code).To(Equal(expectedStatus)) + }, + Entry("enabled, regular user passes guard", true, false, http.StatusNotFound), + Entry("enabled, admin passes guard", true, true, http.StatusNotFound), + Entry("disabled, admin passes guard", false, true, http.StatusNotFound), + Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden), + ) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 5a7023eb6..d6c356b77 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -66,7 +66,7 @@ func (api *Router) routes() http.Handler { r.Use(server.UpdateLastAccessMiddleware(api.ds)) api.RX(r, "/user", api.users.NewRepository, true) api.R(r, "/song", model.MediaFile{}, false) - api.R(r, "/album", model.Album{}, false) + api.addAlbumRoute(r) api.addArtistRoute(r) api.R(r, "/genre", model.Genre{}, false) api.R(r, "/player", model.Player{}, true)