Merge 0c988edfc00803579264ac3dab0fe9df0dd6eeab into 7e16b6acb5c11e283fcd320a0abb82372a8ab0dd

This commit is contained in:
Patrik Wallström 2026-05-01 23:29:15 +02:00 committed by GitHub
commit 4b4df1e31c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 875 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

66
model/get_entity_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

55
tests/mock_folder_repo.go Normal file
View File

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