From 617b76a8fc0ade51ad0c2ccb978cd68ae5bdf6f4 Mon Sep 17 00:00:00 2001 From: Simone Tellini Date: Sat, 5 Sep 2026 12:32:53 +0200 Subject: [PATCH] feat(subsonic): add folder-based navigation to getIndexes and getMusicDirectory --- model/folder.go | 3 + persistence/folder_repository.go | 104 ++++ persistence/folder_repository_test.go | 409 ++++++++++++++ server/subsonic/browsing.go | 245 ++++++++- server/subsonic/browsing_test.go | 514 +++++++++++++++++- server/subsonic/e2e/subsonic_browsing_test.go | 4 +- server/subsonic/helpers.go | 17 + server/subsonic/helpers_test.go | 58 ++ server/subsonic/responses/responses.go | 1 + tests/mock_mediafile_repo.go | 4 + 10 files changed, 1332 insertions(+), 27 deletions(-) diff --git a/model/folder.go b/model/folder.go index 701fca8cd..e30684d1b 100644 --- a/model/folder.go +++ b/model/folder.go @@ -99,4 +99,7 @@ type FolderRepository interface { // GetAllWithPlaylists returns all non-missing folders with playlists, ignoring // the scan-timestamp gate used by GetTouchedWithPlaylists. GetAllWithPlaylists(ctx context.Context) (FolderCursor, error) + GetSubfoldersWithAudio(ctx context.Context, parentID string, libraryIDs ...int) ([]Folder, error) + GetRootSubfoldersWithAudio(ctx context.Context, libraryIDs ...int) ([]Folder, error) + GetCoverArtForFolders(ctx context.Context, folderIDs ...string) (map[string]string, error) } diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index b4f7c9069..7b1562c07 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -3,6 +3,7 @@ package persistence import ( "context" "encoding/json" + "errors" "fmt" "iter" "maps" @@ -298,6 +299,109 @@ func (r folderRepository) GetAllWithPlaylists(ctx context.Context) (model.Folder return wrapFolderCursor(cursor), nil } +const ( + folderRelPathExpr = `CASE WHEN folder.path = '' OR folder.path = '.' THEN folder.name ELSE folder.path || '/' || folder.name END` + + folderEscapedRelPathExpr = `REPLACE(REPLACE(REPLACE(` + folderRelPathExpr + `, '\', '\\'), '%', '\%'), '_', '\_')` + + // folderHasAudioCond checks whether a folder has audio directly, or contains descendant subfolders with audio. + // Note: The EXISTS subquery performs a prefix check on descendant folders when a direct subfolder has + // num_audio_files == 0. Because the outer query restricts rows by parent_id (or root parent_id), the number + // of subquery evaluations is strictly bounded by the direct child count. + folderHasAudioCond = `(folder.num_audio_files > 0 OR EXISTS (` + + `SELECT 1 FROM folder sub ` + + `WHERE sub.library_id = folder.library_id ` + + `AND sub.missing = 0 ` + + `AND sub.num_audio_files > 0 ` + + `AND (sub.path = ` + folderRelPathExpr + ` OR sub.path LIKE ` + folderEscapedRelPathExpr + ` || '/%' ESCAPE '\')` + + `))` +) + +func (r folderRepository) getSubfoldersWithAudio(ctx context.Context, parentCond Sqlizer, libraryIDs ...int) ([]model.Folder, error) { + cond := And{ + parentCond, + Eq{"folder.missing": false}, + ConcatExpr(folderHasAudioCond), + } + if len(libraryIDs) > 0 { + cond = append(cond, Eq{"folder.library_id": libraryIDs}) + } + sq := r.selectFolder(ctx).Where(cond).OrderBy("folder.name COLLATE NOCASE ASC") + var res dbFolders + err := r.queryAll(ctx, sq, &res) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + return res.toModels(), nil +} + +func (r folderRepository) GetRootSubfoldersWithAudio(ctx context.Context, libraryIDs ...int) ([]model.Folder, error) { + return r.getSubfoldersWithAudio(ctx, + ConcatExpr("folder.parent_id IN (SELECT id FROM folder WHERE path = '' AND name = '.')"), + libraryIDs..., + ) +} + +func (r folderRepository) GetSubfoldersWithAudio(ctx context.Context, parentID string, libraryIDs ...int) ([]model.Folder, error) { + return r.getSubfoldersWithAudio(ctx, + Eq{"folder.parent_id": parentID}, + libraryIDs..., + ) +} + +type folderCoverArtRow struct { + FolderID string `db:"folder_id"` + AlbumID string `db:"album_id"` + ImageHash string `db:"image_hash"` + ImageAbsent bool `db:"image_absent"` +} + +func (r folderRepository) GetCoverArtForFolders(ctx context.Context, folderIDs ...string) (map[string]string, error) { + result := make(map[string]string, len(folderIDs)) + if len(folderIDs) == 0 { + return result, nil + } + + for chunk := range slices.Chunk(folderIDs, 200) { + sq := Select( + "f.id AS folder_id", + "al.id AS album_id", + "COALESCE(ia.hash, '') AS image_hash", + "CASE WHEN ia.item_id IS NOT NULL AND ia.hash = '' THEN 1 ELSE 0 END AS image_absent", + ). + From("folder f"). + Join("media_file mf ON (mf.folder_id = f.id OR mf.folder_id IN (SELECT d.id FROM folder d WHERE d.parent_id = f.id AND d.missing = 0))"). + Join("album al ON mf.album_id = al.id"). + LeftJoin("item_artwork ia ON ia.item_kind = 'al' AND ia.item_id = al.id AND ia.image_type = 'primary'"). + Where(And{ + Eq{"f.id": chunk}, + Eq{"f.missing": false}, + Eq{"mf.missing": false}, + }). + GroupBy("f.id"). + Having("f.num_audio_files > 0 OR COUNT(DISTINCT mf.album_id) = 1") + + var rows []folderCoverArtRow + err := r.queryAll(ctx, sq, &rows) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + + for _, row := range rows { + if row.ImageAbsent { + continue + } + artID := model.ArtworkID{ + Kind: model.KindAlbumArtwork, + ID: row.AlbumID, + Hash: row.ImageHash, + } + result[row.FolderID] = artID.String() + } + } + return result, nil +} + func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder })) } diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 4b1c54a3d..240903552 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -388,4 +388,413 @@ var _ = Describe("FolderRepository", func() { Expect(ids).To(ConsistOf(withPls.ID)) // only the non-missing folder with playlists }) }) + + Describe("GetRootSubfoldersWithAudio and GetSubfoldersWithAudio", func() { + var ( + rootFolder, otherRoot *model.Folder + rootWithDirect *model.Folder + rootWithDesc *model.Folder + rootWithDescChild *model.Folder + rootWithGrandchild *model.Folder + rootWithGrandchildChild *model.Folder + rootWithGrandchildGrandchild *model.Folder + rootEmpty *model.Folder + rootEmptyChild *model.Folder + rootMissing *model.Folder + rootDescMissing *model.Folder + rootDescMissingChild *model.Folder + rootSortA, rootSortB, rootSortC *model.Folder + rootOtherLib *model.Folder + rootSpecialWildcard *model.Folder + rootSpecialChild *model.Folder + rootSpecialFalseMatch *model.Folder + + parentFolder *model.Folder + childDirect *model.Folder + childWithDesc *model.Folder + childWithDescGrandchild *model.Folder + childEmpty *model.Folder + childEmptyGrandchild *model.Folder + childMissing *model.Folder + childDescMissing *model.Folder + childDescMissingGrandchild *model.Folder + otherParentFolder *model.Folder + otherParentChild *model.Folder + ) + + BeforeEach(func() { + // Ensure root folders exist for both libraries + rootFolder = model.NewFolder(testLib, ".") + Expect(repo.Put(ctx, rootFolder)).To(Succeed()) + + otherRoot = model.NewFolder(otherLib, ".") + Expect(repo.Put(ctx, otherRoot)).To(Succeed()) + + // 1. Top-level folder with direct audio + rootWithDirect = model.NewFolder(testLib, "TestAudioRootDirect") + rootWithDirect.NumAudioFiles = 3 + Expect(repo.Put(ctx, rootWithDirect)).To(Succeed()) + + // 2. Top-level folder with 0 direct audio, but child has audio + rootWithDesc = model.NewFolder(testLib, "TestAudioRootDesc") + rootWithDesc.NumAudioFiles = 0 + Expect(repo.Put(ctx, rootWithDesc)).To(Succeed()) + rootWithDescChild = model.NewFolder(testLib, "TestAudioRootDesc/Child") + rootWithDescChild.NumAudioFiles = 4 + Expect(repo.Put(ctx, rootWithDescChild)).To(Succeed()) + + // 3. Top-level folder with 0 direct audio, grandchild has audio + rootWithGrandchild = model.NewFolder(testLib, "TestAudioRootGrandchild") + rootWithGrandchild.NumAudioFiles = 0 + Expect(repo.Put(ctx, rootWithGrandchild)).To(Succeed()) + rootWithGrandchildChild = model.NewFolder(testLib, "TestAudioRootGrandchild/Album") + rootWithGrandchildChild.NumAudioFiles = 0 + Expect(repo.Put(ctx, rootWithGrandchildChild)).To(Succeed()) + rootWithGrandchildGrandchild = model.NewFolder(testLib, "TestAudioRootGrandchild/Album/CD1") + rootWithGrandchildGrandchild.NumAudioFiles = 2 + Expect(repo.Put(ctx, rootWithGrandchildGrandchild)).To(Succeed()) + + // 4. Empty top-level folder + rootEmpty = model.NewFolder(testLib, "TestAudioRootEmpty") + rootEmpty.NumAudioFiles = 0 + Expect(repo.Put(ctx, rootEmpty)).To(Succeed()) + rootEmptyChild = model.NewFolder(testLib, "TestAudioRootEmpty/Sub") + rootEmptyChild.NumAudioFiles = 0 + Expect(repo.Put(ctx, rootEmptyChild)).To(Succeed()) + + // 5. Missing top-level folder (has audio directly) + rootMissing = model.NewFolder(testLib, "TestAudioRootMissing") + rootMissing.NumAudioFiles = 5 + rootMissing.Missing = true + Expect(repo.Put(ctx, rootMissing)).To(Succeed()) + + // 6. Top-level folder whose only audio-bearing child is missing + rootDescMissing = model.NewFolder(testLib, "TestAudioRootDescMissing") + rootDescMissing.NumAudioFiles = 0 + Expect(repo.Put(ctx, rootDescMissing)).To(Succeed()) + rootDescMissingChild = model.NewFolder(testLib, "TestAudioRootDescMissing/Sub") + rootDescMissingChild.NumAudioFiles = 3 + rootDescMissingChild.Missing = true + Expect(repo.Put(ctx, rootDescMissingChild)).To(Succeed()) + + // 7. Case-insensitive sorting folders + rootSortB = model.NewFolder(testLib, "TestAudioSort_b") + rootSortB.NumAudioFiles = 1 + Expect(repo.Put(ctx, rootSortB)).To(Succeed()) + rootSortA = model.NewFolder(testLib, "TestAudioSort_A") + rootSortA.NumAudioFiles = 1 + Expect(repo.Put(ctx, rootSortA)).To(Succeed()) + rootSortC = model.NewFolder(testLib, "TestAudioSort_c") + rootSortC.NumAudioFiles = 1 + Expect(repo.Put(ctx, rootSortC)).To(Succeed()) + + // 8. Other library folder + rootOtherLib = model.NewFolder(otherLib, "TestAudioOtherLibRoot") + rootOtherLib.NumAudioFiles = 2 + Expect(repo.Put(ctx, rootOtherLib)).To(Succeed()) + + // 9. Special character escaping: "TestAudio_AC%DC" vs "TestAudio_AC-DC" + rootSpecialWildcard = model.NewFolder(testLib, "TestAudio_AC%DC") + rootSpecialWildcard.NumAudioFiles = 0 + Expect(repo.Put(ctx, rootSpecialWildcard)).To(Succeed()) + rootSpecialChild = model.NewFolder(testLib, "TestAudio_AC%DC/Album") + rootSpecialChild.NumAudioFiles = 1 + Expect(repo.Put(ctx, rootSpecialChild)).To(Succeed()) + + rootSpecialFalseMatch = model.NewFolder(testLib, "TestAudio_AC-DC") + rootSpecialFalseMatch.NumAudioFiles = 0 + Expect(repo.Put(ctx, rootSpecialFalseMatch)).To(Succeed()) + + // Hierarchy for GetSubfoldersWithAudio + parentFolder = model.NewFolder(testLib, "TestAudioSub_Parent") + parentFolder.NumAudioFiles = 0 + Expect(repo.Put(ctx, parentFolder)).To(Succeed()) + + childDirect = model.NewFolder(testLib, "TestAudioSub_Parent/ChildDirect") + childDirect.NumAudioFiles = 2 + Expect(repo.Put(ctx, childDirect)).To(Succeed()) + + childWithDesc = model.NewFolder(testLib, "TestAudioSub_Parent/ChildWithDesc") + childWithDesc.NumAudioFiles = 0 + Expect(repo.Put(ctx, childWithDesc)).To(Succeed()) + childWithDescGrandchild = model.NewFolder(testLib, "TestAudioSub_Parent/ChildWithDesc/Sub") + childWithDescGrandchild.NumAudioFiles = 3 + Expect(repo.Put(ctx, childWithDescGrandchild)).To(Succeed()) + + childEmpty = model.NewFolder(testLib, "TestAudioSub_Parent/ChildEmpty") + childEmpty.NumAudioFiles = 0 + Expect(repo.Put(ctx, childEmpty)).To(Succeed()) + childEmptyGrandchild = model.NewFolder(testLib, "TestAudioSub_Parent/ChildEmpty/Sub") + childEmptyGrandchild.NumAudioFiles = 0 + Expect(repo.Put(ctx, childEmptyGrandchild)).To(Succeed()) + + childMissing = model.NewFolder(testLib, "TestAudioSub_Parent/ChildMissing") + childMissing.NumAudioFiles = 4 + childMissing.Missing = true + Expect(repo.Put(ctx, childMissing)).To(Succeed()) + + childDescMissing = model.NewFolder(testLib, "TestAudioSub_Parent/ChildDescMissing") + childDescMissing.NumAudioFiles = 0 + Expect(repo.Put(ctx, childDescMissing)).To(Succeed()) + childDescMissingGrandchild = model.NewFolder(testLib, "TestAudioSub_Parent/ChildDescMissing/Sub") + childDescMissingGrandchild.NumAudioFiles = 2 + childDescMissingGrandchild.Missing = true + Expect(repo.Put(ctx, childDescMissingGrandchild)).To(Succeed()) + + otherParentFolder = model.NewFolder(testLib, "TestAudioSub_OtherParent") + otherParentFolder.NumAudioFiles = 0 + Expect(repo.Put(ctx, otherParentFolder)).To(Succeed()) + otherParentChild = model.NewFolder(testLib, "TestAudioSub_OtherParent/OtherChild") + otherParentChild.NumAudioFiles = 5 + Expect(repo.Put(ctx, otherParentChild)).To(Succeed()) + + DeferCleanup(func() { + _, _ = conn.NewQuery("DELETE FROM folder WHERE name LIKE 'TestAudio%' OR path LIKE 'TestAudio%'").Execute() + _, _ = conn.NewQuery("DELETE FROM folder WHERE id = {:id}").Bind(dbx.Params{"id": otherRoot.ID}).Execute() + }) + }) + + Describe("GetRootSubfoldersWithAudio", func() { + It("returns only root subfolders with audio in their subtree, sorted by name NOCASE", func() { + folders, err := repo.GetRootSubfoldersWithAudio(ctx, testLib.ID) + Expect(err).ToNot(HaveOccurred()) + + ids := slice.Map(folders, func(f model.Folder) string { return f.ID }) + + // Should include folders with audio directly or in descendants + Expect(ids).To(ContainElements( + rootWithDirect.ID, + rootWithDesc.ID, + rootWithGrandchild.ID, + rootSpecialWildcard.ID, + )) + + // Should exclude empty, missing, or false-wildcard folders + Expect(ids).ToNot(ContainElements( + rootEmpty.ID, + rootMissing.ID, + rootDescMissing.ID, + rootSpecialFalseMatch.ID, + )) + + // Should exclude folders from other library when filtering by testLib.ID + Expect(ids).ToNot(ContainElement(rootOtherLib.ID)) + }) + + It("filters by libraryIDs when provided", func() { + folders, err := repo.GetRootSubfoldersWithAudio(ctx, otherLib.ID) + Expect(err).ToNot(HaveOccurred()) + + ids := slice.Map(folders, func(f model.Folder) string { return f.ID }) + Expect(ids).To(ConsistOf(rootOtherLib.ID)) + }) + + It("sorts results by name COLLATE NOCASE ASC", func() { + folders, err := repo.GetRootSubfoldersWithAudio(ctx, testLib.ID) + Expect(err).ToNot(HaveOccurred()) + + // Filter down to the three sort test folders + var sortNames []string + for _, f := range folders { + if f.ID == rootSortA.ID || f.ID == rootSortB.ID || f.ID == rootSortC.ID { + sortNames = append(sortNames, f.Name) + } + } + Expect(sortNames).To(Equal([]string{ + "TestAudioSort_A", + "TestAudioSort_b", + "TestAudioSort_c", + })) + }) + }) + + Describe("GetSubfoldersWithAudio", func() { + It("returns direct children with audio directly or in descendants", func() { + children, err := repo.GetSubfoldersWithAudio(ctx, parentFolder.ID) + Expect(err).ToNot(HaveOccurred()) + + ids := slice.Map(children, func(f model.Folder) string { return f.ID }) + + // Should include child with direct audio and child with descendant audio + Expect(ids).To(ConsistOf(childDirect.ID, childWithDesc.ID)) + + // Should exclude child of other parent + Expect(ids).ToNot(ContainElement(otherParentChild.ID)) + }) + + It("filters by libraryIDs when provided", func() { + // Asking with otherLib.ID should return empty slice since parent is in testLib + children, err := repo.GetSubfoldersWithAudio(ctx, parentFolder.ID, otherLib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(children).To(BeEmpty()) + + // Asking with testLib.ID should return the matching children + children, err = repo.GetSubfoldersWithAudio(ctx, parentFolder.ID, testLib.ID) + Expect(err).ToNot(HaveOccurred()) + ids := slice.Map(children, func(f model.Folder) string { return f.ID }) + Expect(ids).To(ConsistOf(childDirect.ID, childWithDesc.ID)) + }) + + It("sorts children by name COLLATE NOCASE ASC", func() { + sortParent := model.NewFolder(testLib, "TestAudioSortParent") + Expect(repo.Put(ctx, sortParent)).To(Succeed()) + + childB := model.NewFolder(testLib, "TestAudioSortParent/beta") + childB.NumAudioFiles = 1 + Expect(repo.Put(ctx, childB)).To(Succeed()) + + childA := model.NewFolder(testLib, "TestAudioSortParent/Alpha") + childA.NumAudioFiles = 1 + Expect(repo.Put(ctx, childA)).To(Succeed()) + + childC := model.NewFolder(testLib, "TestAudioSortParent/gamma") + childC.NumAudioFiles = 1 + Expect(repo.Put(ctx, childC)).To(Succeed()) + + children, err := repo.GetSubfoldersWithAudio(ctx, sortParent.ID) + Expect(err).ToNot(HaveOccurred()) + names := slice.Map(children, func(f model.Folder) string { return f.Name }) + Expect(names).To(Equal([]string{"Alpha", "beta", "gamma"})) + }) + + It("returns empty slice when parentID has no children", func() { + children, err := repo.GetSubfoldersWithAudio(ctx, "non-existent-id") + Expect(err).ToNot(HaveOccurred()) + Expect(children).To(BeEmpty()) + }) + }) + + Describe("GetCoverArtForFolders", func() { + var albumRepo model.AlbumRepository + var mfRepo model.MediaFileRepository + var alDirect, alDisc *model.Album + var folDirect, folMultiDisc, folDisc1, folEmpty *model.Folder + + BeforeEach(func() { + albumRepo = NewAlbumRepository(conn) + mfRepo = NewMediaFileRepository(conn) + + alDirect = &model.Album{ + ID: "test-cov-al-1", + Name: "Direct Album", + LibraryID: testLib.ID, + ItemImage: model.ItemImage{ImageHash: "hashdirect", ImageAbsent: false}, + } + Expect(albumRepo.Put(ctx, alDirect)).To(Succeed()) + + alDisc = &model.Album{ + ID: "test-cov-al-2", + Name: "MultiDisc Album", + LibraryID: testLib.ID, + ItemImage: model.ItemImage{ImageHash: "hashdisc", ImageAbsent: false}, + } + Expect(albumRepo.Put(ctx, alDisc)).To(Succeed()) + + folDirect = model.NewFolder(testLib, "TestCover/Direct") + folDirect.NumAudioFiles = 1 + Expect(repo.Put(ctx, folDirect)).To(Succeed()) + + folMultiDisc = model.NewFolder(testLib, "TestCover/MultiDisc") + Expect(repo.Put(ctx, folMultiDisc)).To(Succeed()) + + folDisc1 = model.NewFolder(testLib, "TestCover/MultiDisc/CD1") + folDisc1.NumAudioFiles = 1 + Expect(repo.Put(ctx, folDisc1)).To(Succeed()) + + folEmpty = model.NewFolder(testLib, "TestCover/Empty") + Expect(repo.Put(ctx, folEmpty)).To(Succeed()) + + mfDirect := &model.MediaFile{ + ID: "test-cov-mf-1", + LibraryID: testLib.ID, + AlbumID: alDirect.ID, + FolderID: folDirect.ID, + Path: "TestCover/Direct/01.mp3", + } + Expect(mfRepo.Put(ctx, mfDirect)).To(Succeed()) + + mfDisc := &model.MediaFile{ + ID: "test-cov-mf-2", + LibraryID: testLib.ID, + AlbumID: alDisc.ID, + FolderID: folDisc1.ID, + Path: "TestCover/MultiDisc/CD1/01.mp3", + } + Expect(mfRepo.Put(ctx, mfDisc)).To(Succeed()) + + artworkRepo := NewArtworkRepository(conn) + Expect(artworkRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: alDirect.ID, Hash: "hashdirect"})).To(Succeed()) + Expect(artworkRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: alDisc.ID, Hash: "hashdisc"})).To(Succeed()) + + DeferCleanup(func() { + _, _ = conn.NewQuery("DELETE FROM media_file WHERE id LIKE 'test-cov-mf-%'").Execute() + _, _ = conn.NewQuery("DELETE FROM album WHERE id LIKE 'test-cov-al-%'").Execute() + _, _ = conn.NewQuery("DELETE FROM item_artwork WHERE item_id LIKE 'test-cov-al-%'").Execute() + }) + }) + + It("returns coverArt for folders with direct audio files", func() { + res, err := repo.GetCoverArtForFolders(ctx, folDirect.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(HaveKeyWithValue(folDirect.ID, "al-test-cov-al-1_hashdirect")) + }) + + It("returns coverArt for multi-disc parent folders from disc subfolders", func() { + res, err := repo.GetCoverArtForFolders(ctx, folMultiDisc.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(HaveKeyWithValue(folMultiDisc.ID, "al-test-cov-al-2_hashdisc")) + }) + + It("does not return coverArt for empty folders", func() { + res, err := repo.GetCoverArtForFolders(ctx, folEmpty.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(HaveKey(folEmpty.ID)) + }) + + It("does not return coverArt for artist folders containing multiple albums", func() { + folArtist := model.NewFolder(testLib, "TestCover/Artist") + Expect(repo.Put(ctx, folArtist)).To(Succeed()) + + folAlb1 := model.NewFolder(testLib, "TestCover/Artist/Album1") + folAlb1.NumAudioFiles = 1 + Expect(repo.Put(ctx, folAlb1)).To(Succeed()) + + folAlb2 := model.NewFolder(testLib, "TestCover/Artist/Album2") + folAlb2.NumAudioFiles = 1 + Expect(repo.Put(ctx, folAlb2)).To(Succeed()) + + mfAlb1 := &model.MediaFile{ + ID: "test-cov-mf-alb1", + LibraryID: testLib.ID, + AlbumID: alDirect.ID, + FolderID: folAlb1.ID, + Path: "TestCover/Artist/Album1/01.mp3", + } + Expect(mfRepo.Put(ctx, mfAlb1)).To(Succeed()) + + mfAlb2 := &model.MediaFile{ + ID: "test-cov-mf-alb2", + LibraryID: testLib.ID, + AlbumID: alDisc.ID, + FolderID: folAlb2.ID, + Path: "TestCover/Artist/Album2/01.mp3", + } + Expect(mfRepo.Put(ctx, mfAlb2)).To(Succeed()) + + res, err := repo.GetCoverArtForFolders(ctx, folArtist.ID, folAlb1.ID, folAlb2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(HaveKey(folArtist.ID)) + Expect(res).To(HaveKeyWithValue(folAlb1.ID, "al-test-cov-al-1_hashdirect")) + Expect(res).To(HaveKeyWithValue(folAlb2.ID, "al-test-cov-al-2_hashdisc")) + }) + + It("resolves multiple folders in a single call", func() { + res, err := repo.GetCoverArtForFolders(ctx, folDirect.ID, folMultiDisc.ID, folEmpty.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(HaveLen(2)) + Expect(res).To(HaveKeyWithValue(folDirect.ID, "al-test-cov-al-1_hashdirect")) + Expect(res).To(HaveKeyWithValue(folMultiDisc.ID, "al-test-cov-al-2_hashdisc")) + }) + }) + }) }) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index d74468940..dd4ac9391 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -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 +} diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index 71d758cfd..f70e7b085 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -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() { diff --git a/server/subsonic/e2e/subsonic_browsing_test.go b/server/subsonic/e2e/subsonic_browsing_test.go index 8aa93ee2e..a67fca69b 100644 --- a/server/subsonic/e2e/subsonic_browsing_test.go +++ b/server/subsonic/e2e/subsonic_browsing_test.go @@ -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")) }) }) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 55b1b213e..eb6b46051 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -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) { diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 69fda4681..646312ee6 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -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")) + }) + }) }) + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 51d0020b2..77746836b 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -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"` } diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 392a4d8e0..8ea5ffa99 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -39,6 +39,7 @@ type MockMediaFileRepo struct { MatchesCriteriaValue bool MatchesCriteriaErr error BookmarksAdded []string + GetAllFn func(qo ...model.QueryOptions) (model.MediaFiles, error) } func (m *MockMediaFileRepo) SetError(err bool) { @@ -104,6 +105,9 @@ func (m *MockMediaFileRepo) GetAll(_ context.Context, qo ...model.QueryOptions) if len(qo) > 0 { m.Options = qo[0] } + if m.GetAllFn != nil { + return m.GetAllFn(qo...) + } if m.Err { return nil, errors.New("error") }