feat(subsonic): add folder-based navigation to getIndexes and getMusicDirectory

This commit is contained in:
Simone Tellini 2026-09-05 12:32:53 +02:00
commit 617b76a8fc
10 changed files with 1328 additions and 23 deletions

View file

@ -4,8 +4,12 @@ import (
"context"
"errors"
"net/http"
"slices"
"strconv"
"time"
"unicode"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/publicurl"
@ -15,6 +19,7 @@ import (
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) {
@ -59,25 +64,6 @@ func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time
return indexes, lastScan.UnixMilli(), err
}
func (api *Router) getArtistIndex(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) {
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
if err != nil {
return nil, err
}
res := &responses.Indexes{
IgnoredArticles: conf.Server.IgnoredArticles,
LastModified: modified,
}
res.Index = make([]responses.Index, len(indexes))
for i, idx := range indexes {
res.Index[i].Name = idx.ID
res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtist)
}
return res, nil
}
func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Artists, error) {
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
if err != nil {
@ -97,15 +83,123 @@ func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSi
return res, nil
}
func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
musicFolderIds, _ := selectedMusicFolderIds(r, false)
ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{})
func folderIndexKey(name string) string {
s := str.SanitizeFieldForSortingNoArticle(name)
if len(s) == 0 {
return "#"
}
r := []rune(s)[0]
if r >= 'a' && r <= 'z' {
return string(unicode.ToUpper(r))
}
if r >= 'A' && r <= 'Z' {
return string(r)
}
return "#"
}
res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince)
func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
p := req.Params(r)
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
return nil, err
}
ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{})
lastScanStr, err := api.ds.Property().DefaultGet(ctx, 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, err := time.Parse(time.RFC3339, lastScanStr); err == nil {
lastScan = t
}
}
if !lastScan.After(ifModifiedSince) {
return newResponse(), nil
}
res := &responses.Indexes{
IgnoredArticles: conf.Server.IgnoredArticles,
LastModified: lastScan.UnixMilli(),
}
folders, err := api.ds.Folder().GetRootSubfoldersWithAudio(ctx, musicFolderIds...)
if err != nil {
log.Error(ctx, "Error retrieving root subfolders", err)
return nil, err
}
var folderIDs []string
for _, f := range folders {
folderIDs = append(folderIDs, f.ID)
}
coverArtMap, _ := api.ds.Folder().GetCoverArtForFolders(ctx, folderIDs...)
indexMap := make(map[string][]responses.Artist)
for _, f := range folders {
key := folderIndexKey(f.Name)
artist := responses.Artist{
Id: f.ID,
Name: f.Name,
}
if art, ok := coverArtMap[f.ID]; ok && art != "" {
artist.CoverArt = art
}
indexMap[key] = append(indexMap[key], artist)
}
keys := make([]string, 0, len(indexMap))
for k := range indexMap {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
res.Index = append(res.Index, responses.Index{
Name: k,
Artists: indexMap[k],
})
}
accessibleLibs := getUserAccessibleLibraries(ctx)
libMap := make(map[int]model.Library, len(accessibleLibs))
for _, lib := range accessibleLibs {
libMap[lib.ID] = lib
}
for _, libID := range musicFolderIds {
lib, ok := libMap[libID]
if !ok {
continue
}
rootFolder, err := api.ds.Folder().GetByPath(ctx, lib, ".")
if err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Error retrieving root folder", "libraryId", lib.ID, err)
}
continue
}
if rootFolder == nil || rootFolder.Missing {
continue
}
mfs, err := api.ds.MediaFile().GetAll(ctx, model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"media_file.folder_id": rootFolder.ID},
squirrel.Eq{"media_file.missing": false},
},
Sort: "disc_number, track_number, title",
})
if err != nil {
log.Error(ctx, "Error retrieving loose tracks", "folderId", rootFolder.ID, err)
return nil, err
}
for _, mf := range mfs {
res.Child = append(res.Child, childFromMediaFile(ctx, mf))
}
}
response := newResponse()
response.Indexes = res
@ -130,6 +224,58 @@ func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, erro
id, _ := p.String("id")
ctx := r.Context()
// 1. Check if id is a model.Folder
folder, err := api.ds.Folder().Get(ctx, id)
if err == nil && folder != nil {
if folder.Missing {
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
}
dir, err := api.buildFolderDirectory(ctx, folder)
if err != nil {
log.Error(err)
return nil, err
}
if dir.Name == "." {
for _, lib := range getUserAccessibleLibraries(ctx) {
if lib.ID == folder.LibraryID {
dir.Name = lib.Name
break
}
}
if dir.Name == "." {
if lib, err := api.ds.Library().Get(ctx, folder.LibraryID); err == nil && lib != nil {
dir.Name = lib.Name
}
}
}
response := newResponse()
response.Directory = dir
return response, nil
}
// 2. Check if id is an accessible Library.ID
if libID, err := strconv.Atoi(id); err == nil {
accessibleLibs := getUserAccessibleLibraries(ctx)
for _, lib := range accessibleLibs {
if lib.ID == libID {
rootFolder, err := api.ds.Folder().GetByPath(ctx, lib, ".")
if err != nil || rootFolder == nil || rootFolder.Missing {
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
}
dir, err := api.buildFolderDirectory(ctx, rootFolder)
if err != nil {
log.Error(err)
return nil, err
}
dir.Name = lib.Name
response := newResponse()
response.Directory = dir
return response, nil
}
}
}
// 3. Fallback to existing model.GetEntityByID (*model.Artist and *model.Album)
entity, err := model.GetEntityByID(ctx, api.ds, id)
if errors.Is(err, model.ErrNotFound) {
log.Error(r, "Requested ID not found ", "id", id)
@ -478,3 +624,54 @@ func (api *Router) buildAlbum(ctx context.Context, album *model.Album, mfs model
dir.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile)
return dir
}
func (api *Router) buildFolderDirectory(ctx context.Context, folder *model.Folder) (*responses.Directory, error) {
dir := &responses.Directory{}
dir.Id = folder.ID
dir.Name = folder.Name
if folder.ParentID != "" {
dir.Parent = folder.ParentID
}
subfolders, err := api.ds.Folder().GetSubfoldersWithAudio(ctx, folder.ID, folder.LibraryID)
if err != nil {
return nil, err
}
songs, err := api.ds.MediaFile().GetAll(ctx, model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"media_file.folder_id": folder.ID},
squirrel.Eq{"media_file.missing": false},
},
Sort: "disc_number, track_number, title",
})
if err != nil {
return nil, err
}
if len(songs) > 0 {
if album, err := api.ds.Album().Get(ctx, songs[0].AlbumID); err == nil && album != nil {
dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent)
}
} else {
folderCovers, _ := api.ds.Folder().GetCoverArtForFolders(ctx, folder.ID)
if art, ok := folderCovers[folder.ID]; ok {
dir.CoverArt = art
}
}
var subfolderIDs []string
for _, sf := range subfolders {
subfolderIDs = append(subfolderIDs, sf.ID)
}
coverArtMap, _ := api.ds.Folder().GetCoverArtForFolders(ctx, subfolderIDs...)
for _, sf := range subfolders {
dir.Child = append(dir.Child, childFromFolder(ctx, sf, coverArtMap[sf.ID]))
}
for _, song := range songs {
dir.Child = append(dir.Child, childFromMediaFile(ctx, song))
}
return dir, nil
}

View file

@ -1,19 +1,115 @@
package subsonic
import (
"cmp"
"context"
"errors"
"fmt"
"net/http/httptest"
"slices"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type mockFolderRepo struct {
model.FolderRepository
folders map[string]*model.Folder
byPath map[string]*model.Folder
getRootSubfoldersFunc func(libraryIDs ...int) ([]model.Folder, error)
getSubfoldersFunc func(parentID string, libraryIDs ...int) ([]model.Folder, error)
}
func newMockFolderRepo() *mockFolderRepo {
return &mockFolderRepo{
folders: make(map[string]*model.Folder),
byPath: make(map[string]*model.Folder),
}
}
func (m *mockFolderRepo) SetFolders(folders ...model.Folder) {
for i, f := range folders {
m.folders[f.ID] = &folders[i]
}
}
func (m *mockFolderRepo) SetByPath(lib model.Library, p string, folder *model.Folder) {
key := fmt.Sprintf("%d:%s", lib.ID, p)
m.byPath[key] = folder
}
func (m *mockFolderRepo) Get(ctx context.Context, id string) (*model.Folder, error) {
if f, ok := m.folders[id]; ok {
return f, nil
}
return nil, model.ErrNotFound
}
func (m *mockFolderRepo) GetByPath(ctx context.Context, lib model.Library, p string) (*model.Folder, error) {
key := fmt.Sprintf("%d:%s", lib.ID, p)
if f, ok := m.byPath[key]; ok {
return f, nil
}
return nil, model.ErrNotFound
}
func (m *mockFolderRepo) GetRootSubfoldersWithAudio(ctx context.Context, libraryIDs ...int) ([]model.Folder, error) {
if m.getRootSubfoldersFunc != nil {
return m.getRootSubfoldersFunc(libraryIDs...)
}
var res []model.Folder
for _, f := range m.folders {
if (f.ParentID == "" || f.ParentID == "root-1" || f.ParentID == "root-2" || f.ParentID == "root-3") && f.Name != "." && !f.Missing {
if len(libraryIDs) == 0 || slices.Contains(libraryIDs, f.LibraryID) {
res = append(res, *f)
}
}
}
slices.SortFunc(res, func(a, b model.Folder) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return res, nil
}
func (m *mockFolderRepo) GetSubfoldersWithAudio(ctx context.Context, parentID string, libraryIDs ...int) ([]model.Folder, error) {
if m.getSubfoldersFunc != nil {
return m.getSubfoldersFunc(parentID, libraryIDs...)
}
var res []model.Folder
for _, f := range m.folders {
if f.ParentID == parentID && !f.Missing {
if len(libraryIDs) == 0 || slices.Contains(libraryIDs, f.LibraryID) {
res = append(res, *f)
}
}
}
slices.SortFunc(res, func(a, b model.Folder) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return res, nil
}
func (m *mockFolderRepo) GetCoverArtForFolders(ctx context.Context, folderIDs ...string) (map[string]string, error) {
res := make(map[string]string)
for _, id := range folderIDs {
if f, ok := m.folders[id]; ok && f.NumAudioFiles > 0 {
res[id] = "al-" + id
}
}
return res, nil
}
func contextWithUser(ctx context.Context, userID string, libraryIDs ...int) context.Context {
libraries := make([]model.Library, len(libraryIDs))
for i, id := range libraryIDs {
@ -30,12 +126,78 @@ var _ = Describe("Browsing", func() {
var api *Router
var ctx context.Context
var ds model.DataStore
var folderRepo *mockFolderRepo
BeforeEach(func() {
ds = &tests.MockDataStore{}
mockDS := &tests.MockDataStore{}
folderRepo = newMockFolderRepo()
mockDS.MockedFolder = folderRepo
ds = mockDS
auth.Init(ds)
api = &Router{ds: ds}
ctx = context.Background()
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
mockMF.GetAllFn = func(qo ...model.QueryOptions) (model.MediaFiles, error) {
if len(qo) > 0 && qo[0].Filters != nil {
if and, ok := qo[0].Filters.(squirrel.And); ok {
for _, cond := range and {
if eq, ok := cond.(squirrel.Eq); ok {
if folderID, ok := eq["media_file.folder_id"].(string); ok {
var res model.MediaFiles
for _, mf := range mockMF.Data {
if mf.FolderID == folderID && !mf.Missing {
res = append(res, *mf)
}
}
slices.SortFunc(res, func(a, b model.MediaFile) int {
return cmp.Or(
cmp.Compare(a.DiscNumber, b.DiscNumber),
cmp.Compare(a.TrackNumber, b.TrackNumber),
cmp.Compare(a.Title, b.Title),
)
})
return res, nil
}
}
}
}
if eq, ok := qo[0].Filters.(squirrel.Eq); ok {
if albumID, ok := eq["album_id"].(string); ok {
var res model.MediaFiles
for _, mf := range mockMF.Data {
if mf.AlbumID == albumID && !mf.Missing {
res = append(res, *mf)
}
}
return res, nil
}
}
if and, ok := qo[0].Filters.(squirrel.And); ok {
for _, cond := range and {
if eq, ok := cond.(squirrel.Eq); ok {
if albumID, ok := eq["album_id"].(string); ok {
var res model.MediaFiles
for _, mf := range mockMF.Data {
if mf.AlbumID == albumID && !mf.Missing {
res = append(res, *mf)
}
}
return res, nil
}
}
}
}
}
var res model.MediaFiles
for _, mf := range mockMF.Data {
res = append(res, *mf)
}
slices.SortFunc(res, func(a, b model.MediaFile) int {
return cmp.Compare(a.ID, b.ID)
})
return res, nil
}
})
Describe("GetMusicFolders", func() {
@ -108,6 +270,123 @@ var _ = Describe("Browsing", func() {
Expect(response).ToNot(BeNil())
Expect(response.Indexes).ToNot(BeNil())
})
It("returns top-level folders grouped alphabetically into res.Indexes.Index with artist elements", func() {
ctx = contextWithUser(ctx, "user-id", 1)
lib1 := model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}
rootFolder := &model.Folder{ID: "root-1", LibraryID: 1, Path: "", Name: "."}
folderRepo.SetFolders(
*rootFolder,
model.Folder{ID: "f-beatles", LibraryID: 1, Path: "", Name: "The Beatles", ParentID: "root-1"},
model.Folder{ID: "f-pop", LibraryID: 1, Path: "", Name: "Pop", ParentID: "root-1"},
model.Folder{ID: "f-1984", LibraryID: 1, Path: "", Name: "1984", ParentID: "root-1"},
)
folderRepo.SetByPath(lib1, ".", rootFolder)
r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=1", nil)
r = r.WithContext(ctx)
resp, err := api.GetIndexes(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Indexes).ToNot(BeNil())
indexNames := slice.Map(resp.Indexes.Index, func(idx responses.Index) string { return idx.Name })
Expect(indexNames).To(Equal([]string{"#", "B", "P"}))
Expect(resp.Indexes.Index[0].Artists).To(ConsistOf(responses.Artist{Id: "f-1984", Name: "1984"}))
Expect(resp.Indexes.Index[1].Artists).To(ConsistOf(responses.Artist{Id: "f-beatles", Name: "The Beatles"}))
Expect(resp.Indexes.Index[2].Artists).To(ConsistOf(responses.Artist{Id: "f-pop", Name: "Pop"}))
})
It("returns loose root tracks in res.Indexes.Child", func() {
ctx = contextWithUser(ctx, "user-id", 1)
lib1 := model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}
rootFolder := &model.Folder{ID: "root-1", LibraryID: 1, Path: "", Name: "."}
folderRepo.SetFolders(*rootFolder)
folderRepo.SetByPath(lib1, ".", rootFolder)
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
mockMF.SetData(model.MediaFiles{
{ID: "track-root-1", Title: "Root Song 1", FolderID: "root-1", TrackNumber: 1},
{ID: "track-root-2", Title: "Root Song 2", FolderID: "root-1", TrackNumber: 2},
{ID: "track-sub", Title: "Sub Song", FolderID: "sub-folder", TrackNumber: 1},
})
r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=1", nil)
r = r.WithContext(ctx)
resp, err := api.GetIndexes(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Indexes).ToNot(BeNil())
Expect(resp.Indexes.Child).To(HaveLen(2))
Expect(resp.Indexes.Child[0].Id).To(Equal("track-root-1"))
Expect(resp.Indexes.Child[0].Title).To(Equal("Root Song 1"))
Expect(resp.Indexes.Child[0].IsDir).To(BeFalse())
Expect(resp.Indexes.Child[1].Id).To(Equal("track-root-2"))
Expect(resp.Indexes.Child[1].Title).To(Equal("Root Song 2"))
})
It("honors musicFolderId query parameter", func() {
ctx = contextWithUser(ctx, "user-id", 1, 2)
lib1 := model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}
lib2 := model.Library{ID: 2, Name: "Test Library 2", Path: "/music/library2"}
rootFolder1 := &model.Folder{ID: "root-1", LibraryID: 1, Path: "", Name: "."}
rootFolder2 := &model.Folder{ID: "root-2", LibraryID: 2, Path: "", Name: "."}
folderRepo.SetFolders(
*rootFolder1,
*rootFolder2,
model.Folder{ID: "f-lib1", LibraryID: 1, Path: "", Name: "Alpha", ParentID: "root-1"},
model.Folder{ID: "f-lib2", LibraryID: 2, Path: "", Name: "Beta", ParentID: "root-2"},
)
folderRepo.SetByPath(lib1, ".", rootFolder1)
folderRepo.SetByPath(lib2, ".", rootFolder2)
// Query musicFolderId=1 only
r1 := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=1", nil)
r1 = r1.WithContext(ctx)
resp1, err := api.GetIndexes(r1)
Expect(err).ToNot(HaveOccurred())
Expect(resp1.Indexes.Index).To(HaveLen(1))
Expect(resp1.Indexes.Index[0].Name).To(Equal("A"))
Expect(resp1.Indexes.Index[0].Artists[0].Name).To(Equal("Alpha"))
// Query musicFolderId=2 only
r2 := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil)
r2 = r2.WithContext(ctx)
resp2, err := api.GetIndexes(r2)
Expect(err).ToNot(HaveOccurred())
Expect(resp2.Indexes.Index).To(HaveLen(1))
Expect(resp2.Indexes.Index[0].Name).To(Equal("B"))
Expect(resp2.Indexes.Index[0].Artists[0].Name).To(Equal("Beta"))
})
It("returns empty response without indexes when ifModifiedSince is after last scan timestamp", func() {
ctx = contextWithUser(ctx, "user-id", 1)
lib1 := model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}
rootFolder := &model.Folder{ID: "root-1", LibraryID: 1, Path: "", Name: "."}
folderRepo.SetFolders(
*rootFolder,
model.Folder{ID: "f-beatles", LibraryID: 1, Path: "", Name: "The Beatles", ParentID: "root-1"},
)
folderRepo.SetByPath(lib1, ".", rootFolder)
scanTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
_ = ds.Property().Put(ctx, consts.LastScanStartTimeKey, scanTime.Format(time.RFC3339))
ifModifiedSince := scanTime.Add(time.Hour).UnixMilli()
r := httptest.NewRequest("GET", fmt.Sprintf("/rest/getIndexes?musicFolderId=1&ifModifiedSince=%d", ifModifiedSince), nil)
r = r.WithContext(ctx)
resp, err := api.GetIndexes(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Indexes).To(BeNil())
})
})
Describe("GetArtists", func() {
@ -157,6 +436,239 @@ var _ = Describe("Browsing", func() {
Expect(response).ToNot(BeNil())
Expect(response.Artist).ToNot(BeNil())
})
It("still returns tag-based artist index unchanged", func() {
ctx = contextWithUser(ctx, "user-id", 1)
mockArtistRepo := ds.Artist().(*tests.MockArtistRepo)
mockArtistRepo.SetData(model.Artists{
{ID: "artist-1", Name: "Pink Floyd"},
})
r := httptest.NewRequest("GET", "/rest/getArtists", nil)
r = r.WithContext(ctx)
resp, err := api.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Artist).ToNot(BeNil())
Expect(resp.Artist.Index).ToNot(BeEmpty())
Expect(resp.Artist.Index[0].Artists[0].Name).To(Equal("Pink Floyd"))
})
})
Describe("GetMusicDirectory", func() {
It("returns directory with subfolders and tracks when called with folder ID", func() {
ctx = contextWithUser(ctx, "user-id", 1)
parentFolder := &model.Folder{ID: "f-rock", LibraryID: 1, Name: "Rock", ParentID: "root-1"}
subFolder := model.Folder{ID: "f-beatles", LibraryID: 1, Name: "The Beatles", ParentID: "f-rock"}
folderRepo.SetFolders(*parentFolder, subFolder)
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
mockMF.SetData(model.MediaFiles{
{ID: "song-rock-1", Title: "Rock Song 1", FolderID: "f-rock", TrackNumber: 1},
})
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=f-rock", nil)
r = r.WithContext(ctx)
resp, err := api.GetMusicDirectory(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Directory).ToNot(BeNil())
Expect(resp.Directory.Id).To(Equal("f-rock"))
Expect(resp.Directory.Name).To(Equal("Rock"))
Expect(resp.Directory.Parent).To(Equal("root-1"))
Expect(resp.Directory.Child).To(HaveLen(2))
Expect(resp.Directory.Child[0].Id).To(Equal("f-beatles"))
Expect(resp.Directory.Child[0].IsDir).To(BeTrue())
Expect(resp.Directory.Child[1].Id).To(Equal("song-rock-1"))
Expect(resp.Directory.Child[1].IsDir).To(BeFalse())
})
It("populates coverArt for directory and child subfolders", func() {
ctx = contextWithUser(ctx, "user-id", 1)
mockAlbumRepo := ds.Album().(*tests.MockAlbumRepo)
mockAlbumRepo.SetData(model.Albums{
{ID: "alb-rock", Name: "Rock Album", ItemImage: model.ItemImage{ImageHash: "rockhash"}},
})
parentFolder := &model.Folder{ID: "f-artist", LibraryID: 1, Name: "10,000 Maniacs", ParentID: "root-1"}
subFolder := model.Folder{ID: "f-album", LibraryID: 1, Name: "MTV Unplugged", ParentID: "f-artist", NumAudioFiles: 10}
folderRepo.SetFolders(*parentFolder, subFolder)
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=f-artist", nil)
r = r.WithContext(ctx)
resp, err := api.GetMusicDirectory(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Directory).ToNot(BeNil())
Expect(resp.Directory.Child).To(HaveLen(1))
Expect(resp.Directory.Child[0].Id).To(Equal("f-album"))
Expect(resp.Directory.Child[0].IsDir).To(BeTrue())
Expect(resp.Directory.Child[0].CoverArt).To(Equal("al-f-album"))
// Now test requesting the album folder directly
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
mockMF.SetData(model.MediaFiles{
{ID: "song-1", Title: "Song 1", FolderID: "f-album", AlbumID: "alb-rock", TrackNumber: 1},
})
rAlbum := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=f-album", nil)
rAlbum = rAlbum.WithContext(ctx)
respAlbum, err := api.GetMusicDirectory(rAlbum)
Expect(err).ToNot(HaveOccurred())
Expect(respAlbum.Directory).ToNot(BeNil())
Expect(respAlbum.Directory.CoverArt).To(Equal("al-alb-rock_rockhash"))
})
It("resolves library root folder and returns its contents when called with library ID string", func() {
ctx = contextWithUser(ctx, "user-id", 1)
lib1 := model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}
rootFolder := &model.Folder{ID: "root-1", LibraryID: 1, Name: ".", Path: ""}
subFolder := model.Folder{ID: "f-jazz", LibraryID: 1, Name: "Jazz", ParentID: "root-1"}
folderRepo.SetFolders(*rootFolder, subFolder)
folderRepo.SetByPath(lib1, ".", rootFolder)
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
mockMF.SetData(model.MediaFiles{
{ID: "loose-1", Title: "Loose Song", FolderID: "root-1", TrackNumber: 1},
})
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=1", nil)
r = r.WithContext(ctx)
resp, err := api.GetMusicDirectory(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Directory).ToNot(BeNil())
Expect(resp.Directory.Id).To(Equal("root-1"))
Expect(resp.Directory.Name).To(Equal("Test Library 1"))
Expect(resp.Directory.Child).To(HaveLen(2))
Expect(resp.Directory.Child[0].Id).To(Equal("f-jazz"))
Expect(resp.Directory.Child[0].IsDir).To(BeTrue())
Expect(resp.Directory.Child[1].Id).To(Equal("loose-1"))
Expect(resp.Directory.Child[1].IsDir).To(BeFalse())
})
It("returns artist albums when called with Artist ID (fallback)", func() {
ctx = contextWithUser(ctx, "user-id", 1)
mockArtistRepo := ds.Artist().(*tests.MockArtistRepo)
mockArtistRepo.SetData(model.Artists{
{ID: "art-1", Name: "Pink Floyd"},
})
mockAlbumRepo := ds.Album().(*tests.MockAlbumRepo)
mockAlbumRepo.SetData(model.Albums{
{ID: "alb-1", Name: "The Wall", AlbumArtistID: "art-1"},
})
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=art-1", nil)
r = r.WithContext(ctx)
resp, err := api.GetMusicDirectory(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Directory).ToNot(BeNil())
Expect(resp.Directory.Id).To(Equal("art-1"))
Expect(resp.Directory.Name).To(Equal("Pink Floyd"))
Expect(resp.Directory.Child).To(HaveLen(1))
Expect(resp.Directory.Child[0].Id).To(Equal("alb-1"))
Expect(resp.Directory.Child[0].IsDir).To(BeTrue())
})
It("returns album tracks when called with Album ID (fallback)", func() {
ctx = contextWithUser(ctx, "user-id", 1)
mockAlbumRepo := ds.Album().(*tests.MockAlbumRepo)
mockAlbumRepo.SetData(model.Albums{
{ID: "alb-1", Name: "The Dark Side of the Moon", AlbumArtistID: "art-1"},
})
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
mockMF.SetData(model.MediaFiles{
{ID: "song-time", Title: "Time", AlbumID: "alb-1", TrackNumber: 4},
})
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=alb-1", nil)
r = r.WithContext(ctx)
resp, err := api.GetMusicDirectory(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Directory).ToNot(BeNil())
Expect(resp.Directory.Id).To(Equal("alb-1"))
Expect(resp.Directory.Name).To(Equal("The Dark Side of the Moon"))
Expect(resp.Directory.Child).To(HaveLen(1))
Expect(resp.Directory.Child[0].Id).To(Equal("song-time"))
Expect(resp.Directory.Child[0].IsDir).To(BeFalse())
})
It("returns ErrorDataNotFound when called with nonexistent ID", func() {
ctx = contextWithUser(ctx, "user-id", 1)
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=nonexistent", nil)
r = r.WithContext(ctx)
resp, err := api.GetMusicDirectory(r)
Expect(err).To(HaveOccurred())
Expect(resp).To(BeNil())
var subErr subError
Expect(errors.As(err, &subErr)).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
})
It("returns ErrorDataNotFound when called with unauthorized library ID", func() {
ctx = contextWithUser(ctx, "user-id", 1)
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=99", nil)
r = r.WithContext(ctx)
resp, err := api.GetMusicDirectory(r)
Expect(err).To(HaveOccurred())
Expect(resp).To(BeNil())
var subErr subError
Expect(errors.As(err, &subErr)).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
})
It("returns ErrorDataNotFound when called with a folder that has Missing: true", func() {
ctx = contextWithUser(ctx, "user-id", 1)
missingFolder := &model.Folder{ID: "f-missing", LibraryID: 1, Name: "Missing Folder", Missing: true}
folderRepo.SetFolders(*missingFolder)
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=f-missing", nil)
r = r.WithContext(ctx)
resp, err := api.GetMusicDirectory(r)
Expect(err).To(HaveOccurred())
Expect(resp).To(BeNil())
var subErr subError
Expect(errors.As(err, &subErr)).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
})
It("returns songs in a folder sorted by disc_number, track_number, title", func() {
ctx = contextWithUser(ctx, "user-id", 1)
parentFolder := &model.Folder{ID: "f-multidisc", LibraryID: 1, Name: "Multi Disc Album", ParentID: "root-1"}
folderRepo.SetFolders(*parentFolder)
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
mockMF.SetData(model.MediaFiles{
{ID: "song-d2-t1", Title: "Disc 2 Track 1", FolderID: "f-multidisc", DiscNumber: 2, TrackNumber: 1},
{ID: "song-d1-t2", Title: "Disc 1 Track 2", FolderID: "f-multidisc", DiscNumber: 1, TrackNumber: 2},
{ID: "song-d1-t1", Title: "Disc 1 Track 1", FolderID: "f-multidisc", DiscNumber: 1, TrackNumber: 1},
{ID: "song-d2-t2", Title: "Disc 2 Track 2", FolderID: "f-multidisc", DiscNumber: 2, TrackNumber: 2},
})
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=f-multidisc", nil)
r = r.WithContext(ctx)
resp, err := api.GetMusicDirectory(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.Directory).ToNot(BeNil())
Expect(resp.Directory.Child).To(HaveLen(4))
Expect(resp.Directory.Child[0].Id).To(Equal("song-d1-t1"))
Expect(resp.Directory.Child[1].Id).To(Equal("song-d1-t2"))
Expect(resp.Directory.Child[2].Id).To(Equal("song-d2-t1"))
Expect(resp.Directory.Child[3].Id).To(Equal("song-d2-t2"))
})
})
Describe("GetAlbumInfo", func() {

View file

@ -43,7 +43,7 @@ var _ = Describe("Browsing Endpoints", func() {
Expect(resp.Indexes.Index).ToNot(BeEmpty())
})
It("includes all artists across indexes", func() {
It("includes all top-level folders across indexes", func() {
resp := doReq("getIndexes")
var allArtistNames []string
@ -52,7 +52,7 @@ var _ = Describe("Browsing Endpoints", func() {
allArtistNames = append(allArtistNames, a.Name)
}
}
Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various"))
Expect(allArtistNames).To(ContainElements("CJK", "Jazz", "Lyrics", "Pop", "Rock", "Test"))
})
})

View file

@ -400,6 +400,23 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
return child
}
func childFromFolder(ctx context.Context, f model.Folder, coverArt ...string) responses.Child {
child := responses.Child{}
child.Id = f.ID
child.Parent = f.ParentID
child.IsDir = true
child.Title = f.Name
child.Name = f.Name
child.Album = f.Name
if len(coverArt) > 0 && coverArt[0] != "" {
child.CoverArt = coverArt[0]
}
if !f.CreatedAt.IsZero() {
child.Created = &f.CreatedAt
}
return child
}
func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubsonicChild {
player, _ := request.PlayerFrom(ctx)
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {

View file

@ -855,4 +855,62 @@ var _ = Describe("helpers", func() {
})
})
})
Describe("childFromFolder", func() {
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
})
It("maps folder attributes to responses.Child with IsDir=true", func() {
createdAt := time.Date(2023, 10, 15, 12, 0, 0, 0, time.UTC)
folder := model.Folder{
ID: "folder-123",
ParentID: "parent-456",
Name: "Rock",
CreatedAt: createdAt,
}
child := childFromFolder(ctx, folder)
Expect(child.Id).To(Equal("folder-123"))
Expect(child.Parent).To(Equal("parent-456"))
Expect(child.IsDir).To(BeTrue())
Expect(child.Title).To(Equal("Rock"))
Expect(child.Name).To(Equal("Rock"))
Expect(child.Album).To(Equal("Rock"))
Expect(child.Created).NotTo(BeNil())
Expect(*child.Created).To(Equal(createdAt))
Expect(child.CoverArt).To(BeEmpty())
})
It("leaves CoverArt empty even when folder has image files", func() {
folder := model.Folder{
ID: "folder-789",
Name: "Pop",
ImageFiles: []string{"cover.jpg"},
}
child := childFromFolder(ctx, folder)
Expect(child.CoverArt).To(BeEmpty())
})
It("leaves Created nil when CreatedAt is zero", func() {
folder := model.Folder{
ID: "folder-abc",
Name: "Jazz",
CreatedAt: time.Time{},
}
child := childFromFolder(ctx, folder)
Expect(child.Created).To(BeNil())
Expect(child.CoverArt).To(BeEmpty())
})
It("sets CoverArt when provided", func() {
folder := model.Folder{
ID: "folder-art",
Name: "Album Folder",
}
child := childFromFolder(ctx, folder, "al-album123")
Expect(child.CoverArt).To(Equal("al-album123"))
})
})
})

View file

@ -110,6 +110,7 @@ type Index struct {
type Indexes struct {
Index []Index `xml:"index" json:"index,omitempty"`
Child []Child `xml:"child" json:"child,omitempty"`
LastModified int64 `xml:"lastModified,attr" json:"lastModified"`
IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"`
}