mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge 0c988edfc00803579264ac3dab0fe9df0dd6eeab into 7e16b6acb5c11e283fcd320a0abb82372a8ab0dd
This commit is contained in:
commit
4b4df1e31c
@ -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")
|
||||
|
||||
@ -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:
|
||||
|
||||
64
core/artwork/reader_folder.go
Normal file
64
core/artwork/reader_folder.go
Normal 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...)
|
||||
}
|
||||
134
core/artwork/reader_folder_test.go
Normal file
134
core/artwork/reader_folder_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
66
model/get_entity_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
17
server/subsonic/filter/filter_suite_test.go
Normal file
17
server/subsonic/filter/filter_suite_test.go
Normal 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")
|
||||
}
|
||||
@ -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",
|
||||
|
||||
32
server/subsonic/filter/filters_test.go
Normal file
32
server/subsonic/filter/filters_test.go
Normal 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}))
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
55
tests/mock_folder_repo.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user