This commit is contained in:
wiz78 2026-10-06 08:08:06 -07:00 • committed by GitHub
commit 84b15cd25c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1328 additions and 23 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {

View file

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

View file

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

View file

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

View file

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

View file

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