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")) }) })