Implement folder-based getIndexes per Subsonic spec

Signed-off-by: Patrik Wallström <pawal@amplitut.de>
This commit is contained in:
Patrik Wallström 2026-03-15 01:38:25 +01:00
parent 03f43c1f60
commit 235632f3e5
2 changed files with 140 additions and 30 deletions

View File

@ -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)

View File

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