From 72975a95fbe123a4684bdeb8361b58b0b245cc24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 9 Sep 2026 20:29:00 -0400 Subject: [PATCH] fix(subsonic): honor DefaultDownloadableShare in createShare (#6121) * fix(subsonic): honor DefaultDownloadableShare in createShare The DefaultDownloadableShare option was only sent to the web UI, which used it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare handler built the model.Share without touching Downloadable, so it fell back to the Go zero value and every share created through the API was stored as non-downloadable, regardless of the configured default. createShare now reads an optional downloadable parameter and falls back to conf.Server.DefaultDownloadableShare when the client omits it, matching the web UI. Fixes #6119. updateShare had a related problem: core's share repository wrapper always writes the downloadable column, but the handler never set the field, so any updateShare call silently reset the share to non-downloadable. It now loads the current share and uses its value as the fallback. * refactor(subsonic): trim the share downloadable lookup and align with the UI updateShare fetched the share with Get to recover the stored downloadable flag, which also runs loadMedia and materializes every album and track the share points at, just to read one boolean. It now uses Read, which skips loadMedia, and only queries at all when the client omitted the parameter. createShare now ANDs the default with EnableDownloads, matching what the web UI already computes, so both paths apply the same rule. The specs collapse the create-path matrix into a DescribeTable, reuse the existing albumIDByName helper, and set the request-time config after setupTestDB so it does not leak into the config snapshot. * fix(subsonic): keep the share description on a downloadable-only update updateShare read the description straight from the request, so a client that sent only id and downloadable got an empty string written over the stored description. shareRepositoryWrapper.Update always writes that column, so the description was silently erased. This predates the downloadable parameter added earlier in this branch: any updateShare that omitted description already cleared it. Adding the parameter just made it easy to hit, since toggling downloads is a natural reason to call updateShare without touching the description. Both fields now use the presence-aware accessors and fall back to the stored share, which still costs at most one read and none when the client sends both. An explicitly empty description still clears the field. --- server/subsonic/e2e/subsonic_sharing_test.go | 73 ++++++++++++++++++++ server/subsonic/sharing.go | 32 +++++++-- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/server/subsonic/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go index 0421d96ea..1ae68dc1a 100644 --- a/server/subsonic/e2e/subsonic_sharing_test.go +++ b/server/subsonic/e2e/subsonic_sharing_test.go @@ -205,3 +205,76 @@ var _ = Describe("Sharing Cross-User Isolation", Ordered, func() { Expect(check.Shares.Share[0].ID).To(Equal(shareID)) }) }) + +var _ = Describe("Sharing Downloadable Default", func() { + var albumID string + + BeforeEach(func() { + conf.Server.EnableSharing = true + setupTestDB() + conf.Server.EnableDownloads = true + albumID = albumIDByName("Abbey Road") + }) + + createShare := func(params ...string) *model.Share { + GinkgoHelper() + resp := doReq("createShare", append([]string{"id", albumID}, params...)...) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + share, err := ds.Share(ctx).Get(resp.Shares.Share[0].ID) + Expect(err).ToNot(HaveOccurred()) + return share + } + + DescribeTable("createShare resolves downloadable", + func(defaultDownloadable, enableDownloads bool, params []string, expected bool) { + conf.Server.DefaultDownloadableShare = defaultDownloadable + conf.Server.EnableDownloads = enableDownloads + + Expect(createShare(params...).Downloadable).To(Equal(expected)) + }, + Entry("applies the default when the param is absent", true, true, nil, true), + Entry("stays off when the default is off", false, true, nil, false), + Entry("ignores the default when downloads are disabled", true, false, nil, false), + Entry("honors an explicit false over the default", true, true, []string{"downloadable", "false"}, false), + Entry("honors an explicit true over the default", false, true, []string{"downloadable", "true"}, true), + ) + + It("updateShare keeps the current downloadable when the param is absent", func() { + conf.Server.DefaultDownloadableShare = true + share := createShare() + Expect(share.Downloadable).To(BeTrue()) + + resp := doReq("updateShare", "id", share.ID, "description", "Updated") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + updated, err := ds.Share(ctx).Get(share.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.Description).To(Equal("Updated")) + Expect(updated.Downloadable).To(BeTrue()) + }) + + It("updateShare applies an explicit downloadable and keeps the description", func() { + conf.Server.DefaultDownloadableShare = true + share := createShare("description", "Keep me") + + resp := doReq("updateShare", "id", share.ID, "downloadable", "false") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + updated, err := ds.Share(ctx).Get(share.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.Downloadable).To(BeFalse()) + Expect(updated.Description).To(Equal("Keep me")) + }) + + It("updateShare clears the description when it is sent empty", func() { + share := createShare("description", "Clear me") + + resp := doReq("updateShare", "id", share.ID, "description", "") + Expect(resp.Status).To(Equal(responses.StatusOK)) + + updated, err := ds.Share(ctx).Get(share.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(updated.Description).To(BeEmpty()) + }) +}) diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 36124c40b..c4b735832 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -1,11 +1,13 @@ package subsonic import ( + "cmp" "net/http" "strings" "time" "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -60,9 +62,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { description, _ := p.String("description") repo := api.share.NewRepository(r.Context()) share := &model.Share{ - Description: description, - ExpiresAt: new(p.TimeOr("expires", time.Time{})), - ResourceIDs: strings.Join(ids, ","), + Description: description, + Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads), + ExpiresAt: new(p.TimeOr("expires", time.Time{})), + ResourceIDs: strings.Join(ids, ","), } id, err := repo.(rest.Persistable).Save(share) @@ -87,12 +90,27 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { return nil, err } - description, _ := p.String("description") repo := api.share.NewRepository(r.Context()) + + // The update always writes description and downloadable, so read back the + // stored value for whichever one the client omitted. + description := p.StringPtr("description") + downloadable := p.BoolPtr("downloadable") + if description == nil || downloadable == nil { + current, err := repo.Read(id) + if err != nil { + return nil, err + } + cur := current.(*model.Share) + description = cmp.Or(description, &cur.Description) + downloadable = cmp.Or(downloadable, &cur.Downloadable) + } + share := &model.Share{ - ID: id, - Description: description, - ExpiresAt: new(p.TimeOr("expires", time.Time{})), + ID: id, + Description: *description, + Downloadable: *downloadable, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), } err = repo.(rest.Persistable).Update(id, share)