From 8de1f8bfffad65d21c065826c68f9f91b76926c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 01:17:53 +0100 Subject: [PATCH 01/12] Added SongsByFolder filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- server/subsonic/filter/filter_suite_test.go | 17 +++++++++++ server/subsonic/filter/filters.go | 7 +++++ server/subsonic/filter/filters_test.go | 32 +++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 server/subsonic/filter/filter_suite_test.go create mode 100644 server/subsonic/filter/filters_test.go diff --git a/server/subsonic/filter/filter_suite_test.go b/server/subsonic/filter/filter_suite_test.go new file mode 100644 index 000000000..e78726182 --- /dev/null +++ b/server/subsonic/filter/filter_suite_test.go @@ -0,0 +1,17 @@ +package filter_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFilter(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Subsonic Filter Suite") +} diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 8ba4f0ff9..17cb32d41 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -90,6 +90,13 @@ func SongsByAlbum(albumId string) Options { }) } +func SongsByFolder(folderID string) Options { + return addDefaultFilters(Options{ + Filters: Eq{"folder_id": folderID}, + Sort: "path", + }) +} + func SongsByRandom(genre string, fromYear, toYear int) Options { options := Options{ Sort: "random", diff --git a/server/subsonic/filter/filters_test.go b/server/subsonic/filter/filters_test.go new file mode 100644 index 000000000..83e764333 --- /dev/null +++ b/server/subsonic/filter/filters_test.go @@ -0,0 +1,32 @@ +package filter_test + +import ( + sq "github.com/Masterminds/squirrel" + . "github.com/navidrome/navidrome/server/subsonic/filter" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SongsByFolder", func() { + It("sets sort to path", func() { + opts := SongsByFolder("folder-1") + Expect(opts.Sort).To(Equal("path")) + }) + + It("filters by the given folder ID", func() { + opts := SongsByFolder("folder-1") + Expect(opts.Filters).To(Equal(sq.And{sq.Eq{"missing": false}, sq.Eq{"folder_id": "folder-1"}})) + }) + + It("uses a different folder ID per call", func() { + opts := SongsByFolder("folder-2") + Expect(opts.Filters).To(Equal(sq.And{sq.Eq{"missing": false}, sq.Eq{"folder_id": "folder-2"}})) + }) + + It("excludes missing files via default filter", func() { + opts := SongsByFolder("any-folder") + filters, ok := opts.Filters.(sq.And) + Expect(ok).To(BeTrue()) + Expect(filters).To(ContainElement(sq.Eq{"missing": false})) + }) +}) From 32200abeb91df5ba42fe572274f9eba89f48e419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 01:20:26 +0100 Subject: [PATCH 02/12] Added childFromFolder helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- server/subsonic/helpers.go | 10 +++++++ server/subsonic/helpers_test.go | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e930aa630..1fd2c2795 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -317,6 +317,16 @@ func sanitizeSlashes(target string) string { return strings.ReplaceAll(target, "/", "_") } +func childFromFolder(_ context.Context, folder model.Folder) responses.Child { + child := responses.Child{} + child.Id = folder.ID + child.Parent = folder.ParentID + child.IsDir = true + child.Title = folder.Name + child.Name = folder.Name + return child +} + func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child := responses.Child{} child.Id = al.ID diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index d20ca89ad..7935ac351 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -477,6 +477,54 @@ var _ = Describe("helpers", func() { }) }) + Describe("childFromFolder", func() { + var folder model.Folder + var ctx context.Context + + BeforeEach(func() { + folder = model.Folder{ + ID: "folder-1", + ParentID: "parent-1", + Name: "Jazz", + NumAudioFiles: 12, + } + ctx = context.Background() + }) + + It("sets Id from folder ID", func() { + child := childFromFolder(ctx, folder) + Expect(child.Id).To(Equal("folder-1")) + }) + + It("sets Parent from folder ParentID", func() { + child := childFromFolder(ctx, folder) + Expect(child.Parent).To(Equal("parent-1")) + }) + + It("marks the child as a directory", func() { + child := childFromFolder(ctx, folder) + Expect(child.IsDir).To(BeTrue()) + }) + + It("sets Title and Name from folder Name", func() { + child := childFromFolder(ctx, folder) + Expect(child.Title).To(Equal("Jazz")) + Expect(child.Name).To(Equal("Jazz")) + }) + + It("leaves CoverArt empty (populated in task 1.3)", func() { + child := childFromFolder(ctx, folder) + Expect(child.CoverArt).To(BeEmpty()) + }) + + It("works for a root folder with no parent", func() { + folder.ParentID = "" + child := childFromFolder(ctx, folder) + Expect(child.Parent).To(BeEmpty()) + Expect(child.IsDir).To(BeTrue()) + }) + }) + Describe("AverageRating in responses", func() { var ctx context.Context From 0addb23bf5a90d9471c12d1c59b1a6ed9d0a66cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 01:26:33 +0100 Subject: [PATCH 03/12] Add folder artwork support and SongsByFolder filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- core/artwork/artwork.go | 2 + core/artwork/reader_folder.go | 64 ++++++++++++++ core/artwork/reader_folder_test.go | 134 +++++++++++++++++++++++++++++ model/artwork_id.go | 10 +++ model/artwork_id_test.go | 12 +++ model/folder.go | 8 ++ model/folder_test.go | 26 ++++++ server/subsonic/helpers.go | 1 + server/subsonic/helpers_test.go | 8 +- 9 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 core/artwork/reader_folder.go create mode 100644 core/artwork/reader_folder_test.go diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 4a0a32afc..652e823c0 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -124,6 +124,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s artReader, err = newPlaylistArtworkReader(ctx, a, artID) case model.KindDiscArtwork: artReader, err = newDiscArtworkReader(ctx, a, artID) + case model.KindFolderArtwork: + artReader, err = newFolderArtworkReader(ctx, a, artID) default: return nil, ErrUnavailable } diff --git a/core/artwork/reader_folder.go b/core/artwork/reader_folder.go new file mode 100644 index 000000000..63d8bfeba --- /dev/null +++ b/core/artwork/reader_folder.go @@ -0,0 +1,64 @@ +package artwork + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" +) + +type folderArtworkReader struct { + cacheKey + folder model.Folder + imgFiles []string +} + +func newFolderArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*folderArtworkReader, error) { + folder, err := a.ds.Folder(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + + absPath := folder.AbsolutePath() + imgFiles := make([]string, len(folder.ImageFiles)) + for i, img := range folder.ImageFiles { + imgFiles[i] = filepath.Join(absPath, img) + } + + r := &folderArtworkReader{ + folder: *folder, + imgFiles: imgFiles, + } + r.cacheKey.artID = artID + r.cacheKey.lastUpdate = folder.ImagesUpdatedAt + return r, nil +} + +func (f *folderArtworkReader) Key() string { + hash := md5.Sum([]byte(conf.Server.CoverArtPriority)) + return fmt.Sprintf("%s.%x", f.cacheKey.Key(), hash) +} + +func (f *folderArtworkReader) LastUpdated() time.Time { + return f.folder.ImagesUpdatedAt +} + +func (f *folderArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + var ff []sourceFunc + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded" || pattern == "external": + // Folders have no embedded tags and no external artwork sources + case len(f.imgFiles) > 0: + ff = append(ff, fromExternalFile(ctx, f.imgFiles, pattern)) + } + } + return selectImageReader(ctx, f.cacheKey.artID, ff...) +} diff --git a/core/artwork/reader_folder_test.go b/core/artwork/reader_folder_test.go new file mode 100644 index 000000000..edaa44e81 --- /dev/null +++ b/core/artwork/reader_folder_test.go @@ -0,0 +1,134 @@ +package artwork + +import ( + "context" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("folderArtworkReader", func() { + var ( + ctx context.Context + a *artwork + tmpDir string + folderRepo *fakeFolderRepo + folder model.Folder + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + tmpDir = GinkgoT().TempDir() + conf.Server.CoverArtPriority = "cover.*, front.*, *" + + folderRepo = &fakeFolderRepo{} + ds := &fakeDataStore{folderRepo: folderRepo} + a = &artwork{ds: ds} + + folder = model.Folder{ + ID: "folder-1", + LibraryPath: tmpDir, + Path: ".", + Name: "Jazz", + ImageFiles: []string{"cover.jpg"}, + ImagesUpdatedAt: time.Now().Truncate(time.Second), + } + }) + + createImage := func(name string) { + fullPath := filepath.Join(folder.AbsolutePath(), name) + Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) + Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) + } + + Describe("newFolderArtworkReader", func() { + It("returns a reader when the folder is found", func() { + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + }) + + It("returns an error when the folder is not found", func() { + artID := model.NewArtworkID(model.KindFolderArtwork, "missing", nil) + _, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("builds absolute image file paths from folder.ImageFiles", func() { + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + Expect(reader.imgFiles).To(ConsistOf( + filepath.Join(folder.AbsolutePath(), "cover.jpg"), + )) + }) + + It("uses ImagesUpdatedAt as the cache key lastUpdate", func() { + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + Expect(reader.LastUpdated()).To(Equal(folder.ImagesUpdatedAt)) + }) + }) + + Describe("Reader", func() { + It("returns the matching image file", func() { + createImage("cover.jpg") + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + rc, path, err := reader.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(rc).ToNot(BeNil()) + Expect(path).To(ContainSubstring("cover.jpg")) + rc.Close() + }) + + It("returns ErrUnavailable when no images match the priority patterns", func() { + conf.Server.CoverArtPriority = "cover.*" + folder.ImageFiles = []string{"other.jpg"} + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + _, _, err = reader.Reader(ctx) + Expect(err).To(MatchError(ErrUnavailable)) + }) + + It("skips embedded and external patterns without error", func() { + conf.Server.CoverArtPriority = "embedded, external, cover.*" + createImage("cover.jpg") + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + rc, _, err := reader.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(rc).ToNot(BeNil()) + rc.Close() + }) + + It("returns ErrUnavailable when folder has no images and only external/embedded in priority", func() { + conf.Server.CoverArtPriority = "embedded, external" + folder.ImageFiles = []string{} + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + _, _, err = reader.Reader(ctx) + Expect(err).To(MatchError(ErrUnavailable)) + }) + }) +}) diff --git a/model/artwork_id.go b/model/artwork_id.go index 8d935f427..ab4cd1aa8 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -23,6 +23,7 @@ var ( KindAlbumArtwork = Kind{"al", "album"} KindPlaylistArtwork = Kind{"pl", "playlist"} KindDiscArtwork = Kind{"dc", "disc"} + KindFolderArtwork = Kind{"fo", "folder"} ) var artworkKindMap = map[string]Kind{ @@ -31,6 +32,7 @@ var artworkKindMap = map[string]Kind{ KindAlbumArtwork.prefix: KindAlbumArtwork, KindPlaylistArtwork.prefix: KindPlaylistArtwork, KindDiscArtwork.prefix: KindDiscArtwork, + KindFolderArtwork.prefix: KindFolderArtwork, } type ArtworkID struct { @@ -139,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID { ID: ar.ID, } } + +func artworkIDFromFolder(f Folder) ArtworkID { + return ArtworkID{ + Kind: KindFolderArtwork, + ID: f.ID, + LastUpdate: f.ImagesUpdatedAt, + } +} diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index b634e7cbc..24284d447 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -62,6 +62,18 @@ var _ = Describe("ArtworkID", func() { ) }) + Describe("ParseArtworkID - folder kind", func() { + It("parses a folder artwork ID with fo prefix", func() { + now := time.Now() + id := model.NewArtworkID(model.KindFolderArtwork, "folder-id-123", &now) + parsedId, err := model.ParseArtworkID(id.String()) + Expect(err).ToNot(HaveOccurred()) + Expect(parsedId.Kind).To(Equal(model.KindFolderArtwork)) + Expect(parsedId.ID).To(Equal("folder-id-123")) + Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix())) + }) + }) + Describe("ParseArtworkID()", func() { It("parses album artwork ids", func() { id, err := model.ParseArtworkID("al-1234") diff --git a/model/folder.go b/model/folder.go index 7a769735e..f28f507b1 100644 --- a/model/folder.go +++ b/model/folder.go @@ -31,6 +31,14 @@ type Folder struct { CreatedAt time.Time `structs:"created_at"` } +// CoverArtID returns a non-empty ArtworkID only when the folder contains image files. +func (f Folder) CoverArtID() ArtworkID { + if len(f.ImageFiles) == 0 { + return ArtworkID{} + } + return artworkIDFromFolder(f) +} + func (f Folder) AbsolutePath() string { return filepath.Join(f.LibraryPath, f.Path, f.Name) } diff --git a/model/folder_test.go b/model/folder_test.go index 0535f6987..c136dfa96 100644 --- a/model/folder_test.go +++ b/model/folder_test.go @@ -11,6 +11,32 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("Folder.CoverArtID", func() { + It("returns empty ArtworkID when folder has no images", func() { + f := model.Folder{ID: "folder-1"} + Expect(f.CoverArtID()).To(Equal(model.ArtworkID{})) + Expect(f.CoverArtID().String()).To(BeEmpty()) + }) + + It("returns a folder ArtworkID when folder has images", func() { + now := time.Now().Truncate(time.Second) + f := model.Folder{ID: "folder-1", ImageFiles: []string{"cover.jpg"}, ImagesUpdatedAt: now} + artID := f.CoverArtID() + Expect(artID.Kind).To(Equal(model.KindFolderArtwork)) + Expect(artID.ID).To(Equal("folder-1")) + Expect(artID.LastUpdate.Unix()).To(Equal(now.Unix())) + }) + + It("produces a parseable ArtworkID string", func() { + now := time.Now() + f := model.Folder{ID: "folder-1", ImageFiles: []string{"cover.jpg"}, ImagesUpdatedAt: now} + parsed, err := model.ParseArtworkID(f.CoverArtID().String()) + Expect(err).ToNot(HaveOccurred()) + Expect(parsed.Kind).To(Equal(model.KindFolderArtwork)) + Expect(parsed.ID).To(Equal("folder-1")) + }) +}) + var _ = Describe("Folder", func() { var ( lib model.Library diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 1fd2c2795..7637a1344 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -324,6 +324,7 @@ func childFromFolder(_ context.Context, folder model.Folder) responses.Child { child.IsDir = true child.Title = folder.Name child.Name = folder.Name + child.CoverArt = folder.CoverArtID().String() return child } diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 7935ac351..353ded060 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -512,11 +512,17 @@ var _ = Describe("helpers", func() { Expect(child.Name).To(Equal("Jazz")) }) - It("leaves CoverArt empty (populated in task 1.3)", func() { + It("leaves CoverArt empty when folder has no images", func() { child := childFromFolder(ctx, folder) Expect(child.CoverArt).To(BeEmpty()) }) + It("sets CoverArt when folder has images", func() { + folder.ImageFiles = []string{"cover.jpg"} + child := childFromFolder(ctx, folder) + Expect(child.CoverArt).To(HavePrefix("fo-")) + }) + It("works for a root folder with no parent", func() { folder.ParentID = "" child := childFromFolder(ctx, folder) From b763ff7bab50f5d23d25907b129d6ab708b955bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 01:29:40 +0100 Subject: [PATCH 04/12] Add Folder lookup to GetEntityByID, add MockFolderRepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- model/get_entity.go | 4 +++ model/get_entity_test.go | 66 +++++++++++++++++++++++++++++++++++++++ tests/mock_data_store.go | 2 +- tests/mock_folder_repo.go | 55 ++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 model/get_entity_test.go create mode 100644 tests/mock_folder_repo.go diff --git a/model/get_entity.go b/model/get_entity.go index 26f718396..b50f336e6 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -6,6 +6,10 @@ import ( // TODO: Should the type be encoded in the ID? func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { + f, err := ds.Folder(ctx).Get(id) + if err == nil { + return f, nil + } ar, err := ds.Artist(ctx).Get(id) if err == nil { return ar, nil diff --git a/model/get_entity_test.go b/model/get_entity_test.go new file mode 100644 index 000000000..6fcb91547 --- /dev/null +++ b/model/get_entity_test.go @@ -0,0 +1,66 @@ +package model_test + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetEntityByID", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + ) + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + }) + + It("returns a Folder when found", func() { + folder := model.Folder{ID: "folder-1", Name: "Jazz"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{folder}) + + entity, err := model.GetEntityByID(ctx, ds, "folder-1") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Folder{})) + Expect(entity.(*model.Folder).ID).To(Equal("folder-1")) + }) + + It("returns a Folder before trying Artist for the same ID", func() { + folder := model.Folder{ID: "shared-id", Name: "Folder"} + artist := model.Artist{ID: "shared-id", Name: "Artist"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{folder}) + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{artist}) + + entity, err := model.GetEntityByID(ctx, ds, "shared-id") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Folder{})) + }) + + It("returns an Artist when no Folder matches", func() { + artist := model.Artist{ID: "artist-1", Name: "Kraftwerk"} + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{artist}) + + entity, err := model.GetEntityByID(ctx, ds, "artist-1") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Artist{})) + }) + + It("returns an Album when no Folder or Artist matches", func() { + album := model.Album{ID: "album-1", Name: "Radioactivity"} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + + entity, err := model.GetEntityByID(ctx, ds, "album-1") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Album{})) + }) + + It("returns an error when no entity is found", func() { + _, err := model.GetEntityByID(ctx, ds, "nonexistent") + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 754f0c084..26586d8e1 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -54,7 +54,7 @@ func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { if db.RealDS != nil { return db.RealDS.Folder(ctx) } - db.MockedFolder = struct{ model.FolderRepository }{} + db.MockedFolder = CreateMockFolderRepo() return db.MockedFolder } diff --git a/tests/mock_folder_repo.go b/tests/mock_folder_repo.go new file mode 100644 index 000000000..360ce9210 --- /dev/null +++ b/tests/mock_folder_repo.go @@ -0,0 +1,55 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" +) + +func CreateMockFolderRepo() *MockFolderRepo { + return &MockFolderRepo{ + Data: make(map[string]*model.Folder), + } +} + +type MockFolderRepo struct { + model.FolderRepository + Data map[string]*model.Folder + Err bool + Options model.QueryOptions +} + +func (m *MockFolderRepo) SetError(err bool) { + m.Err = err +} + +func (m *MockFolderRepo) SetData(folders []model.Folder) { + m.Data = make(map[string]*model.Folder) + for i, f := range folders { + m.Data[f.ID] = &folders[i] + } +} + +func (m *MockFolderRepo) Get(id string) (*model.Folder, error) { + if m.Err { + return nil, errors.New("Error!") + } + if d, ok := m.Data[id]; ok { + return d, nil + } + return nil, model.ErrNotFound +} + +func (m *MockFolderRepo) GetAll(opts ...model.QueryOptions) ([]model.Folder, error) { + if m.Err { + return nil, errors.New("Error!") + } + if len(opts) > 0 { + m.Options = opts[0] + } + var result []model.Folder + for _, f := range m.Data { + result = append(result, *f) + } + return result, nil +} From c2b2e4217035f2693b75e6f312d7ffe4123dc55c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 01:33:32 +0100 Subject: [PATCH 05/12] Add buildFolderDirectory for folder-based getMusicDirectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- server/subsonic/browsing.go | 25 ++++++++++ server/subsonic/browsing_test.go | 83 +++++++++++++++++++++++++++++++ server/subsonic/filter/filters.go | 7 +++ 3 files changed, 115 insertions(+) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 5b9c4f3c9..397e98cec 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -400,6 +400,31 @@ func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) { return response, nil } +func (api *Router) buildFolderDirectory(ctx context.Context, folder *model.Folder) (*responses.Directory, error) { + dir := &responses.Directory{} + dir.Id = folder.ID + dir.Name = folder.Name + dir.Parent = folder.ParentID + dir.CoverArt = folder.CoverArtID().String() + + childFolders, err := api.ds.Folder(ctx).GetAll(filter.FoldersByParent(folder.ID)) + if err != nil { + return nil, err + } + + mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByFolder(folder.ID)) + if err != nil { + return nil, err + } + + dir.Child = make([]responses.Child, 0, len(childFolders)+len(mfs)) + for _, f := range childFolders { + dir.Child = append(dir.Child, childFromFolder(ctx, f)) + } + dir.Child = append(dir.Child, slice.MapWithArg(mfs, ctx, childFromMediaFile)...) + return dir, nil +} + func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artist) (*responses.Directory, error) { dir := &responses.Directory{} dir.Id = artist.ID diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index b8f510aed..2109faa69 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -60,6 +60,89 @@ var _ = Describe("Browsing", func() { }) }) + Describe("buildFolderDirectory", func() { + var folder model.Folder + + BeforeEach(func() { + folder = model.Folder{ + ID: "folder-1", + ParentID: "root-1", + Name: "Jazz", + } + }) + + It("returns a directory with correct id, name and parent", func() { + ctx = contextWithUser(ctx, "user-id", 1) + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{}) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.Id).To(Equal("folder-1")) + Expect(dir.Name).To(Equal("Jazz")) + Expect(dir.Parent).To(Equal("root-1")) + }) + + It("includes child folders as directory entries with IsDir=true", func() { + ctx = contextWithUser(ctx, "user-id", 1) + childFolder := model.Folder{ID: "folder-2", ParentID: "folder-1", Name: "Blues"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{childFolder}) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.Child).To(HaveLen(1)) + Expect(dir.Child[0].Id).To(Equal("folder-2")) + Expect(dir.Child[0].IsDir).To(BeTrue()) + }) + + It("includes media files as directory entries with IsDir=false", func() { + ctx = contextWithUser(ctx, "user-id", 1) + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf-1", Title: "Track 1", FolderID: "folder-1"}, + }) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.Child).To(HaveLen(1)) + Expect(dir.Child[0].Id).To(Equal("mf-1")) + Expect(dir.Child[0].IsDir).To(BeFalse()) + }) + + It("lists child folders before media files", func() { + ctx = contextWithUser(ctx, "user-id", 1) + childFolder := model.Folder{ID: "folder-2", ParentID: "folder-1", Name: "Sub"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{childFolder}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf-1", Title: "Track 1", FolderID: "folder-1"}, + }) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.Child).To(HaveLen(2)) + Expect(dir.Child[0].IsDir).To(BeTrue()) + Expect(dir.Child[1].IsDir).To(BeFalse()) + }) + + It("sets cover art when folder has image files", func() { + ctx = contextWithUser(ctx, "user-id", 1) + folder.ImageFiles = []string{"cover.jpg"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{}) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.CoverArt).ToNot(BeEmpty()) + }) + + It("returns empty cover art when folder has no image files", func() { + ctx = contextWithUser(ctx, "user-id", 1) + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{}) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.CoverArt).To(BeEmpty()) + }) + }) + Describe("GetIndexes", func() { It("should validate user access to the specified musicFolderId", func() { // Create mock user with access to library 1 only diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 17cb32d41..a3f8e0ccb 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -97,6 +97,13 @@ func SongsByFolder(folderID string) Options { }) } +func FoldersByParent(parentID string) Options { + return Options{ + Filters: And{Eq{"parent_id": parentID}, Eq{"missing": false}}, + Sort: "name", + } +} + func SongsByRandom(genre string, fromYear, toYear int) Options { options := Options{ Sort: "random", From 03f43c1f6010e3adbff069e7c68ab7f8ff55f18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 01:35:12 +0100 Subject: [PATCH 06/12] Handle folder IDs in getMusicDirectory per Subsonic spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- server/subsonic/browsing.go | 2 ++ server/subsonic/browsing_test.go | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 397e98cec..bb051f952 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -143,6 +143,8 @@ func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, erro var dir *responses.Directory switch v := entity.(type) { + case *model.Folder: + dir, err = api.buildFolderDirectory(ctx, v) case *model.Artist: dir, err = api.buildArtistDirectory(ctx, v) case *model.Album: diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index 2109faa69..c8ae92290 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -143,6 +143,52 @@ var _ = Describe("Browsing", func() { }) }) + Describe("GetMusicDirectory with folder ID", func() { + It("returns directory for a valid folder ID", func() { + ctx = contextWithUser(ctx, "user-id", 1) + folder := model.Folder{ID: "folder-1", ParentID: "root-1", Name: "Rock"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{folder}) + + r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=folder-1", nil) + r = r.WithContext(ctx) + + response, err := api.GetMusicDirectory(r) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Directory).ToNot(BeNil()) + Expect(response.Directory.Id).To(Equal("folder-1")) + Expect(response.Directory.Name).To(Equal("Rock")) + Expect(response.Directory.Parent).To(Equal("root-1")) + }) + + It("returns error for unknown ID", func() { + ctx = contextWithUser(ctx, "user-id", 1) + + r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=nonexistent", nil) + r = r.WithContext(ctx) + + response, err := api.GetMusicDirectory(r) + Expect(err).To(HaveOccurred()) + Expect(response).To(BeNil()) + }) + + It("includes child folders as IsDir=true entries in the directory", func() { + ctx = contextWithUser(ctx, "user-id", 1) + folder := model.Folder{ID: "folder-1", ParentID: "", Name: "Music"} + child := model.Folder{ID: "folder-2", ParentID: "folder-1", Name: "Jazz"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{folder, child}) + + r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=folder-1", nil) + r = r.WithContext(ctx) + + response, err := api.GetMusicDirectory(r) + Expect(err).ToNot(HaveOccurred()) + // The mock returns all folders; verify child-2 is present and IsDir + Expect(response.Directory.Child).To(ContainElement( + And(HaveField("Id", "folder-2"), HaveField("IsDir", true)), + )) + }) + }) + Describe("GetIndexes", func() { It("should validate user access to the specified musicFolderId", func() { // Create mock user with access to library 1 only From 235632f3e5c6101764a5971664b8c3679d916f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 01:38:25 +0100 Subject: [PATCH 07/12] Implement folder-based getIndexes per Subsonic spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- server/subsonic/browsing.go | 88 +++++++++++++++++++++++++++++++- server/subsonic/browsing_test.go | 82 +++++++++++++++++++---------- 2 files changed, 140 insertions(+), 30 deletions(-) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index bb051f952..b15dad98d 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -4,6 +4,8 @@ import ( "context" "errors" "net/http" + "sort" + "strings" "time" "github.com/navidrome/navidrome/conf" @@ -99,10 +101,13 @@ func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSi func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - musicFolderIds, _ := selectedMusicFolderIds(r, false) + musicFolderIds, err := selectedMusicFolderIds(r, false) + if err != nil { + return nil, err + } ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{}) - res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince) + res, err := api.getFolderIndex(r.Context(), musicFolderIds, ifModifiedSince) if err != nil { return nil, err } @@ -112,6 +117,85 @@ func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) { return response, nil } +func (api *Router) getFolderIndex(ctx context.Context, musicFolderIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) { + lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") + if err != nil { + log.Error(ctx, "Error retrieving last scan start time", err) + return nil, err + } + lastScan := time.Now() + if lastScanStr != "" { + if t, parseErr := time.Parse(time.RFC3339, lastScanStr); parseErr == nil { + lastScan = t + } + } + + res := &responses.Indexes{ + IgnoredArticles: conf.Server.IgnoredArticles, + LastModified: lastScan.UnixMilli(), + } + + if !lastScan.After(ifModifiedSince) { + return res, nil + } + + // Filter accessible libraries down to the requested ones + libIdSet := make(map[int]bool, len(musicFolderIds)) + for _, id := range musicFolderIds { + libIdSet[id] = true + } + var libraries []model.Library + for _, lib := range getUserAccessibleLibraries(ctx) { + if len(libIdSet) == 0 || libIdSet[lib.ID] { + libraries = append(libraries, lib) + } + } + + // Collect top-level folders (direct children of each library's root) + var allFolders []model.Folder + for _, lib := range libraries { + rootID := model.FolderID(lib, ".") + folders, err := api.ds.Folder(ctx).GetAll(filter.FoldersByParent(rootID)) + if err != nil { + return nil, err + } + allFolders = append(allFolders, folders...) + } + + // Sort by name (case-insensitive) + sort.Slice(allFolders, func(i, j int) bool { + return strings.ToLower(allFolders[i].Name) < strings.ToLower(allFolders[j].Name) + }) + + // Group by first letter; non-alpha goes under "#" + indexMap := make(map[string]*responses.Index) + var indexOrder []string + for _, f := range allFolders { + letter := "#" + if runes := []rune(f.Name); len(runes) > 0 { + up := strings.ToUpper(string(runes[0])) + if up >= "A" && up <= "Z" { + letter = up + } + } + if _, exists := indexMap[letter]; !exists { + indexMap[letter] = &responses.Index{Name: letter} + indexOrder = append(indexOrder, letter) + } + indexMap[letter].Artists = append(indexMap[letter].Artists, responses.Artist{ + Id: f.ID, + Name: f.Name, + CoverArt: f.CoverArtID().String(), + }) + } + + res.Index = make([]responses.Index, len(indexOrder)) + for i, letter := range indexOrder { + res.Index[i] = *indexMap[letter] + } + return res, nil +} + func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) { musicFolderIds, _ := selectedMusicFolderIds(r, false) diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index c8ae92290..eb60d9bcb 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -190,51 +190,77 @@ var _ = Describe("Browsing", func() { }) Describe("GetIndexes", func() { - It("should validate user access to the specified musicFolderId", func() { - // Create mock user with access to library 1 only + var lib1 model.Library + + BeforeEach(func() { + lib1 = model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"} + }) + + It("should return error when musicFolderId is not accessible", func() { ctx = contextWithUser(ctx, "user-id", 1) - // Create request with musicFolderId=2 (not accessible) r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil) r = r.WithContext(ctx) - // Call endpoint response, err := api.GetIndexes(r) - - // Should return error due to lack of access Expect(err).To(HaveOccurred()) Expect(response).To(BeNil()) }) - It("should default to first accessible library when no musicFolderId specified", func() { - // Create mock user with access to libraries 2 and 3 - ctx = contextWithUser(ctx, "user-id", 2, 3) + It("returns an empty index when the library has no top-level folders", func() { + ctx = contextWithUser(ctx, "user-id", 1) + // Folder repo returns nothing — no top-level folders - // Setup minimal mock library data for working tests - mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) - mockLibRepo.SetData(model.Libraries{ - {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, - {ID: 3, Name: "Test Library 3", Path: "/music/library3"}, - }) - - // Setup mock artist data - mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo) - mockArtistRepo.SetData(model.Artists{ - {ID: "1", Name: "Test Artist 1"}, - {ID: "2", Name: "Test Artist 2"}, - }) - - // Create request without musicFolderId r := httptest.NewRequest("GET", "/rest/getIndexes", nil) r = r.WithContext(ctx) - // Call endpoint response, err := api.GetIndexes(r) - - // Should succeed and use first accessible library (2) Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) Expect(response.Indexes).ToNot(BeNil()) + Expect(response.Indexes.Index).To(BeEmpty()) + }) + + It("returns top-level folders grouped by first letter", func() { + ctx = contextWithUser(ctx, "user-id", 1) + rootID := model.FolderID(lib1, ".") + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{ + {ID: "f-jazz", ParentID: rootID, Name: "Jazz"}, + {ID: "f-rock", ParentID: rootID, Name: "Rock"}, + {ID: "f-blues", ParentID: rootID, Name: "Blues"}, + }) + + r := httptest.NewRequest("GET", "/rest/getIndexes", nil) + r = r.WithContext(ctx) + + response, err := api.GetIndexes(r) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Indexes.Index).To(HaveLen(3)) // B, J, R + // Find the "J" index + Expect(response.Indexes.Index).To(ContainElement( + And( + HaveField("Name", "J"), + HaveField("Artists", ContainElement( + And(HaveField("Id", "f-jazz"), HaveField("Name", "Jazz")), + )), + ), + )) + }) + + It("groups non-alpha folder names under '#'", func() { + ctx = contextWithUser(ctx, "user-id", 1) + rootID := model.FolderID(lib1, ".") + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{ + {ID: "f-90s", ParentID: rootID, Name: "90s Hip-Hop"}, + }) + + r := httptest.NewRequest("GET", "/rest/getIndexes", nil) + r = r.WithContext(ctx) + + response, err := api.GetIndexes(r) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Indexes.Index).To(HaveLen(1)) + Expect(response.Indexes.Index[0].Name).To(Equal("#")) + Expect(response.Indexes.Index[0].Artists[0].Id).To(Equal("f-90s")) }) }) From 501cfce84195c9b7642ab9a95de739b6a79e8e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 02:02:40 +0100 Subject: [PATCH 08/12] Add Subsonic.FolderBrowsing config flag to gate getIndexes behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- server/subsonic/browsing.go | 7 ++++++- server/subsonic/browsing_test.go | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index b15dad98d..6312d4100 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -107,7 +107,12 @@ func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) { } ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{}) - res, err := api.getFolderIndex(r.Context(), musicFolderIds, ifModifiedSince) + var res *responses.Indexes + if conf.Server.Subsonic.FolderBrowsing { + res, err = api.getFolderIndex(r.Context(), musicFolderIds, ifModifiedSince) + } else { + res, err = api.getArtistIndex(r, musicFolderIds, ifModifiedSince) + } if err != nil { return nil, err } diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index eb60d9bcb..1c3250330 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http/httptest" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -262,6 +263,26 @@ var _ = Describe("Browsing", func() { Expect(response.Indexes.Index[0].Name).To(Equal("#")) Expect(response.Indexes.Index[0].Artists[0].Id).To(Equal("f-90s")) }) + + It("falls back to artist-based index when FolderBrowsing is disabled", func() { + conf.Server.Subsonic.FolderBrowsing = false + DeferCleanup(func() { conf.Server.Subsonic.FolderBrowsing = true }) + + ctx = contextWithUser(ctx, "user-id", 1) + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ + {ID: "a-1", Name: "Kraftwerk"}, + }) + + r := httptest.NewRequest("GET", "/rest/getIndexes", nil) + r = r.WithContext(ctx) + + response, err := api.GetIndexes(r) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Indexes).ToNot(BeNil()) + // Artist-based index returns artist entries, not folder entries + Expect(response.Indexes.Index).ToNot(BeEmpty()) + Expect(response.Indexes.Index[0].Artists[0].Id).To(Equal("a-1")) + }) }) Describe("GetArtists", func() { From bc2cb3ab4d9693b524fa425880999ab7ac1a2b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 02:31:26 +0100 Subject: [PATCH 09/12] Added subsonic.folderbrowsing option, and removed debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- conf/configuration.go | 2 ++ scanner/phase_1_folders.go | 2 +- server/subsonic/browsing.go | 10 ++++++++-- server/subsonic/browsing_test.go | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index dc5fd1b59..5e22dcdbb 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -163,6 +163,7 @@ type subsonicOptions struct { ArtistParticipations bool DefaultReportRealPath bool EnableAverageRating bool + FolderBrowsing bool LegacyClients string MinimalClients string } @@ -710,6 +711,7 @@ func setViperDefaults() { viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.defaultreportrealpath", false) viper.SetDefault("subsonic.enableaveragerating", true) + viper.SetDefault("subsonic.folderbrowsing", true) viper.SetDefault("subsonic.legacyclients", "DSub") viper.SetDefault("subsonic.minimalclients", "SubMusic") viper.SetDefault("agents", "deezer,lastfm,spotify") diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 38967832c..bb94a9f74 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -172,7 +172,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { // Check if folder is outdated if folder.isOutdated() { if !p.state.fullScan { - if folder.hasNoFiles() && folder.isNew() { + if folder.hasNoFiles() && folder.isNew() && folder.numSubFolders == 0 { log.Trace(p.ctx, "Scanner: Skipping new folder with no files", "folder", folder.path, "lib", job.lib.Name) continue } diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 6312d4100..48bc51ef1 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -144,13 +144,19 @@ func (api *Router) getFolderIndex(ctx context.Context, musicFolderIds []int, ifM return res, nil } - // Filter accessible libraries down to the requested ones + // Fetch all libraries from the database; user.Libraries in context may be + // empty for admin users (not in user_library table), so we can't rely on it. + allLibraries, err := api.ds.Library(ctx).GetAll() + if err != nil { + log.Error(ctx, "Error retrieving libraries for folder index", err) + return nil, err + } libIdSet := make(map[int]bool, len(musicFolderIds)) for _, id := range musicFolderIds { libIdSet[id] = true } var libraries []model.Library - for _, lib := range getUserAccessibleLibraries(ctx) { + for _, lib := range allLibraries { if len(libIdSet) == 0 || libIdSet[lib.ID] { libraries = append(libraries, lib) } diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index 1c3250330..5b123fa5f 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -195,6 +195,7 @@ var _ = Describe("Browsing", func() { BeforeEach(func() { lib1 = model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"} + ds.Library(ctx).(*tests.MockLibraryRepo).SetData(model.Libraries{lib1}) }) It("should return error when musicFolderId is not accessible", func() { From cba0cfe5690c782347a3e33ad7df25dd2d594227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 02:49:52 +0100 Subject: [PATCH 10/12] Return empty ArtistInfo for folder IDs to avoid client errors (from i.e. DSub) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- server/subsonic/browsing.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 48bc51ef1..a03baba57 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -387,6 +387,15 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m includeNotPresent := p.BoolOr("includeNotPresent", false) artist, err := api.provider.UpdateArtistInfo(ctx, id, count, includeNotPresent) + if errors.Is(err, model.ErrNotFound) { + // The ID may be a folder ID (e.g. from a folder-browsing client like DSub + // that calls getArtistInfo on any directory ID). Return empty info rather + // than an error so the client degrades gracefully. + if _, folderErr := api.ds.Folder(ctx).Get(id); folderErr == nil { + empty := model.Artists{} + return &responses.ArtistInfoBase{}, &empty, nil + } + } if err != nil { return nil, nil, err } From fd61e7e73fd488a099b5ef884e596a5d2b39947d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 03:10:54 +0100 Subject: [PATCH 11/12] Update getIndexes tests for folder-based browsing, add FolderBrowsing=false fallback test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- server/e2e/subsonic_browsing_test.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/server/e2e/subsonic_browsing_test.go b/server/e2e/subsonic_browsing_test.go index 55aeb8e9e..45c385e47 100644 --- a/server/e2e/subsonic_browsing_test.go +++ b/server/e2e/subsonic_browsing_test.go @@ -2,6 +2,7 @@ package e2e import ( "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" @@ -26,7 +27,7 @@ var _ = Describe("Browsing Endpoints", func() { }) Describe("getIndexes", func() { - It("returns artist indexes", func() { + It("returns folder indexes by default", func() { resp := doReq("getIndexes") Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -34,16 +35,31 @@ var _ = Describe("Browsing Endpoints", func() { Expect(resp.Indexes.Index).ToNot(BeEmpty()) }) - It("includes all artists across indexes", func() { + It("includes top-level folders grouped by first letter", func() { resp := doReq("getIndexes") - var allArtistNames []string + var allNames []string for _, idx := range resp.Indexes.Index { for _, a := range idx.Artists { - allArtistNames = append(allArtistNames, a.Name) + allNames = append(allNames, a.Name) } } - Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various")) + Expect(allNames).To(ContainElements("Jazz", "Pop", "Rock", "CJK")) + }) + + It("falls back to artist index when FolderBrowsing is disabled", func() { + conf.Server.Subsonic.FolderBrowsing = false + DeferCleanup(func() { conf.Server.Subsonic.FolderBrowsing = true }) + + resp := doReq("getIndexes") + + var allNames []string + for _, idx := range resp.Indexes.Index { + for _, a := range idx.Artists { + allNames = append(allNames, a.Name) + } + } + Expect(allNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various")) }) }) From 9275f6c542c81a85a89d8740e53ca23eb1e26776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Wallstr=C3=B6m?= Date: Sun, 15 Mar 2026 03:24:29 +0100 Subject: [PATCH 12/12] Handle folder names starting with non-ASCII letters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrik Wallström --- server/subsonic/browsing.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index a03baba57..39d655cfd 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "golang.org/x/text/unicode/norm" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/publicurl" @@ -183,7 +185,7 @@ func (api *Router) getFolderIndex(ctx context.Context, musicFolderIds []int, ifM var indexOrder []string for _, f := range allFolders { letter := "#" - if runes := []rune(f.Name); len(runes) > 0 { + if runes := []rune(norm.NFKD.String(f.Name)); len(runes) > 0 { up := strings.ToUpper(string(runes[0])) if up >= "A" && up <= "Z" { letter = up