diff --git a/conf/configuration.go b/conf/configuration.go index 1c4829d82..251bb0248 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -168,6 +168,7 @@ type subsonicOptions struct { ArtistParticipations bool DefaultReportRealPath bool EnableAverageRating bool + FolderBrowsing bool LegacyClients string MinimalClients string } @@ -815,6 +816,7 @@ func setViperDefaults() { viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.defaultreportrealpath", false) viper.SetDefault("subsonic.enableaveragerating", true) + viper.SetDefault("subsonic.folderbrowsing", true) viper.SetDefault("subsonic.legacyclients", "DSub") viper.SetDefault("subsonic.minimalclients", "SubMusic") viper.SetDefault("agents", "deezer,lastfm,listenbrainz") diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index b8c395c12..979dc9012 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -124,6 +124,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s artReader, err = newPlaylistArtworkReader(ctx, a, artID) case model.KindDiscArtwork: artReader, err = newDiscArtworkReader(ctx, a, artID) + case model.KindFolderArtwork: + artReader, err = newFolderArtworkReader(ctx, a, artID) case model.KindRadioArtwork: artReader, err = newRadioArtworkReader(ctx, a, artID) default: diff --git a/core/artwork/reader_folder.go b/core/artwork/reader_folder.go new file mode 100644 index 000000000..63d8bfeba --- /dev/null +++ b/core/artwork/reader_folder.go @@ -0,0 +1,64 @@ +package artwork + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" +) + +type folderArtworkReader struct { + cacheKey + folder model.Folder + imgFiles []string +} + +func newFolderArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*folderArtworkReader, error) { + folder, err := a.ds.Folder(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + + absPath := folder.AbsolutePath() + imgFiles := make([]string, len(folder.ImageFiles)) + for i, img := range folder.ImageFiles { + imgFiles[i] = filepath.Join(absPath, img) + } + + r := &folderArtworkReader{ + folder: *folder, + imgFiles: imgFiles, + } + r.cacheKey.artID = artID + r.cacheKey.lastUpdate = folder.ImagesUpdatedAt + return r, nil +} + +func (f *folderArtworkReader) Key() string { + hash := md5.Sum([]byte(conf.Server.CoverArtPriority)) + return fmt.Sprintf("%s.%x", f.cacheKey.Key(), hash) +} + +func (f *folderArtworkReader) LastUpdated() time.Time { + return f.folder.ImagesUpdatedAt +} + +func (f *folderArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + var ff []sourceFunc + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded" || pattern == "external": + // Folders have no embedded tags and no external artwork sources + case len(f.imgFiles) > 0: + ff = append(ff, fromExternalFile(ctx, f.imgFiles, pattern)) + } + } + return selectImageReader(ctx, f.cacheKey.artID, ff...) +} diff --git a/core/artwork/reader_folder_test.go b/core/artwork/reader_folder_test.go new file mode 100644 index 000000000..edaa44e81 --- /dev/null +++ b/core/artwork/reader_folder_test.go @@ -0,0 +1,134 @@ +package artwork + +import ( + "context" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("folderArtworkReader", func() { + var ( + ctx context.Context + a *artwork + tmpDir string + folderRepo *fakeFolderRepo + folder model.Folder + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + tmpDir = GinkgoT().TempDir() + conf.Server.CoverArtPriority = "cover.*, front.*, *" + + folderRepo = &fakeFolderRepo{} + ds := &fakeDataStore{folderRepo: folderRepo} + a = &artwork{ds: ds} + + folder = model.Folder{ + ID: "folder-1", + LibraryPath: tmpDir, + Path: ".", + Name: "Jazz", + ImageFiles: []string{"cover.jpg"}, + ImagesUpdatedAt: time.Now().Truncate(time.Second), + } + }) + + createImage := func(name string) { + fullPath := filepath.Join(folder.AbsolutePath(), name) + Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) + Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) + } + + Describe("newFolderArtworkReader", func() { + It("returns a reader when the folder is found", func() { + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + }) + + It("returns an error when the folder is not found", func() { + artID := model.NewArtworkID(model.KindFolderArtwork, "missing", nil) + _, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("builds absolute image file paths from folder.ImageFiles", func() { + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + Expect(reader.imgFiles).To(ConsistOf( + filepath.Join(folder.AbsolutePath(), "cover.jpg"), + )) + }) + + It("uses ImagesUpdatedAt as the cache key lastUpdate", func() { + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + Expect(reader.LastUpdated()).To(Equal(folder.ImagesUpdatedAt)) + }) + }) + + Describe("Reader", func() { + It("returns the matching image file", func() { + createImage("cover.jpg") + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + rc, path, err := reader.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(rc).ToNot(BeNil()) + Expect(path).To(ContainSubstring("cover.jpg")) + rc.Close() + }) + + It("returns ErrUnavailable when no images match the priority patterns", func() { + conf.Server.CoverArtPriority = "cover.*" + folder.ImageFiles = []string{"other.jpg"} + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + _, _, err = reader.Reader(ctx) + Expect(err).To(MatchError(ErrUnavailable)) + }) + + It("skips embedded and external patterns without error", func() { + conf.Server.CoverArtPriority = "embedded, external, cover.*" + createImage("cover.jpg") + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + rc, _, err := reader.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(rc).ToNot(BeNil()) + rc.Close() + }) + + It("returns ErrUnavailable when folder has no images and only external/embedded in priority", func() { + conf.Server.CoverArtPriority = "embedded, external" + folder.ImageFiles = []string{} + folderRepo.parentResult = &folder + artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil) + reader, err := newFolderArtworkReader(ctx, a, artID) + Expect(err).ToNot(HaveOccurred()) + _, _, err = reader.Reader(ctx) + Expect(err).To(MatchError(ErrUnavailable)) + }) + }) +}) diff --git a/model/artwork_id.go b/model/artwork_id.go index 1bd146c1f..8a463f173 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -23,6 +23,7 @@ var ( KindAlbumArtwork = Kind{"al", "album"} KindPlaylistArtwork = Kind{"pl", "playlist"} KindDiscArtwork = Kind{"dc", "disc"} + KindFolderArtwork = Kind{"fo", "folder"} KindRadioArtwork = Kind{"ra", "radio"} ) @@ -32,6 +33,7 @@ var artworkKindMap = map[string]Kind{ KindAlbumArtwork.prefix: KindAlbumArtwork, KindPlaylistArtwork.prefix: KindPlaylistArtwork, KindDiscArtwork.prefix: KindDiscArtwork, + KindFolderArtwork.prefix: KindFolderArtwork, KindRadioArtwork.prefix: KindRadioArtwork, } @@ -142,6 +144,14 @@ func artworkIDFromArtist(ar Artist) ArtworkID { } } +func artworkIDFromFolder(f Folder) ArtworkID { + return ArtworkID{ + Kind: KindFolderArtwork, + ID: f.ID, + LastUpdate: f.ImagesUpdatedAt, + } +} + func artworkIDFromRadio(r Radio) ArtworkID { return ArtworkID{ Kind: KindRadioArtwork, diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index b634e7cbc..24284d447 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -62,6 +62,18 @@ var _ = Describe("ArtworkID", func() { ) }) + Describe("ParseArtworkID - folder kind", func() { + It("parses a folder artwork ID with fo prefix", func() { + now := time.Now() + id := model.NewArtworkID(model.KindFolderArtwork, "folder-id-123", &now) + parsedId, err := model.ParseArtworkID(id.String()) + Expect(err).ToNot(HaveOccurred()) + Expect(parsedId.Kind).To(Equal(model.KindFolderArtwork)) + Expect(parsedId.ID).To(Equal("folder-id-123")) + Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix())) + }) + }) + Describe("ParseArtworkID()", func() { It("parses album artwork ids", func() { id, err := model.ParseArtworkID("al-1234") diff --git a/model/folder.go b/model/folder.go index 7a769735e..f28f507b1 100644 --- a/model/folder.go +++ b/model/folder.go @@ -31,6 +31,14 @@ type Folder struct { CreatedAt time.Time `structs:"created_at"` } +// CoverArtID returns a non-empty ArtworkID only when the folder contains image files. +func (f Folder) CoverArtID() ArtworkID { + if len(f.ImageFiles) == 0 { + return ArtworkID{} + } + return artworkIDFromFolder(f) +} + func (f Folder) AbsolutePath() string { return filepath.Join(f.LibraryPath, f.Path, f.Name) } diff --git a/model/folder_test.go b/model/folder_test.go index 4c1b4c2b7..221cbf611 100644 --- a/model/folder_test.go +++ b/model/folder_test.go @@ -12,6 +12,32 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("Folder.CoverArtID", func() { + It("returns empty ArtworkID when folder has no images", func() { + f := model.Folder{ID: "folder-1"} + Expect(f.CoverArtID()).To(Equal(model.ArtworkID{})) + Expect(f.CoverArtID().String()).To(BeEmpty()) + }) + + It("returns a folder ArtworkID when folder has images", func() { + now := time.Now().Truncate(time.Second) + f := model.Folder{ID: "folder-1", ImageFiles: []string{"cover.jpg"}, ImagesUpdatedAt: now} + artID := f.CoverArtID() + Expect(artID.Kind).To(Equal(model.KindFolderArtwork)) + Expect(artID.ID).To(Equal("folder-1")) + Expect(artID.LastUpdate.Unix()).To(Equal(now.Unix())) + }) + + It("produces a parseable ArtworkID string", func() { + now := time.Now() + f := model.Folder{ID: "folder-1", ImageFiles: []string{"cover.jpg"}, ImagesUpdatedAt: now} + parsed, err := model.ParseArtworkID(f.CoverArtID().String()) + Expect(err).ToNot(HaveOccurred()) + Expect(parsed.Kind).To(Equal(model.KindFolderArtwork)) + Expect(parsed.ID).To(Equal("folder-1")) + }) +}) + var _ = Describe("Folder", func() { var ( lib model.Library diff --git a/model/get_entity.go b/model/get_entity.go index 60972b2e9..c7577f093 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -6,6 +6,10 @@ import ( // TODO: Should the type be encoded in the ID? func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { + f, err := ds.Folder(ctx).Get(id) + if err == nil { + return f, nil + } ar, err := ds.Artist(ctx).Get(id) if err == nil { return ar, nil diff --git a/model/get_entity_test.go b/model/get_entity_test.go new file mode 100644 index 000000000..6fcb91547 --- /dev/null +++ b/model/get_entity_test.go @@ -0,0 +1,66 @@ +package model_test + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetEntityByID", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + ) + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + }) + + It("returns a Folder when found", func() { + folder := model.Folder{ID: "folder-1", Name: "Jazz"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{folder}) + + entity, err := model.GetEntityByID(ctx, ds, "folder-1") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Folder{})) + Expect(entity.(*model.Folder).ID).To(Equal("folder-1")) + }) + + It("returns a Folder before trying Artist for the same ID", func() { + folder := model.Folder{ID: "shared-id", Name: "Folder"} + artist := model.Artist{ID: "shared-id", Name: "Artist"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{folder}) + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{artist}) + + entity, err := model.GetEntityByID(ctx, ds, "shared-id") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Folder{})) + }) + + It("returns an Artist when no Folder matches", func() { + artist := model.Artist{ID: "artist-1", Name: "Kraftwerk"} + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{artist}) + + entity, err := model.GetEntityByID(ctx, ds, "artist-1") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Artist{})) + }) + + It("returns an Album when no Folder or Artist matches", func() { + album := model.Album{ID: "album-1", Name: "Radioactivity"} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + + entity, err := model.GetEntityByID(ctx, ds, "album-1") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Album{})) + }) + + It("returns an error when no entity is found", func() { + _, err := model.GetEntityByID(ctx, ds, "nonexistent") + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 38967832c..bb94a9f74 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -172,7 +172,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { // Check if folder is outdated if folder.isOutdated() { if !p.state.fullScan { - if folder.hasNoFiles() && folder.isNew() { + if folder.hasNoFiles() && folder.isNew() && folder.numSubFolders == 0 { log.Trace(p.ctx, "Scanner: Skipping new folder with no files", "folder", folder.path, "lib", job.lib.Name) continue } diff --git a/server/e2e/subsonic_browsing_test.go b/server/e2e/subsonic_browsing_test.go index 55aeb8e9e..45c385e47 100644 --- a/server/e2e/subsonic_browsing_test.go +++ b/server/e2e/subsonic_browsing_test.go @@ -2,6 +2,7 @@ package e2e import ( "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" @@ -26,7 +27,7 @@ var _ = Describe("Browsing Endpoints", func() { }) Describe("getIndexes", func() { - It("returns artist indexes", func() { + It("returns folder indexes by default", func() { resp := doReq("getIndexes") Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -34,16 +35,31 @@ var _ = Describe("Browsing Endpoints", func() { Expect(resp.Indexes.Index).ToNot(BeEmpty()) }) - It("includes all artists across indexes", func() { + It("includes top-level folders grouped by first letter", func() { resp := doReq("getIndexes") - var allArtistNames []string + var allNames []string for _, idx := range resp.Indexes.Index { for _, a := range idx.Artists { - allArtistNames = append(allArtistNames, a.Name) + allNames = append(allNames, a.Name) } } - Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various")) + Expect(allNames).To(ContainElements("Jazz", "Pop", "Rock", "CJK")) + }) + + It("falls back to artist index when FolderBrowsing is disabled", func() { + conf.Server.Subsonic.FolderBrowsing = false + DeferCleanup(func() { conf.Server.Subsonic.FolderBrowsing = true }) + + resp := doReq("getIndexes") + + var allNames []string + for _, idx := range resp.Indexes.Index { + for _, a := range idx.Artists { + allNames = append(allNames, a.Name) + } + } + Expect(allNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various")) }) }) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 5b9c4f3c9..39d655cfd 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -4,8 +4,12 @@ import ( "context" "errors" "net/http" + "sort" + "strings" "time" + "golang.org/x/text/unicode/norm" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/publicurl" @@ -99,10 +103,18 @@ 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) + var res *responses.Indexes + if conf.Server.Subsonic.FolderBrowsing { + res, err = api.getFolderIndex(r.Context(), musicFolderIds, ifModifiedSince) + } else { + res, err = api.getArtistIndex(r, musicFolderIds, ifModifiedSince) + } if err != nil { return nil, err } @@ -112,6 +124,91 @@ 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 + } + + // Fetch all libraries from the database; user.Libraries in context may be + // empty for admin users (not in user_library table), so we can't rely on it. + allLibraries, err := api.ds.Library(ctx).GetAll() + if err != nil { + log.Error(ctx, "Error retrieving libraries for folder index", err) + return nil, err + } + libIdSet := make(map[int]bool, len(musicFolderIds)) + for _, id := range musicFolderIds { + libIdSet[id] = true + } + var libraries []model.Library + for _, lib := range allLibraries { + 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(norm.NFKD.String(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) @@ -143,6 +240,8 @@ func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, erro var dir *responses.Directory switch v := entity.(type) { + case *model.Folder: + dir, err = api.buildFolderDirectory(ctx, v) case *model.Artist: dir, err = api.buildArtistDirectory(ctx, v) case *model.Album: @@ -290,6 +389,15 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m includeNotPresent := p.BoolOr("includeNotPresent", false) artist, err := api.provider.UpdateArtistInfo(ctx, id, count, includeNotPresent) + if errors.Is(err, model.ErrNotFound) { + // The ID may be a folder ID (e.g. from a folder-browsing client like DSub + // that calls getArtistInfo on any directory ID). Return empty info rather + // than an error so the client degrades gracefully. + if _, folderErr := api.ds.Folder(ctx).Get(id); folderErr == nil { + empty := model.Artists{} + return &responses.ArtistInfoBase{}, &empty, nil + } + } if err != nil { return nil, nil, err } @@ -400,6 +508,31 @@ func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) { return response, nil } +func (api *Router) buildFolderDirectory(ctx context.Context, folder *model.Folder) (*responses.Directory, error) { + dir := &responses.Directory{} + dir.Id = folder.ID + dir.Name = folder.Name + dir.Parent = folder.ParentID + dir.CoverArt = folder.CoverArtID().String() + + childFolders, err := api.ds.Folder(ctx).GetAll(filter.FoldersByParent(folder.ID)) + if err != nil { + return nil, err + } + + mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByFolder(folder.ID)) + if err != nil { + return nil, err + } + + dir.Child = make([]responses.Child, 0, len(childFolders)+len(mfs)) + for _, f := range childFolders { + dir.Child = append(dir.Child, childFromFolder(ctx, f)) + } + dir.Child = append(dir.Child, slice.MapWithArg(mfs, ctx, childFromMediaFile)...) + return dir, nil +} + func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artist) (*responses.Directory, error) { dir := &responses.Directory{} dir.Id = artist.ID diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index b8f510aed..5b123fa5f 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http/httptest" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -60,52 +61,228 @@ 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 - ctx = contextWithUser(ctx, "user-id", 1) + Describe("buildFolderDirectory", func() { + var folder model.Folder - // Create request with musicFolderId=2 (not accessible) - r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil) + BeforeEach(func() { + folder = model.Folder{ + ID: "folder-1", + ParentID: "root-1", + Name: "Jazz", + } + }) + + It("returns a directory with correct id, name and parent", func() { + ctx = contextWithUser(ctx, "user-id", 1) + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{}) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.Id).To(Equal("folder-1")) + Expect(dir.Name).To(Equal("Jazz")) + Expect(dir.Parent).To(Equal("root-1")) + }) + + It("includes child folders as directory entries with IsDir=true", func() { + ctx = contextWithUser(ctx, "user-id", 1) + childFolder := model.Folder{ID: "folder-2", ParentID: "folder-1", Name: "Blues"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{childFolder}) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.Child).To(HaveLen(1)) + Expect(dir.Child[0].Id).To(Equal("folder-2")) + Expect(dir.Child[0].IsDir).To(BeTrue()) + }) + + It("includes media files as directory entries with IsDir=false", func() { + ctx = contextWithUser(ctx, "user-id", 1) + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf-1", Title: "Track 1", FolderID: "folder-1"}, + }) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.Child).To(HaveLen(1)) + Expect(dir.Child[0].Id).To(Equal("mf-1")) + Expect(dir.Child[0].IsDir).To(BeFalse()) + }) + + It("lists child folders before media files", func() { + ctx = contextWithUser(ctx, "user-id", 1) + childFolder := model.Folder{ID: "folder-2", ParentID: "folder-1", Name: "Sub"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{childFolder}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf-1", Title: "Track 1", FolderID: "folder-1"}, + }) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.Child).To(HaveLen(2)) + Expect(dir.Child[0].IsDir).To(BeTrue()) + Expect(dir.Child[1].IsDir).To(BeFalse()) + }) + + It("sets cover art when folder has image files", func() { + ctx = contextWithUser(ctx, "user-id", 1) + folder.ImageFiles = []string{"cover.jpg"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{}) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.CoverArt).ToNot(BeEmpty()) + }) + + It("returns empty cover art when folder has no image files", func() { + ctx = contextWithUser(ctx, "user-id", 1) + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{}) + + dir, err := api.buildFolderDirectory(ctx, &folder) + Expect(err).ToNot(HaveOccurred()) + Expect(dir.CoverArt).To(BeEmpty()) + }) + }) + + Describe("GetMusicDirectory with folder ID", func() { + It("returns directory for a valid folder ID", func() { + ctx = contextWithUser(ctx, "user-id", 1) + folder := model.Folder{ID: "folder-1", ParentID: "root-1", Name: "Rock"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{folder}) + + r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=folder-1", nil) r = r.WithContext(ctx) - // Call endpoint - response, err := api.GetIndexes(r) + response, err := api.GetMusicDirectory(r) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Directory).ToNot(BeNil()) + Expect(response.Directory.Id).To(Equal("folder-1")) + Expect(response.Directory.Name).To(Equal("Rock")) + Expect(response.Directory.Parent).To(Equal("root-1")) + }) - // Should return error due to lack of access + It("returns error for unknown ID", func() { + ctx = contextWithUser(ctx, "user-id", 1) + + r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=nonexistent", nil) + r = r.WithContext(ctx) + + response, err := api.GetMusicDirectory(r) Expect(err).To(HaveOccurred()) Expect(response).To(BeNil()) }) - It("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("includes child folders as IsDir=true entries in the directory", func() { + ctx = contextWithUser(ctx, "user-id", 1) + folder := model.Folder{ID: "folder-1", ParentID: "", Name: "Music"} + child := model.Folder{ID: "folder-2", ParentID: "folder-1", Name: "Jazz"} + ds.Folder(ctx).(*tests.MockFolderRepo).SetData([]model.Folder{folder, child}) - // 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"}, - }) + r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=folder-1", nil) + r = r.WithContext(ctx) - // 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"}, - }) + response, err := api.GetMusicDirectory(r) + Expect(err).ToNot(HaveOccurred()) + // The mock returns all folders; verify child-2 is present and IsDir + Expect(response.Directory.Child).To(ContainElement( + And(HaveField("Id", "folder-2"), HaveField("IsDir", true)), + )) + }) + }) + + Describe("GetIndexes", func() { + var lib1 model.Library + + BeforeEach(func() { + lib1 = model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"} + ds.Library(ctx).(*tests.MockLibraryRepo).SetData(model.Libraries{lib1}) + }) + + It("should return error when musicFolderId is not accessible", func() { + ctx = contextWithUser(ctx, "user-id", 1) + + r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil) + r = r.WithContext(ctx) + + response, err := api.GetIndexes(r) + Expect(err).To(HaveOccurred()) + Expect(response).To(BeNil()) + }) + + 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 - // 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")) + }) + + It("falls back to artist-based index when FolderBrowsing is disabled", func() { + conf.Server.Subsonic.FolderBrowsing = false + DeferCleanup(func() { conf.Server.Subsonic.FolderBrowsing = true }) + + ctx = contextWithUser(ctx, "user-id", 1) + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ + {ID: "a-1", Name: "Kraftwerk"}, + }) + + r := httptest.NewRequest("GET", "/rest/getIndexes", nil) + r = r.WithContext(ctx) + + response, err := api.GetIndexes(r) + Expect(err).ToNot(HaveOccurred()) + Expect(response.Indexes).ToNot(BeNil()) + // Artist-based index returns artist entries, not folder entries + Expect(response.Indexes.Index).ToNot(BeEmpty()) + Expect(response.Indexes.Index[0].Artists[0].Id).To(Equal("a-1")) }) }) diff --git a/server/subsonic/filter/filter_suite_test.go b/server/subsonic/filter/filter_suite_test.go new file mode 100644 index 000000000..e78726182 --- /dev/null +++ b/server/subsonic/filter/filter_suite_test.go @@ -0,0 +1,17 @@ +package filter_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFilter(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Subsonic Filter Suite") +} diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 8ba4f0ff9..a3f8e0ccb 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -90,6 +90,20 @@ func SongsByAlbum(albumId string) Options { }) } +func SongsByFolder(folderID string) Options { + return addDefaultFilters(Options{ + Filters: Eq{"folder_id": folderID}, + Sort: "path", + }) +} + +func FoldersByParent(parentID string) Options { + return Options{ + Filters: And{Eq{"parent_id": parentID}, Eq{"missing": false}}, + Sort: "name", + } +} + func SongsByRandom(genre string, fromYear, toYear int) Options { options := Options{ Sort: "random", diff --git a/server/subsonic/filter/filters_test.go b/server/subsonic/filter/filters_test.go new file mode 100644 index 000000000..83e764333 --- /dev/null +++ b/server/subsonic/filter/filters_test.go @@ -0,0 +1,32 @@ +package filter_test + +import ( + sq "github.com/Masterminds/squirrel" + . "github.com/navidrome/navidrome/server/subsonic/filter" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SongsByFolder", func() { + It("sets sort to path", func() { + opts := SongsByFolder("folder-1") + Expect(opts.Sort).To(Equal("path")) + }) + + It("filters by the given folder ID", func() { + opts := SongsByFolder("folder-1") + Expect(opts.Filters).To(Equal(sq.And{sq.Eq{"missing": false}, sq.Eq{"folder_id": "folder-1"}})) + }) + + It("uses a different folder ID per call", func() { + opts := SongsByFolder("folder-2") + Expect(opts.Filters).To(Equal(sq.And{sq.Eq{"missing": false}, sq.Eq{"folder_id": "folder-2"}})) + }) + + It("excludes missing files via default filter", func() { + opts := SongsByFolder("any-folder") + filters, ok := opts.Filters.(sq.And) + Expect(ok).To(BeTrue()) + Expect(filters).To(ContainElement(sq.Eq{"missing": false})) + }) +}) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 74d57ade4..03fa4806a 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -319,6 +319,17 @@ func sanitizeSlashes(target string) string { return strings.ReplaceAll(target, "/", "_") } +func childFromFolder(_ context.Context, folder model.Folder) responses.Child { + child := responses.Child{} + child.Id = folder.ID + child.Parent = folder.ParentID + child.IsDir = true + child.Title = folder.Name + child.Name = folder.Name + child.CoverArt = folder.CoverArtID().String() + return child +} + // albumCreatedAt returns a best-effort timestamp for the album's `created` // field, which is required by the OpenSubsonic spec but may be zero on legacy // DB rows. Falls back to UpdatedAt → ImportedAt; can still return zero if all diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index abf6116f3..d00f14abd 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -485,6 +485,60 @@ var _ = Describe("helpers", func() { }) }) + Describe("childFromFolder", func() { + var folder model.Folder + var ctx context.Context + + BeforeEach(func() { + folder = model.Folder{ + ID: "folder-1", + ParentID: "parent-1", + Name: "Jazz", + NumAudioFiles: 12, + } + ctx = context.Background() + }) + + It("sets Id from folder ID", func() { + child := childFromFolder(ctx, folder) + Expect(child.Id).To(Equal("folder-1")) + }) + + It("sets Parent from folder ParentID", func() { + child := childFromFolder(ctx, folder) + Expect(child.Parent).To(Equal("parent-1")) + }) + + It("marks the child as a directory", func() { + child := childFromFolder(ctx, folder) + Expect(child.IsDir).To(BeTrue()) + }) + + It("sets Title and Name from folder Name", func() { + child := childFromFolder(ctx, folder) + Expect(child.Title).To(Equal("Jazz")) + Expect(child.Name).To(Equal("Jazz")) + }) + + It("leaves CoverArt empty when folder has no images", func() { + child := childFromFolder(ctx, folder) + Expect(child.CoverArt).To(BeEmpty()) + }) + + It("sets CoverArt when folder has images", func() { + folder.ImageFiles = []string{"cover.jpg"} + child := childFromFolder(ctx, folder) + Expect(child.CoverArt).To(HavePrefix("fo-")) + }) + + It("works for a root folder with no parent", func() { + folder.ParentID = "" + child := childFromFolder(ctx, folder) + Expect(child.Parent).To(BeEmpty()) + Expect(child.IsDir).To(BeTrue()) + }) + }) + Describe("AverageRating in responses", func() { var ctx context.Context diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 754f0c084..26586d8e1 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -54,7 +54,7 @@ func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { if db.RealDS != nil { return db.RealDS.Folder(ctx) } - db.MockedFolder = struct{ model.FolderRepository }{} + db.MockedFolder = CreateMockFolderRepo() return db.MockedFolder } diff --git a/tests/mock_folder_repo.go b/tests/mock_folder_repo.go new file mode 100644 index 000000000..360ce9210 --- /dev/null +++ b/tests/mock_folder_repo.go @@ -0,0 +1,55 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" +) + +func CreateMockFolderRepo() *MockFolderRepo { + return &MockFolderRepo{ + Data: make(map[string]*model.Folder), + } +} + +type MockFolderRepo struct { + model.FolderRepository + Data map[string]*model.Folder + Err bool + Options model.QueryOptions +} + +func (m *MockFolderRepo) SetError(err bool) { + m.Err = err +} + +func (m *MockFolderRepo) SetData(folders []model.Folder) { + m.Data = make(map[string]*model.Folder) + for i, f := range folders { + m.Data[f.ID] = &folders[i] + } +} + +func (m *MockFolderRepo) Get(id string) (*model.Folder, error) { + if m.Err { + return nil, errors.New("Error!") + } + if d, ok := m.Data[id]; ok { + return d, nil + } + return nil, model.ErrNotFound +} + +func (m *MockFolderRepo) GetAll(opts ...model.QueryOptions) ([]model.Folder, error) { + if m.Err { + return nil, errors.New("Error!") + } + if len(opts) > 0 { + m.Options = opts[0] + } + var result []model.Folder + for _, f := range m.Data { + result = append(result, *f) + } + return result, nil +}