mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Merge 617b76a8fc into 52135913d4
This commit is contained in:
commit
84b15cd25c
10 changed files with 1328 additions and 23 deletions
|
|
@ -99,4 +99,7 @@ type FolderRepository interface {
|
|||
// GetAllWithPlaylists returns all non-missing folders with playlists, ignoring
|
||||
// the scan-timestamp gate used by GetTouchedWithPlaylists.
|
||||
GetAllWithPlaylists(ctx context.Context) (FolderCursor, error)
|
||||
GetSubfoldersWithAudio(ctx context.Context, parentID string, libraryIDs ...int) ([]Folder, error)
|
||||
GetRootSubfoldersWithAudio(ctx context.Context, libraryIDs ...int) ([]Folder, error)
|
||||
GetCoverArtForFolders(ctx context.Context, folderIDs ...string) (map[string]string, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package persistence
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"maps"
|
||||
|
|
@ -298,6 +299,109 @@ func (r folderRepository) GetAllWithPlaylists(ctx context.Context) (model.Folder
|
|||
return wrapFolderCursor(cursor), nil
|
||||
}
|
||||
|
||||
const (
|
||||
folderRelPathExpr = `CASE WHEN folder.path = '' OR folder.path = '.' THEN folder.name ELSE folder.path || '/' || folder.name END`
|
||||
|
||||
folderEscapedRelPathExpr = `REPLACE(REPLACE(REPLACE(` + folderRelPathExpr + `, '\', '\\'), '%', '\%'), '_', '\_')`
|
||||
|
||||
// folderHasAudioCond checks whether a folder has audio directly, or contains descendant subfolders with audio.
|
||||
// Note: The EXISTS subquery performs a prefix check on descendant folders when a direct subfolder has
|
||||
// num_audio_files == 0. Because the outer query restricts rows by parent_id (or root parent_id), the number
|
||||
// of subquery evaluations is strictly bounded by the direct child count.
|
||||
folderHasAudioCond = `(folder.num_audio_files > 0 OR EXISTS (` +
|
||||
`SELECT 1 FROM folder sub ` +
|
||||
`WHERE sub.library_id = folder.library_id ` +
|
||||
`AND sub.missing = 0 ` +
|
||||
`AND sub.num_audio_files > 0 ` +
|
||||
`AND (sub.path = ` + folderRelPathExpr + ` OR sub.path LIKE ` + folderEscapedRelPathExpr + ` || '/%' ESCAPE '\')` +
|
||||
`))`
|
||||
)
|
||||
|
||||
func (r folderRepository) getSubfoldersWithAudio(ctx context.Context, parentCond Sqlizer, libraryIDs ...int) ([]model.Folder, error) {
|
||||
cond := And{
|
||||
parentCond,
|
||||
Eq{"folder.missing": false},
|
||||
ConcatExpr(folderHasAudioCond),
|
||||
}
|
||||
if len(libraryIDs) > 0 {
|
||||
cond = append(cond, Eq{"folder.library_id": libraryIDs})
|
||||
}
|
||||
sq := r.selectFolder(ctx).Where(cond).OrderBy("folder.name COLLATE NOCASE ASC")
|
||||
var res dbFolders
|
||||
err := r.queryAll(ctx, sq, &res)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
func (r folderRepository) GetRootSubfoldersWithAudio(ctx context.Context, libraryIDs ...int) ([]model.Folder, error) {
|
||||
return r.getSubfoldersWithAudio(ctx,
|
||||
ConcatExpr("folder.parent_id IN (SELECT id FROM folder WHERE path = '' AND name = '.')"),
|
||||
libraryIDs...,
|
||||
)
|
||||
}
|
||||
|
||||
func (r folderRepository) GetSubfoldersWithAudio(ctx context.Context, parentID string, libraryIDs ...int) ([]model.Folder, error) {
|
||||
return r.getSubfoldersWithAudio(ctx,
|
||||
Eq{"folder.parent_id": parentID},
|
||||
libraryIDs...,
|
||||
)
|
||||
}
|
||||
|
||||
type folderCoverArtRow struct {
|
||||
FolderID string `db:"folder_id"`
|
||||
AlbumID string `db:"album_id"`
|
||||
ImageHash string `db:"image_hash"`
|
||||
ImageAbsent bool `db:"image_absent"`
|
||||
}
|
||||
|
||||
func (r folderRepository) GetCoverArtForFolders(ctx context.Context, folderIDs ...string) (map[string]string, error) {
|
||||
result := make(map[string]string, len(folderIDs))
|
||||
if len(folderIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for chunk := range slices.Chunk(folderIDs, 200) {
|
||||
sq := Select(
|
||||
"f.id AS folder_id",
|
||||
"al.id AS album_id",
|
||||
"COALESCE(ia.hash, '') AS image_hash",
|
||||
"CASE WHEN ia.item_id IS NOT NULL AND ia.hash = '' THEN 1 ELSE 0 END AS image_absent",
|
||||
).
|
||||
From("folder f").
|
||||
Join("media_file mf ON (mf.folder_id = f.id OR mf.folder_id IN (SELECT d.id FROM folder d WHERE d.parent_id = f.id AND d.missing = 0))").
|
||||
Join("album al ON mf.album_id = al.id").
|
||||
LeftJoin("item_artwork ia ON ia.item_kind = 'al' AND ia.item_id = al.id AND ia.image_type = 'primary'").
|
||||
Where(And{
|
||||
Eq{"f.id": chunk},
|
||||
Eq{"f.missing": false},
|
||||
Eq{"mf.missing": false},
|
||||
}).
|
||||
GroupBy("f.id").
|
||||
Having("f.num_audio_files > 0 OR COUNT(DISTINCT mf.album_id) = 1")
|
||||
|
||||
var rows []folderCoverArtRow
|
||||
err := r.queryAll(ctx, sq, &rows)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.ImageAbsent {
|
||||
continue
|
||||
}
|
||||
artID := model.ArtworkID{
|
||||
Kind: model.KindAlbumArtwork,
|
||||
ID: row.AlbumID,
|
||||
Hash: row.ImageHash,
|
||||
}
|
||||
result[row.FolderID] = artID.String()
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor {
|
||||
return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -388,4 +388,413 @@ var _ = Describe("FolderRepository", func() {
|
|||
Expect(ids).To(ConsistOf(withPls.ID)) // only the non-missing folder with playlists
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetRootSubfoldersWithAudio and GetSubfoldersWithAudio", func() {
|
||||
var (
|
||||
rootFolder, otherRoot *model.Folder
|
||||
rootWithDirect *model.Folder
|
||||
rootWithDesc *model.Folder
|
||||
rootWithDescChild *model.Folder
|
||||
rootWithGrandchild *model.Folder
|
||||
rootWithGrandchildChild *model.Folder
|
||||
rootWithGrandchildGrandchild *model.Folder
|
||||
rootEmpty *model.Folder
|
||||
rootEmptyChild *model.Folder
|
||||
rootMissing *model.Folder
|
||||
rootDescMissing *model.Folder
|
||||
rootDescMissingChild *model.Folder
|
||||
rootSortA, rootSortB, rootSortC *model.Folder
|
||||
rootOtherLib *model.Folder
|
||||
rootSpecialWildcard *model.Folder
|
||||
rootSpecialChild *model.Folder
|
||||
rootSpecialFalseMatch *model.Folder
|
||||
|
||||
parentFolder *model.Folder
|
||||
childDirect *model.Folder
|
||||
childWithDesc *model.Folder
|
||||
childWithDescGrandchild *model.Folder
|
||||
childEmpty *model.Folder
|
||||
childEmptyGrandchild *model.Folder
|
||||
childMissing *model.Folder
|
||||
childDescMissing *model.Folder
|
||||
childDescMissingGrandchild *model.Folder
|
||||
otherParentFolder *model.Folder
|
||||
otherParentChild *model.Folder
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Ensure root folders exist for both libraries
|
||||
rootFolder = model.NewFolder(testLib, ".")
|
||||
Expect(repo.Put(ctx, rootFolder)).To(Succeed())
|
||||
|
||||
otherRoot = model.NewFolder(otherLib, ".")
|
||||
Expect(repo.Put(ctx, otherRoot)).To(Succeed())
|
||||
|
||||
// 1. Top-level folder with direct audio
|
||||
rootWithDirect = model.NewFolder(testLib, "TestAudioRootDirect")
|
||||
rootWithDirect.NumAudioFiles = 3
|
||||
Expect(repo.Put(ctx, rootWithDirect)).To(Succeed())
|
||||
|
||||
// 2. Top-level folder with 0 direct audio, but child has audio
|
||||
rootWithDesc = model.NewFolder(testLib, "TestAudioRootDesc")
|
||||
rootWithDesc.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, rootWithDesc)).To(Succeed())
|
||||
rootWithDescChild = model.NewFolder(testLib, "TestAudioRootDesc/Child")
|
||||
rootWithDescChild.NumAudioFiles = 4
|
||||
Expect(repo.Put(ctx, rootWithDescChild)).To(Succeed())
|
||||
|
||||
// 3. Top-level folder with 0 direct audio, grandchild has audio
|
||||
rootWithGrandchild = model.NewFolder(testLib, "TestAudioRootGrandchild")
|
||||
rootWithGrandchild.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, rootWithGrandchild)).To(Succeed())
|
||||
rootWithGrandchildChild = model.NewFolder(testLib, "TestAudioRootGrandchild/Album")
|
||||
rootWithGrandchildChild.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, rootWithGrandchildChild)).To(Succeed())
|
||||
rootWithGrandchildGrandchild = model.NewFolder(testLib, "TestAudioRootGrandchild/Album/CD1")
|
||||
rootWithGrandchildGrandchild.NumAudioFiles = 2
|
||||
Expect(repo.Put(ctx, rootWithGrandchildGrandchild)).To(Succeed())
|
||||
|
||||
// 4. Empty top-level folder
|
||||
rootEmpty = model.NewFolder(testLib, "TestAudioRootEmpty")
|
||||
rootEmpty.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, rootEmpty)).To(Succeed())
|
||||
rootEmptyChild = model.NewFolder(testLib, "TestAudioRootEmpty/Sub")
|
||||
rootEmptyChild.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, rootEmptyChild)).To(Succeed())
|
||||
|
||||
// 5. Missing top-level folder (has audio directly)
|
||||
rootMissing = model.NewFolder(testLib, "TestAudioRootMissing")
|
||||
rootMissing.NumAudioFiles = 5
|
||||
rootMissing.Missing = true
|
||||
Expect(repo.Put(ctx, rootMissing)).To(Succeed())
|
||||
|
||||
// 6. Top-level folder whose only audio-bearing child is missing
|
||||
rootDescMissing = model.NewFolder(testLib, "TestAudioRootDescMissing")
|
||||
rootDescMissing.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, rootDescMissing)).To(Succeed())
|
||||
rootDescMissingChild = model.NewFolder(testLib, "TestAudioRootDescMissing/Sub")
|
||||
rootDescMissingChild.NumAudioFiles = 3
|
||||
rootDescMissingChild.Missing = true
|
||||
Expect(repo.Put(ctx, rootDescMissingChild)).To(Succeed())
|
||||
|
||||
// 7. Case-insensitive sorting folders
|
||||
rootSortB = model.NewFolder(testLib, "TestAudioSort_b")
|
||||
rootSortB.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, rootSortB)).To(Succeed())
|
||||
rootSortA = model.NewFolder(testLib, "TestAudioSort_A")
|
||||
rootSortA.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, rootSortA)).To(Succeed())
|
||||
rootSortC = model.NewFolder(testLib, "TestAudioSort_c")
|
||||
rootSortC.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, rootSortC)).To(Succeed())
|
||||
|
||||
// 8. Other library folder
|
||||
rootOtherLib = model.NewFolder(otherLib, "TestAudioOtherLibRoot")
|
||||
rootOtherLib.NumAudioFiles = 2
|
||||
Expect(repo.Put(ctx, rootOtherLib)).To(Succeed())
|
||||
|
||||
// 9. Special character escaping: "TestAudio_AC%DC" vs "TestAudio_AC-DC"
|
||||
rootSpecialWildcard = model.NewFolder(testLib, "TestAudio_AC%DC")
|
||||
rootSpecialWildcard.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, rootSpecialWildcard)).To(Succeed())
|
||||
rootSpecialChild = model.NewFolder(testLib, "TestAudio_AC%DC/Album")
|
||||
rootSpecialChild.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, rootSpecialChild)).To(Succeed())
|
||||
|
||||
rootSpecialFalseMatch = model.NewFolder(testLib, "TestAudio_AC-DC")
|
||||
rootSpecialFalseMatch.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, rootSpecialFalseMatch)).To(Succeed())
|
||||
|
||||
// Hierarchy for GetSubfoldersWithAudio
|
||||
parentFolder = model.NewFolder(testLib, "TestAudioSub_Parent")
|
||||
parentFolder.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, parentFolder)).To(Succeed())
|
||||
|
||||
childDirect = model.NewFolder(testLib, "TestAudioSub_Parent/ChildDirect")
|
||||
childDirect.NumAudioFiles = 2
|
||||
Expect(repo.Put(ctx, childDirect)).To(Succeed())
|
||||
|
||||
childWithDesc = model.NewFolder(testLib, "TestAudioSub_Parent/ChildWithDesc")
|
||||
childWithDesc.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, childWithDesc)).To(Succeed())
|
||||
childWithDescGrandchild = model.NewFolder(testLib, "TestAudioSub_Parent/ChildWithDesc/Sub")
|
||||
childWithDescGrandchild.NumAudioFiles = 3
|
||||
Expect(repo.Put(ctx, childWithDescGrandchild)).To(Succeed())
|
||||
|
||||
childEmpty = model.NewFolder(testLib, "TestAudioSub_Parent/ChildEmpty")
|
||||
childEmpty.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, childEmpty)).To(Succeed())
|
||||
childEmptyGrandchild = model.NewFolder(testLib, "TestAudioSub_Parent/ChildEmpty/Sub")
|
||||
childEmptyGrandchild.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, childEmptyGrandchild)).To(Succeed())
|
||||
|
||||
childMissing = model.NewFolder(testLib, "TestAudioSub_Parent/ChildMissing")
|
||||
childMissing.NumAudioFiles = 4
|
||||
childMissing.Missing = true
|
||||
Expect(repo.Put(ctx, childMissing)).To(Succeed())
|
||||
|
||||
childDescMissing = model.NewFolder(testLib, "TestAudioSub_Parent/ChildDescMissing")
|
||||
childDescMissing.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, childDescMissing)).To(Succeed())
|
||||
childDescMissingGrandchild = model.NewFolder(testLib, "TestAudioSub_Parent/ChildDescMissing/Sub")
|
||||
childDescMissingGrandchild.NumAudioFiles = 2
|
||||
childDescMissingGrandchild.Missing = true
|
||||
Expect(repo.Put(ctx, childDescMissingGrandchild)).To(Succeed())
|
||||
|
||||
otherParentFolder = model.NewFolder(testLib, "TestAudioSub_OtherParent")
|
||||
otherParentFolder.NumAudioFiles = 0
|
||||
Expect(repo.Put(ctx, otherParentFolder)).To(Succeed())
|
||||
otherParentChild = model.NewFolder(testLib, "TestAudioSub_OtherParent/OtherChild")
|
||||
otherParentChild.NumAudioFiles = 5
|
||||
Expect(repo.Put(ctx, otherParentChild)).To(Succeed())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_, _ = conn.NewQuery("DELETE FROM folder WHERE name LIKE 'TestAudio%' OR path LIKE 'TestAudio%'").Execute()
|
||||
_, _ = conn.NewQuery("DELETE FROM folder WHERE id = {:id}").Bind(dbx.Params{"id": otherRoot.ID}).Execute()
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetRootSubfoldersWithAudio", func() {
|
||||
It("returns only root subfolders with audio in their subtree, sorted by name NOCASE", func() {
|
||||
folders, err := repo.GetRootSubfoldersWithAudio(ctx, testLib.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ids := slice.Map(folders, func(f model.Folder) string { return f.ID })
|
||||
|
||||
// Should include folders with audio directly or in descendants
|
||||
Expect(ids).To(ContainElements(
|
||||
rootWithDirect.ID,
|
||||
rootWithDesc.ID,
|
||||
rootWithGrandchild.ID,
|
||||
rootSpecialWildcard.ID,
|
||||
))
|
||||
|
||||
// Should exclude empty, missing, or false-wildcard folders
|
||||
Expect(ids).ToNot(ContainElements(
|
||||
rootEmpty.ID,
|
||||
rootMissing.ID,
|
||||
rootDescMissing.ID,
|
||||
rootSpecialFalseMatch.ID,
|
||||
))
|
||||
|
||||
// Should exclude folders from other library when filtering by testLib.ID
|
||||
Expect(ids).ToNot(ContainElement(rootOtherLib.ID))
|
||||
})
|
||||
|
||||
It("filters by libraryIDs when provided", func() {
|
||||
folders, err := repo.GetRootSubfoldersWithAudio(ctx, otherLib.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ids := slice.Map(folders, func(f model.Folder) string { return f.ID })
|
||||
Expect(ids).To(ConsistOf(rootOtherLib.ID))
|
||||
})
|
||||
|
||||
It("sorts results by name COLLATE NOCASE ASC", func() {
|
||||
folders, err := repo.GetRootSubfoldersWithAudio(ctx, testLib.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Filter down to the three sort test folders
|
||||
var sortNames []string
|
||||
for _, f := range folders {
|
||||
if f.ID == rootSortA.ID || f.ID == rootSortB.ID || f.ID == rootSortC.ID {
|
||||
sortNames = append(sortNames, f.Name)
|
||||
}
|
||||
}
|
||||
Expect(sortNames).To(Equal([]string{
|
||||
"TestAudioSort_A",
|
||||
"TestAudioSort_b",
|
||||
"TestAudioSort_c",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSubfoldersWithAudio", func() {
|
||||
It("returns direct children with audio directly or in descendants", func() {
|
||||
children, err := repo.GetSubfoldersWithAudio(ctx, parentFolder.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ids := slice.Map(children, func(f model.Folder) string { return f.ID })
|
||||
|
||||
// Should include child with direct audio and child with descendant audio
|
||||
Expect(ids).To(ConsistOf(childDirect.ID, childWithDesc.ID))
|
||||
|
||||
// Should exclude child of other parent
|
||||
Expect(ids).ToNot(ContainElement(otherParentChild.ID))
|
||||
})
|
||||
|
||||
It("filters by libraryIDs when provided", func() {
|
||||
// Asking with otherLib.ID should return empty slice since parent is in testLib
|
||||
children, err := repo.GetSubfoldersWithAudio(ctx, parentFolder.ID, otherLib.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(children).To(BeEmpty())
|
||||
|
||||
// Asking with testLib.ID should return the matching children
|
||||
children, err = repo.GetSubfoldersWithAudio(ctx, parentFolder.ID, testLib.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ids := slice.Map(children, func(f model.Folder) string { return f.ID })
|
||||
Expect(ids).To(ConsistOf(childDirect.ID, childWithDesc.ID))
|
||||
})
|
||||
|
||||
It("sorts children by name COLLATE NOCASE ASC", func() {
|
||||
sortParent := model.NewFolder(testLib, "TestAudioSortParent")
|
||||
Expect(repo.Put(ctx, sortParent)).To(Succeed())
|
||||
|
||||
childB := model.NewFolder(testLib, "TestAudioSortParent/beta")
|
||||
childB.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, childB)).To(Succeed())
|
||||
|
||||
childA := model.NewFolder(testLib, "TestAudioSortParent/Alpha")
|
||||
childA.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, childA)).To(Succeed())
|
||||
|
||||
childC := model.NewFolder(testLib, "TestAudioSortParent/gamma")
|
||||
childC.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, childC)).To(Succeed())
|
||||
|
||||
children, err := repo.GetSubfoldersWithAudio(ctx, sortParent.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
names := slice.Map(children, func(f model.Folder) string { return f.Name })
|
||||
Expect(names).To(Equal([]string{"Alpha", "beta", "gamma"}))
|
||||
})
|
||||
|
||||
It("returns empty slice when parentID has no children", func() {
|
||||
children, err := repo.GetSubfoldersWithAudio(ctx, "non-existent-id")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(children).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetCoverArtForFolders", func() {
|
||||
var albumRepo model.AlbumRepository
|
||||
var mfRepo model.MediaFileRepository
|
||||
var alDirect, alDisc *model.Album
|
||||
var folDirect, folMultiDisc, folDisc1, folEmpty *model.Folder
|
||||
|
||||
BeforeEach(func() {
|
||||
albumRepo = NewAlbumRepository(conn)
|
||||
mfRepo = NewMediaFileRepository(conn)
|
||||
|
||||
alDirect = &model.Album{
|
||||
ID: "test-cov-al-1",
|
||||
Name: "Direct Album",
|
||||
LibraryID: testLib.ID,
|
||||
ItemImage: model.ItemImage{ImageHash: "hashdirect", ImageAbsent: false},
|
||||
}
|
||||
Expect(albumRepo.Put(ctx, alDirect)).To(Succeed())
|
||||
|
||||
alDisc = &model.Album{
|
||||
ID: "test-cov-al-2",
|
||||
Name: "MultiDisc Album",
|
||||
LibraryID: testLib.ID,
|
||||
ItemImage: model.ItemImage{ImageHash: "hashdisc", ImageAbsent: false},
|
||||
}
|
||||
Expect(albumRepo.Put(ctx, alDisc)).To(Succeed())
|
||||
|
||||
folDirect = model.NewFolder(testLib, "TestCover/Direct")
|
||||
folDirect.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, folDirect)).To(Succeed())
|
||||
|
||||
folMultiDisc = model.NewFolder(testLib, "TestCover/MultiDisc")
|
||||
Expect(repo.Put(ctx, folMultiDisc)).To(Succeed())
|
||||
|
||||
folDisc1 = model.NewFolder(testLib, "TestCover/MultiDisc/CD1")
|
||||
folDisc1.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, folDisc1)).To(Succeed())
|
||||
|
||||
folEmpty = model.NewFolder(testLib, "TestCover/Empty")
|
||||
Expect(repo.Put(ctx, folEmpty)).To(Succeed())
|
||||
|
||||
mfDirect := &model.MediaFile{
|
||||
ID: "test-cov-mf-1",
|
||||
LibraryID: testLib.ID,
|
||||
AlbumID: alDirect.ID,
|
||||
FolderID: folDirect.ID,
|
||||
Path: "TestCover/Direct/01.mp3",
|
||||
}
|
||||
Expect(mfRepo.Put(ctx, mfDirect)).To(Succeed())
|
||||
|
||||
mfDisc := &model.MediaFile{
|
||||
ID: "test-cov-mf-2",
|
||||
LibraryID: testLib.ID,
|
||||
AlbumID: alDisc.ID,
|
||||
FolderID: folDisc1.ID,
|
||||
Path: "TestCover/MultiDisc/CD1/01.mp3",
|
||||
}
|
||||
Expect(mfRepo.Put(ctx, mfDisc)).To(Succeed())
|
||||
|
||||
artworkRepo := NewArtworkRepository(conn)
|
||||
Expect(artworkRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: alDirect.ID, Hash: "hashdirect"})).To(Succeed())
|
||||
Expect(artworkRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: alDisc.ID, Hash: "hashdisc"})).To(Succeed())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_, _ = conn.NewQuery("DELETE FROM media_file WHERE id LIKE 'test-cov-mf-%'").Execute()
|
||||
_, _ = conn.NewQuery("DELETE FROM album WHERE id LIKE 'test-cov-al-%'").Execute()
|
||||
_, _ = conn.NewQuery("DELETE FROM item_artwork WHERE item_id LIKE 'test-cov-al-%'").Execute()
|
||||
})
|
||||
})
|
||||
|
||||
It("returns coverArt for folders with direct audio files", func() {
|
||||
res, err := repo.GetCoverArtForFolders(ctx, folDirect.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(HaveKeyWithValue(folDirect.ID, "al-test-cov-al-1_hashdirect"))
|
||||
})
|
||||
|
||||
It("returns coverArt for multi-disc parent folders from disc subfolders", func() {
|
||||
res, err := repo.GetCoverArtForFolders(ctx, folMultiDisc.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(HaveKeyWithValue(folMultiDisc.ID, "al-test-cov-al-2_hashdisc"))
|
||||
})
|
||||
|
||||
It("does not return coverArt for empty folders", func() {
|
||||
res, err := repo.GetCoverArtForFolders(ctx, folEmpty.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).ToNot(HaveKey(folEmpty.ID))
|
||||
})
|
||||
|
||||
It("does not return coverArt for artist folders containing multiple albums", func() {
|
||||
folArtist := model.NewFolder(testLib, "TestCover/Artist")
|
||||
Expect(repo.Put(ctx, folArtist)).To(Succeed())
|
||||
|
||||
folAlb1 := model.NewFolder(testLib, "TestCover/Artist/Album1")
|
||||
folAlb1.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, folAlb1)).To(Succeed())
|
||||
|
||||
folAlb2 := model.NewFolder(testLib, "TestCover/Artist/Album2")
|
||||
folAlb2.NumAudioFiles = 1
|
||||
Expect(repo.Put(ctx, folAlb2)).To(Succeed())
|
||||
|
||||
mfAlb1 := &model.MediaFile{
|
||||
ID: "test-cov-mf-alb1",
|
||||
LibraryID: testLib.ID,
|
||||
AlbumID: alDirect.ID,
|
||||
FolderID: folAlb1.ID,
|
||||
Path: "TestCover/Artist/Album1/01.mp3",
|
||||
}
|
||||
Expect(mfRepo.Put(ctx, mfAlb1)).To(Succeed())
|
||||
|
||||
mfAlb2 := &model.MediaFile{
|
||||
ID: "test-cov-mf-alb2",
|
||||
LibraryID: testLib.ID,
|
||||
AlbumID: alDisc.ID,
|
||||
FolderID: folAlb2.ID,
|
||||
Path: "TestCover/Artist/Album2/01.mp3",
|
||||
}
|
||||
Expect(mfRepo.Put(ctx, mfAlb2)).To(Succeed())
|
||||
|
||||
res, err := repo.GetCoverArtForFolders(ctx, folArtist.ID, folAlb1.ID, folAlb2.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).ToNot(HaveKey(folArtist.ID))
|
||||
Expect(res).To(HaveKeyWithValue(folAlb1.ID, "al-test-cov-al-1_hashdirect"))
|
||||
Expect(res).To(HaveKeyWithValue(folAlb2.ID, "al-test-cov-al-2_hashdisc"))
|
||||
})
|
||||
|
||||
It("resolves multiple folders in a single call", func() {
|
||||
res, err := repo.GetCoverArtForFolders(ctx, folDirect.ID, folMultiDisc.ID, folEmpty.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(HaveLen(2))
|
||||
Expect(res).To(HaveKeyWithValue(folDirect.ID, "al-test-cov-al-1_hashdirect"))
|
||||
Expect(res).To(HaveKeyWithValue(folMultiDisc.ID, "al-test-cov-al-2_hashdisc"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/publicurl"
|
||||
|
|
@ -15,6 +19,7 @@ import (
|
|||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) {
|
||||
|
|
@ -59,25 +64,6 @@ func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time
|
|||
return indexes, lastScan.UnixMilli(), err
|
||||
}
|
||||
|
||||
func (api *Router) getArtistIndex(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) {
|
||||
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &responses.Indexes{
|
||||
IgnoredArticles: conf.Server.IgnoredArticles,
|
||||
LastModified: modified,
|
||||
}
|
||||
|
||||
res.Index = make([]responses.Index, len(indexes))
|
||||
for i, idx := range indexes {
|
||||
res.Index[i].Name = idx.ID
|
||||
res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtist)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Artists, error) {
|
||||
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
|
||||
if err != nil {
|
||||
|
|
@ -97,15 +83,123 @@ func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSi
|
|||
return res, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
musicFolderIds, _ := selectedMusicFolderIds(r, false)
|
||||
ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{})
|
||||
func folderIndexKey(name string) string {
|
||||
s := str.SanitizeFieldForSortingNoArticle(name)
|
||||
if len(s) == 0 {
|
||||
return "#"
|
||||
}
|
||||
r := []rune(s)[0]
|
||||
if r >= 'a' && r <= 'z' {
|
||||
return string(unicode.ToUpper(r))
|
||||
}
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return string(r)
|
||||
}
|
||||
return "#"
|
||||
}
|
||||
|
||||
res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince)
|
||||
func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
musicFolderIds, err := selectedMusicFolderIds(r, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{})
|
||||
|
||||
lastScanStr, err := api.ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "")
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving last scan start time", err)
|
||||
return nil, err
|
||||
}
|
||||
lastScan := time.Now()
|
||||
if lastScanStr != "" {
|
||||
if t, err := time.Parse(time.RFC3339, lastScanStr); err == nil {
|
||||
lastScan = t
|
||||
}
|
||||
}
|
||||
|
||||
if !lastScan.After(ifModifiedSince) {
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
res := &responses.Indexes{
|
||||
IgnoredArticles: conf.Server.IgnoredArticles,
|
||||
LastModified: lastScan.UnixMilli(),
|
||||
}
|
||||
|
||||
folders, err := api.ds.Folder().GetRootSubfoldersWithAudio(ctx, musicFolderIds...)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving root subfolders", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var folderIDs []string
|
||||
for _, f := range folders {
|
||||
folderIDs = append(folderIDs, f.ID)
|
||||
}
|
||||
coverArtMap, _ := api.ds.Folder().GetCoverArtForFolders(ctx, folderIDs...)
|
||||
|
||||
indexMap := make(map[string][]responses.Artist)
|
||||
for _, f := range folders {
|
||||
key := folderIndexKey(f.Name)
|
||||
artist := responses.Artist{
|
||||
Id: f.ID,
|
||||
Name: f.Name,
|
||||
}
|
||||
if art, ok := coverArtMap[f.ID]; ok && art != "" {
|
||||
artist.CoverArt = art
|
||||
}
|
||||
indexMap[key] = append(indexMap[key], artist)
|
||||
}
|
||||
keys := make([]string, 0, len(indexMap))
|
||||
for k := range indexMap {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for _, k := range keys {
|
||||
res.Index = append(res.Index, responses.Index{
|
||||
Name: k,
|
||||
Artists: indexMap[k],
|
||||
})
|
||||
}
|
||||
|
||||
accessibleLibs := getUserAccessibleLibraries(ctx)
|
||||
libMap := make(map[int]model.Library, len(accessibleLibs))
|
||||
for _, lib := range accessibleLibs {
|
||||
libMap[lib.ID] = lib
|
||||
}
|
||||
|
||||
for _, libID := range musicFolderIds {
|
||||
lib, ok := libMap[libID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rootFolder, err := api.ds.Folder().GetByPath(ctx, lib, ".")
|
||||
if err != nil {
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, "Error retrieving root folder", "libraryId", lib.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rootFolder == nil || rootFolder.Missing {
|
||||
continue
|
||||
}
|
||||
mfs, err := api.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"media_file.folder_id": rootFolder.ID},
|
||||
squirrel.Eq{"media_file.missing": false},
|
||||
},
|
||||
Sort: "disc_number, track_number, title",
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving loose tracks", "folderId", rootFolder.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
for _, mf := range mfs {
|
||||
res.Child = append(res.Child, childFromMediaFile(ctx, mf))
|
||||
}
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Indexes = res
|
||||
|
|
@ -130,6 +224,58 @@ func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, erro
|
|||
id, _ := p.String("id")
|
||||
ctx := r.Context()
|
||||
|
||||
// 1. Check if id is a model.Folder
|
||||
folder, err := api.ds.Folder().Get(ctx, id)
|
||||
if err == nil && folder != nil {
|
||||
if folder.Missing {
|
||||
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
|
||||
}
|
||||
dir, err := api.buildFolderDirectory(ctx, folder)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
if dir.Name == "." {
|
||||
for _, lib := range getUserAccessibleLibraries(ctx) {
|
||||
if lib.ID == folder.LibraryID {
|
||||
dir.Name = lib.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if dir.Name == "." {
|
||||
if lib, err := api.ds.Library().Get(ctx, folder.LibraryID); err == nil && lib != nil {
|
||||
dir.Name = lib.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
response := newResponse()
|
||||
response.Directory = dir
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// 2. Check if id is an accessible Library.ID
|
||||
if libID, err := strconv.Atoi(id); err == nil {
|
||||
accessibleLibs := getUserAccessibleLibraries(ctx)
|
||||
for _, lib := range accessibleLibs {
|
||||
if lib.ID == libID {
|
||||
rootFolder, err := api.ds.Folder().GetByPath(ctx, lib, ".")
|
||||
if err != nil || rootFolder == nil || rootFolder.Missing {
|
||||
return nil, newError(responses.ErrorDataNotFound, "Directory not found")
|
||||
}
|
||||
dir, err := api.buildFolderDirectory(ctx, rootFolder)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return nil, err
|
||||
}
|
||||
dir.Name = lib.Name
|
||||
response := newResponse()
|
||||
response.Directory = dir
|
||||
return response, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to existing model.GetEntityByID (*model.Artist and *model.Album)
|
||||
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(r, "Requested ID not found ", "id", id)
|
||||
|
|
@ -478,3 +624,54 @@ func (api *Router) buildAlbum(ctx context.Context, album *model.Album, mfs model
|
|||
dir.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile)
|
||||
return dir
|
||||
}
|
||||
|
||||
func (api *Router) buildFolderDirectory(ctx context.Context, folder *model.Folder) (*responses.Directory, error) {
|
||||
dir := &responses.Directory{}
|
||||
dir.Id = folder.ID
|
||||
dir.Name = folder.Name
|
||||
if folder.ParentID != "" {
|
||||
dir.Parent = folder.ParentID
|
||||
}
|
||||
|
||||
subfolders, err := api.ds.Folder().GetSubfoldersWithAudio(ctx, folder.ID, folder.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
songs, err := api.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"media_file.folder_id": folder.ID},
|
||||
squirrel.Eq{"media_file.missing": false},
|
||||
},
|
||||
Sort: "disc_number, track_number, title",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(songs) > 0 {
|
||||
if album, err := api.ds.Album().Get(ctx, songs[0].AlbumID); err == nil && album != nil {
|
||||
dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent)
|
||||
}
|
||||
} else {
|
||||
folderCovers, _ := api.ds.Folder().GetCoverArtForFolders(ctx, folder.ID)
|
||||
if art, ok := folderCovers[folder.ID]; ok {
|
||||
dir.CoverArt = art
|
||||
}
|
||||
}
|
||||
|
||||
var subfolderIDs []string
|
||||
for _, sf := range subfolders {
|
||||
subfolderIDs = append(subfolderIDs, sf.ID)
|
||||
}
|
||||
coverArtMap, _ := api.ds.Folder().GetCoverArtForFolders(ctx, subfolderIDs...)
|
||||
|
||||
for _, sf := range subfolders {
|
||||
dir.Child = append(dir.Child, childFromFolder(ctx, sf, coverArtMap[sf.ID]))
|
||||
}
|
||||
for _, song := range songs {
|
||||
dir.Child = append(dir.Child, childFromMediaFile(ctx, song))
|
||||
}
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,115 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type mockFolderRepo struct {
|
||||
model.FolderRepository
|
||||
folders map[string]*model.Folder
|
||||
byPath map[string]*model.Folder
|
||||
getRootSubfoldersFunc func(libraryIDs ...int) ([]model.Folder, error)
|
||||
getSubfoldersFunc func(parentID string, libraryIDs ...int) ([]model.Folder, error)
|
||||
}
|
||||
|
||||
func newMockFolderRepo() *mockFolderRepo {
|
||||
return &mockFolderRepo{
|
||||
folders: make(map[string]*model.Folder),
|
||||
byPath: make(map[string]*model.Folder),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockFolderRepo) SetFolders(folders ...model.Folder) {
|
||||
for i, f := range folders {
|
||||
m.folders[f.ID] = &folders[i]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockFolderRepo) SetByPath(lib model.Library, p string, folder *model.Folder) {
|
||||
key := fmt.Sprintf("%d:%s", lib.ID, p)
|
||||
m.byPath[key] = folder
|
||||
}
|
||||
|
||||
func (m *mockFolderRepo) Get(ctx context.Context, id string) (*model.Folder, error) {
|
||||
if f, ok := m.folders[id]; ok {
|
||||
return f, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockFolderRepo) GetByPath(ctx context.Context, lib model.Library, p string) (*model.Folder, error) {
|
||||
key := fmt.Sprintf("%d:%s", lib.ID, p)
|
||||
if f, ok := m.byPath[key]; ok {
|
||||
return f, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockFolderRepo) GetRootSubfoldersWithAudio(ctx context.Context, libraryIDs ...int) ([]model.Folder, error) {
|
||||
if m.getRootSubfoldersFunc != nil {
|
||||
return m.getRootSubfoldersFunc(libraryIDs...)
|
||||
}
|
||||
var res []model.Folder
|
||||
for _, f := range m.folders {
|
||||
if (f.ParentID == "" || f.ParentID == "root-1" || f.ParentID == "root-2" || f.ParentID == "root-3") && f.Name != "." && !f.Missing {
|
||||
if len(libraryIDs) == 0 || slices.Contains(libraryIDs, f.LibraryID) {
|
||||
res = append(res, *f)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(res, func(a, b model.Folder) int {
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *mockFolderRepo) GetSubfoldersWithAudio(ctx context.Context, parentID string, libraryIDs ...int) ([]model.Folder, error) {
|
||||
if m.getSubfoldersFunc != nil {
|
||||
return m.getSubfoldersFunc(parentID, libraryIDs...)
|
||||
}
|
||||
var res []model.Folder
|
||||
for _, f := range m.folders {
|
||||
if f.ParentID == parentID && !f.Missing {
|
||||
if len(libraryIDs) == 0 || slices.Contains(libraryIDs, f.LibraryID) {
|
||||
res = append(res, *f)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(res, func(a, b model.Folder) int {
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *mockFolderRepo) GetCoverArtForFolders(ctx context.Context, folderIDs ...string) (map[string]string, error) {
|
||||
res := make(map[string]string)
|
||||
for _, id := range folderIDs {
|
||||
if f, ok := m.folders[id]; ok && f.NumAudioFiles > 0 {
|
||||
res[id] = "al-" + id
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func contextWithUser(ctx context.Context, userID string, libraryIDs ...int) context.Context {
|
||||
libraries := make([]model.Library, len(libraryIDs))
|
||||
for i, id := range libraryIDs {
|
||||
|
|
@ -30,12 +126,78 @@ var _ = Describe("Browsing", func() {
|
|||
var api *Router
|
||||
var ctx context.Context
|
||||
var ds model.DataStore
|
||||
var folderRepo *mockFolderRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
mockDS := &tests.MockDataStore{}
|
||||
folderRepo = newMockFolderRepo()
|
||||
mockDS.MockedFolder = folderRepo
|
||||
ds = mockDS
|
||||
auth.Init(ds)
|
||||
api = &Router{ds: ds}
|
||||
ctx = context.Background()
|
||||
|
||||
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mockMF.GetAllFn = func(qo ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
if len(qo) > 0 && qo[0].Filters != nil {
|
||||
if and, ok := qo[0].Filters.(squirrel.And); ok {
|
||||
for _, cond := range and {
|
||||
if eq, ok := cond.(squirrel.Eq); ok {
|
||||
if folderID, ok := eq["media_file.folder_id"].(string); ok {
|
||||
var res model.MediaFiles
|
||||
for _, mf := range mockMF.Data {
|
||||
if mf.FolderID == folderID && !mf.Missing {
|
||||
res = append(res, *mf)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(res, func(a, b model.MediaFile) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(a.DiscNumber, b.DiscNumber),
|
||||
cmp.Compare(a.TrackNumber, b.TrackNumber),
|
||||
cmp.Compare(a.Title, b.Title),
|
||||
)
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if eq, ok := qo[0].Filters.(squirrel.Eq); ok {
|
||||
if albumID, ok := eq["album_id"].(string); ok {
|
||||
var res model.MediaFiles
|
||||
for _, mf := range mockMF.Data {
|
||||
if mf.AlbumID == albumID && !mf.Missing {
|
||||
res = append(res, *mf)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
if and, ok := qo[0].Filters.(squirrel.And); ok {
|
||||
for _, cond := range and {
|
||||
if eq, ok := cond.(squirrel.Eq); ok {
|
||||
if albumID, ok := eq["album_id"].(string); ok {
|
||||
var res model.MediaFiles
|
||||
for _, mf := range mockMF.Data {
|
||||
if mf.AlbumID == albumID && !mf.Missing {
|
||||
res = append(res, *mf)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var res model.MediaFiles
|
||||
for _, mf := range mockMF.Data {
|
||||
res = append(res, *mf)
|
||||
}
|
||||
slices.SortFunc(res, func(a, b model.MediaFile) int {
|
||||
return cmp.Compare(a.ID, b.ID)
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
})
|
||||
|
||||
Describe("GetMusicFolders", func() {
|
||||
|
|
@ -108,6 +270,123 @@ var _ = Describe("Browsing", func() {
|
|||
Expect(response).ToNot(BeNil())
|
||||
Expect(response.Indexes).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns top-level folders grouped alphabetically into res.Indexes.Index with artist elements", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
lib1 := model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}
|
||||
|
||||
rootFolder := &model.Folder{ID: "root-1", LibraryID: 1, Path: "", Name: "."}
|
||||
folderRepo.SetFolders(
|
||||
*rootFolder,
|
||||
model.Folder{ID: "f-beatles", LibraryID: 1, Path: "", Name: "The Beatles", ParentID: "root-1"},
|
||||
model.Folder{ID: "f-pop", LibraryID: 1, Path: "", Name: "Pop", ParentID: "root-1"},
|
||||
model.Folder{ID: "f-1984", LibraryID: 1, Path: "", Name: "1984", ParentID: "root-1"},
|
||||
)
|
||||
folderRepo.SetByPath(lib1, ".", rootFolder)
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=1", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetIndexes(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Indexes).ToNot(BeNil())
|
||||
|
||||
indexNames := slice.Map(resp.Indexes.Index, func(idx responses.Index) string { return idx.Name })
|
||||
Expect(indexNames).To(Equal([]string{"#", "B", "P"}))
|
||||
|
||||
Expect(resp.Indexes.Index[0].Artists).To(ConsistOf(responses.Artist{Id: "f-1984", Name: "1984"}))
|
||||
Expect(resp.Indexes.Index[1].Artists).To(ConsistOf(responses.Artist{Id: "f-beatles", Name: "The Beatles"}))
|
||||
Expect(resp.Indexes.Index[2].Artists).To(ConsistOf(responses.Artist{Id: "f-pop", Name: "Pop"}))
|
||||
})
|
||||
|
||||
It("returns loose root tracks in res.Indexes.Child", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
lib1 := model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}
|
||||
|
||||
rootFolder := &model.Folder{ID: "root-1", LibraryID: 1, Path: "", Name: "."}
|
||||
folderRepo.SetFolders(*rootFolder)
|
||||
folderRepo.SetByPath(lib1, ".", rootFolder)
|
||||
|
||||
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mockMF.SetData(model.MediaFiles{
|
||||
{ID: "track-root-1", Title: "Root Song 1", FolderID: "root-1", TrackNumber: 1},
|
||||
{ID: "track-root-2", Title: "Root Song 2", FolderID: "root-1", TrackNumber: 2},
|
||||
{ID: "track-sub", Title: "Sub Song", FolderID: "sub-folder", TrackNumber: 1},
|
||||
})
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=1", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetIndexes(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Indexes).ToNot(BeNil())
|
||||
Expect(resp.Indexes.Child).To(HaveLen(2))
|
||||
Expect(resp.Indexes.Child[0].Id).To(Equal("track-root-1"))
|
||||
Expect(resp.Indexes.Child[0].Title).To(Equal("Root Song 1"))
|
||||
Expect(resp.Indexes.Child[0].IsDir).To(BeFalse())
|
||||
Expect(resp.Indexes.Child[1].Id).To(Equal("track-root-2"))
|
||||
Expect(resp.Indexes.Child[1].Title).To(Equal("Root Song 2"))
|
||||
})
|
||||
|
||||
It("honors musicFolderId query parameter", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1, 2)
|
||||
lib1 := model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}
|
||||
lib2 := model.Library{ID: 2, Name: "Test Library 2", Path: "/music/library2"}
|
||||
|
||||
rootFolder1 := &model.Folder{ID: "root-1", LibraryID: 1, Path: "", Name: "."}
|
||||
rootFolder2 := &model.Folder{ID: "root-2", LibraryID: 2, Path: "", Name: "."}
|
||||
folderRepo.SetFolders(
|
||||
*rootFolder1,
|
||||
*rootFolder2,
|
||||
model.Folder{ID: "f-lib1", LibraryID: 1, Path: "", Name: "Alpha", ParentID: "root-1"},
|
||||
model.Folder{ID: "f-lib2", LibraryID: 2, Path: "", Name: "Beta", ParentID: "root-2"},
|
||||
)
|
||||
folderRepo.SetByPath(lib1, ".", rootFolder1)
|
||||
folderRepo.SetByPath(lib2, ".", rootFolder2)
|
||||
|
||||
// Query musicFolderId=1 only
|
||||
r1 := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=1", nil)
|
||||
r1 = r1.WithContext(ctx)
|
||||
resp1, err := api.GetIndexes(r1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp1.Indexes.Index).To(HaveLen(1))
|
||||
Expect(resp1.Indexes.Index[0].Name).To(Equal("A"))
|
||||
Expect(resp1.Indexes.Index[0].Artists[0].Name).To(Equal("Alpha"))
|
||||
|
||||
// Query musicFolderId=2 only
|
||||
r2 := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil)
|
||||
r2 = r2.WithContext(ctx)
|
||||
resp2, err := api.GetIndexes(r2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp2.Indexes.Index).To(HaveLen(1))
|
||||
Expect(resp2.Indexes.Index[0].Name).To(Equal("B"))
|
||||
Expect(resp2.Indexes.Index[0].Artists[0].Name).To(Equal("Beta"))
|
||||
})
|
||||
|
||||
It("returns empty response without indexes when ifModifiedSince is after last scan timestamp", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
lib1 := model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}
|
||||
rootFolder := &model.Folder{ID: "root-1", LibraryID: 1, Path: "", Name: "."}
|
||||
folderRepo.SetFolders(
|
||||
*rootFolder,
|
||||
model.Folder{ID: "f-beatles", LibraryID: 1, Path: "", Name: "The Beatles", ParentID: "root-1"},
|
||||
)
|
||||
folderRepo.SetByPath(lib1, ".", rootFolder)
|
||||
|
||||
scanTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
_ = ds.Property().Put(ctx, consts.LastScanStartTimeKey, scanTime.Format(time.RFC3339))
|
||||
|
||||
ifModifiedSince := scanTime.Add(time.Hour).UnixMilli()
|
||||
r := httptest.NewRequest("GET", fmt.Sprintf("/rest/getIndexes?musicFolderId=1&ifModifiedSince=%d", ifModifiedSince), nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetIndexes(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Indexes).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtists", func() {
|
||||
|
|
@ -157,6 +436,239 @@ var _ = Describe("Browsing", func() {
|
|||
Expect(response).ToNot(BeNil())
|
||||
Expect(response.Artist).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("still returns tag-based artist index unchanged", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
mockArtistRepo := ds.Artist().(*tests.MockArtistRepo)
|
||||
mockArtistRepo.SetData(model.Artists{
|
||||
{ID: "artist-1", Name: "Pink Floyd"},
|
||||
})
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getArtists", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetArtists(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Artist).ToNot(BeNil())
|
||||
Expect(resp.Artist.Index).ToNot(BeEmpty())
|
||||
Expect(resp.Artist.Index[0].Artists[0].Name).To(Equal("Pink Floyd"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetMusicDirectory", func() {
|
||||
It("returns directory with subfolders and tracks when called with folder ID", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
parentFolder := &model.Folder{ID: "f-rock", LibraryID: 1, Name: "Rock", ParentID: "root-1"}
|
||||
subFolder := model.Folder{ID: "f-beatles", LibraryID: 1, Name: "The Beatles", ParentID: "f-rock"}
|
||||
folderRepo.SetFolders(*parentFolder, subFolder)
|
||||
|
||||
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mockMF.SetData(model.MediaFiles{
|
||||
{ID: "song-rock-1", Title: "Rock Song 1", FolderID: "f-rock", TrackNumber: 1},
|
||||
})
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=f-rock", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetMusicDirectory(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Directory).ToNot(BeNil())
|
||||
Expect(resp.Directory.Id).To(Equal("f-rock"))
|
||||
Expect(resp.Directory.Name).To(Equal("Rock"))
|
||||
Expect(resp.Directory.Parent).To(Equal("root-1"))
|
||||
Expect(resp.Directory.Child).To(HaveLen(2))
|
||||
Expect(resp.Directory.Child[0].Id).To(Equal("f-beatles"))
|
||||
Expect(resp.Directory.Child[0].IsDir).To(BeTrue())
|
||||
Expect(resp.Directory.Child[1].Id).To(Equal("song-rock-1"))
|
||||
Expect(resp.Directory.Child[1].IsDir).To(BeFalse())
|
||||
})
|
||||
|
||||
It("populates coverArt for directory and child subfolders", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
mockAlbumRepo := ds.Album().(*tests.MockAlbumRepo)
|
||||
mockAlbumRepo.SetData(model.Albums{
|
||||
{ID: "alb-rock", Name: "Rock Album", ItemImage: model.ItemImage{ImageHash: "rockhash"}},
|
||||
})
|
||||
|
||||
parentFolder := &model.Folder{ID: "f-artist", LibraryID: 1, Name: "10,000 Maniacs", ParentID: "root-1"}
|
||||
subFolder := model.Folder{ID: "f-album", LibraryID: 1, Name: "MTV Unplugged", ParentID: "f-artist", NumAudioFiles: 10}
|
||||
folderRepo.SetFolders(*parentFolder, subFolder)
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=f-artist", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetMusicDirectory(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Directory).ToNot(BeNil())
|
||||
Expect(resp.Directory.Child).To(HaveLen(1))
|
||||
Expect(resp.Directory.Child[0].Id).To(Equal("f-album"))
|
||||
Expect(resp.Directory.Child[0].IsDir).To(BeTrue())
|
||||
Expect(resp.Directory.Child[0].CoverArt).To(Equal("al-f-album"))
|
||||
|
||||
// Now test requesting the album folder directly
|
||||
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mockMF.SetData(model.MediaFiles{
|
||||
{ID: "song-1", Title: "Song 1", FolderID: "f-album", AlbumID: "alb-rock", TrackNumber: 1},
|
||||
})
|
||||
|
||||
rAlbum := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=f-album", nil)
|
||||
rAlbum = rAlbum.WithContext(ctx)
|
||||
|
||||
respAlbum, err := api.GetMusicDirectory(rAlbum)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(respAlbum.Directory).ToNot(BeNil())
|
||||
Expect(respAlbum.Directory.CoverArt).To(Equal("al-alb-rock_rockhash"))
|
||||
})
|
||||
|
||||
It("resolves library root folder and returns its contents when called with library ID string", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
lib1 := model.Library{ID: 1, Name: "Test Library 1", Path: "/music/library1"}
|
||||
rootFolder := &model.Folder{ID: "root-1", LibraryID: 1, Name: ".", Path: ""}
|
||||
subFolder := model.Folder{ID: "f-jazz", LibraryID: 1, Name: "Jazz", ParentID: "root-1"}
|
||||
folderRepo.SetFolders(*rootFolder, subFolder)
|
||||
folderRepo.SetByPath(lib1, ".", rootFolder)
|
||||
|
||||
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mockMF.SetData(model.MediaFiles{
|
||||
{ID: "loose-1", Title: "Loose Song", FolderID: "root-1", TrackNumber: 1},
|
||||
})
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=1", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetMusicDirectory(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Directory).ToNot(BeNil())
|
||||
Expect(resp.Directory.Id).To(Equal("root-1"))
|
||||
Expect(resp.Directory.Name).To(Equal("Test Library 1"))
|
||||
Expect(resp.Directory.Child).To(HaveLen(2))
|
||||
Expect(resp.Directory.Child[0].Id).To(Equal("f-jazz"))
|
||||
Expect(resp.Directory.Child[0].IsDir).To(BeTrue())
|
||||
Expect(resp.Directory.Child[1].Id).To(Equal("loose-1"))
|
||||
Expect(resp.Directory.Child[1].IsDir).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns artist albums when called with Artist ID (fallback)", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
mockArtistRepo := ds.Artist().(*tests.MockArtistRepo)
|
||||
mockArtistRepo.SetData(model.Artists{
|
||||
{ID: "art-1", Name: "Pink Floyd"},
|
||||
})
|
||||
mockAlbumRepo := ds.Album().(*tests.MockAlbumRepo)
|
||||
mockAlbumRepo.SetData(model.Albums{
|
||||
{ID: "alb-1", Name: "The Wall", AlbumArtistID: "art-1"},
|
||||
})
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=art-1", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetMusicDirectory(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Directory).ToNot(BeNil())
|
||||
Expect(resp.Directory.Id).To(Equal("art-1"))
|
||||
Expect(resp.Directory.Name).To(Equal("Pink Floyd"))
|
||||
Expect(resp.Directory.Child).To(HaveLen(1))
|
||||
Expect(resp.Directory.Child[0].Id).To(Equal("alb-1"))
|
||||
Expect(resp.Directory.Child[0].IsDir).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns album tracks when called with Album ID (fallback)", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
mockAlbumRepo := ds.Album().(*tests.MockAlbumRepo)
|
||||
mockAlbumRepo.SetData(model.Albums{
|
||||
{ID: "alb-1", Name: "The Dark Side of the Moon", AlbumArtistID: "art-1"},
|
||||
})
|
||||
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mockMF.SetData(model.MediaFiles{
|
||||
{ID: "song-time", Title: "Time", AlbumID: "alb-1", TrackNumber: 4},
|
||||
})
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=alb-1", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetMusicDirectory(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Directory).ToNot(BeNil())
|
||||
Expect(resp.Directory.Id).To(Equal("alb-1"))
|
||||
Expect(resp.Directory.Name).To(Equal("The Dark Side of the Moon"))
|
||||
Expect(resp.Directory.Child).To(HaveLen(1))
|
||||
Expect(resp.Directory.Child[0].Id).To(Equal("song-time"))
|
||||
Expect(resp.Directory.Child[0].IsDir).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns ErrorDataNotFound when called with nonexistent ID", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=nonexistent", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetMusicDirectory(r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(resp).To(BeNil())
|
||||
var subErr subError
|
||||
Expect(errors.As(err, &subErr)).To(BeTrue())
|
||||
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
|
||||
})
|
||||
|
||||
It("returns ErrorDataNotFound when called with unauthorized library ID", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=99", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetMusicDirectory(r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(resp).To(BeNil())
|
||||
var subErr subError
|
||||
Expect(errors.As(err, &subErr)).To(BeTrue())
|
||||
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
|
||||
})
|
||||
|
||||
It("returns ErrorDataNotFound when called with a folder that has Missing: true", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
missingFolder := &model.Folder{ID: "f-missing", LibraryID: 1, Name: "Missing Folder", Missing: true}
|
||||
folderRepo.SetFolders(*missingFolder)
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=f-missing", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetMusicDirectory(r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(resp).To(BeNil())
|
||||
var subErr subError
|
||||
Expect(errors.As(err, &subErr)).To(BeTrue())
|
||||
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
|
||||
})
|
||||
|
||||
It("returns songs in a folder sorted by disc_number, track_number, title", func() {
|
||||
ctx = contextWithUser(ctx, "user-id", 1)
|
||||
parentFolder := &model.Folder{ID: "f-multidisc", LibraryID: 1, Name: "Multi Disc Album", ParentID: "root-1"}
|
||||
folderRepo.SetFolders(*parentFolder)
|
||||
|
||||
mockMF := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mockMF.SetData(model.MediaFiles{
|
||||
{ID: "song-d2-t1", Title: "Disc 2 Track 1", FolderID: "f-multidisc", DiscNumber: 2, TrackNumber: 1},
|
||||
{ID: "song-d1-t2", Title: "Disc 1 Track 2", FolderID: "f-multidisc", DiscNumber: 1, TrackNumber: 2},
|
||||
{ID: "song-d1-t1", Title: "Disc 1 Track 1", FolderID: "f-multidisc", DiscNumber: 1, TrackNumber: 1},
|
||||
{ID: "song-d2-t2", Title: "Disc 2 Track 2", FolderID: "f-multidisc", DiscNumber: 2, TrackNumber: 2},
|
||||
})
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getMusicDirectory?id=f-multidisc", nil)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
resp, err := api.GetMusicDirectory(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Directory).ToNot(BeNil())
|
||||
Expect(resp.Directory.Child).To(HaveLen(4))
|
||||
Expect(resp.Directory.Child[0].Id).To(Equal("song-d1-t1"))
|
||||
Expect(resp.Directory.Child[1].Id).To(Equal("song-d1-t2"))
|
||||
Expect(resp.Directory.Child[2].Id).To(Equal("song-d2-t1"))
|
||||
Expect(resp.Directory.Child[3].Id).To(Equal("song-d2-t2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAlbumInfo", func() {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ var _ = Describe("Browsing Endpoints", func() {
|
|||
Expect(resp.Indexes.Index).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("includes all artists across indexes", func() {
|
||||
It("includes all top-level folders across indexes", func() {
|
||||
resp := doReq("getIndexes")
|
||||
|
||||
var allArtistNames []string
|
||||
|
|
@ -52,7 +52,7 @@ var _ = Describe("Browsing Endpoints", func() {
|
|||
allArtistNames = append(allArtistNames, a.Name)
|
||||
}
|
||||
}
|
||||
Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various"))
|
||||
Expect(allArtistNames).To(ContainElements("CJK", "Jazz", "Lyrics", "Pop", "Rock", "Test"))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -400,6 +400,23 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
|||
return child
|
||||
}
|
||||
|
||||
func childFromFolder(ctx context.Context, f model.Folder, coverArt ...string) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = f.ID
|
||||
child.Parent = f.ParentID
|
||||
child.IsDir = true
|
||||
child.Title = f.Name
|
||||
child.Name = f.Name
|
||||
child.Album = f.Name
|
||||
if len(coverArt) > 0 && coverArt[0] != "" {
|
||||
child.CoverArt = coverArt[0]
|
||||
}
|
||||
if !f.CreatedAt.IsZero() {
|
||||
child.Created = &f.CreatedAt
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubsonicChild {
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
|
||||
|
|
|
|||
|
|
@ -855,4 +855,62 @@ var _ = Describe("helpers", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("childFromFolder", func() {
|
||||
var ctx context.Context
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
It("maps folder attributes to responses.Child with IsDir=true", func() {
|
||||
createdAt := time.Date(2023, 10, 15, 12, 0, 0, 0, time.UTC)
|
||||
folder := model.Folder{
|
||||
ID: "folder-123",
|
||||
ParentID: "parent-456",
|
||||
Name: "Rock",
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
child := childFromFolder(ctx, folder)
|
||||
Expect(child.Id).To(Equal("folder-123"))
|
||||
Expect(child.Parent).To(Equal("parent-456"))
|
||||
Expect(child.IsDir).To(BeTrue())
|
||||
Expect(child.Title).To(Equal("Rock"))
|
||||
Expect(child.Name).To(Equal("Rock"))
|
||||
Expect(child.Album).To(Equal("Rock"))
|
||||
Expect(child.Created).NotTo(BeNil())
|
||||
Expect(*child.Created).To(Equal(createdAt))
|
||||
Expect(child.CoverArt).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("leaves CoverArt empty even when folder has image files", func() {
|
||||
folder := model.Folder{
|
||||
ID: "folder-789",
|
||||
Name: "Pop",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
}
|
||||
child := childFromFolder(ctx, folder)
|
||||
Expect(child.CoverArt).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("leaves Created nil when CreatedAt is zero", func() {
|
||||
folder := model.Folder{
|
||||
ID: "folder-abc",
|
||||
Name: "Jazz",
|
||||
CreatedAt: time.Time{},
|
||||
}
|
||||
child := childFromFolder(ctx, folder)
|
||||
Expect(child.Created).To(BeNil())
|
||||
Expect(child.CoverArt).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("sets CoverArt when provided", func() {
|
||||
folder := model.Folder{
|
||||
ID: "folder-art",
|
||||
Name: "Album Folder",
|
||||
}
|
||||
child := childFromFolder(ctx, folder, "al-album123")
|
||||
Expect(child.CoverArt).To(Equal("al-album123"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ type Index struct {
|
|||
|
||||
type Indexes struct {
|
||||
Index []Index `xml:"index" json:"index,omitempty"`
|
||||
Child []Child `xml:"child" json:"child,omitempty"`
|
||||
LastModified int64 `xml:"lastModified,attr" json:"lastModified"`
|
||||
IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ type MockMediaFileRepo struct {
|
|||
MatchesCriteriaValue bool
|
||||
MatchesCriteriaErr error
|
||||
BookmarksAdded []string
|
||||
GetAllFn func(qo ...model.QueryOptions) (model.MediaFiles, error)
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) SetError(err bool) {
|
||||
|
|
@ -104,6 +105,9 @@ func (m *MockMediaFileRepo) GetAll(_ context.Context, qo ...model.QueryOptions)
|
|||
if len(qo) > 0 {
|
||||
m.Options = qo[0]
|
||||
}
|
||||
if m.GetAllFn != nil {
|
||||
return m.GetAllFn(qo...)
|
||||
}
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue