Add folder artwork support and SongsByFolder filter

Signed-off-by: Patrik Wallström <pawal@amplitut.de>
This commit is contained in:
Patrik Wallström 2026-03-15 01:26:33 +01:00
parent 32200abeb9
commit 0addb23bf5
9 changed files with 264 additions and 1 deletions

View File

@ -124,6 +124,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
artReader, err = newPlaylistArtworkReader(ctx, a, artID) artReader, err = newPlaylistArtworkReader(ctx, a, artID)
case model.KindDiscArtwork: case model.KindDiscArtwork:
artReader, err = newDiscArtworkReader(ctx, a, artID) artReader, err = newDiscArtworkReader(ctx, a, artID)
case model.KindFolderArtwork:
artReader, err = newFolderArtworkReader(ctx, a, artID)
default: default:
return nil, ErrUnavailable return nil, ErrUnavailable
} }

View File

@ -0,0 +1,64 @@
package artwork
import (
"context"
"crypto/md5"
"fmt"
"io"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
)
type folderArtworkReader struct {
cacheKey
folder model.Folder
imgFiles []string
}
func newFolderArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*folderArtworkReader, error) {
folder, err := a.ds.Folder(ctx).Get(artID.ID)
if err != nil {
return nil, err
}
absPath := folder.AbsolutePath()
imgFiles := make([]string, len(folder.ImageFiles))
for i, img := range folder.ImageFiles {
imgFiles[i] = filepath.Join(absPath, img)
}
r := &folderArtworkReader{
folder: *folder,
imgFiles: imgFiles,
}
r.cacheKey.artID = artID
r.cacheKey.lastUpdate = folder.ImagesUpdatedAt
return r, nil
}
func (f *folderArtworkReader) Key() string {
hash := md5.Sum([]byte(conf.Server.CoverArtPriority))
return fmt.Sprintf("%s.%x", f.cacheKey.Key(), hash)
}
func (f *folderArtworkReader) LastUpdated() time.Time {
return f.folder.ImagesUpdatedAt
}
func (f *folderArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
var ff []sourceFunc
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded" || pattern == "external":
// Folders have no embedded tags and no external artwork sources
case len(f.imgFiles) > 0:
ff = append(ff, fromExternalFile(ctx, f.imgFiles, pattern))
}
}
return selectImageReader(ctx, f.cacheKey.artID, ff...)
}

View File

@ -0,0 +1,134 @@
package artwork
import (
"context"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("folderArtworkReader", func() {
var (
ctx context.Context
a *artwork
tmpDir string
folderRepo *fakeFolderRepo
folder model.Folder
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background()
tmpDir = GinkgoT().TempDir()
conf.Server.CoverArtPriority = "cover.*, front.*, *"
folderRepo = &fakeFolderRepo{}
ds := &fakeDataStore{folderRepo: folderRepo}
a = &artwork{ds: ds}
folder = model.Folder{
ID: "folder-1",
LibraryPath: tmpDir,
Path: ".",
Name: "Jazz",
ImageFiles: []string{"cover.jpg"},
ImagesUpdatedAt: time.Now().Truncate(time.Second),
}
})
createImage := func(name string) {
fullPath := filepath.Join(folder.AbsolutePath(), name)
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
}
Describe("newFolderArtworkReader", func() {
It("returns a reader when the folder is found", func() {
folderRepo.parentResult = &folder
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
reader, err := newFolderArtworkReader(ctx, a, artID)
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
})
It("returns an error when the folder is not found", func() {
artID := model.NewArtworkID(model.KindFolderArtwork, "missing", nil)
_, err := newFolderArtworkReader(ctx, a, artID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("builds absolute image file paths from folder.ImageFiles", func() {
folderRepo.parentResult = &folder
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
reader, err := newFolderArtworkReader(ctx, a, artID)
Expect(err).ToNot(HaveOccurred())
Expect(reader.imgFiles).To(ConsistOf(
filepath.Join(folder.AbsolutePath(), "cover.jpg"),
))
})
It("uses ImagesUpdatedAt as the cache key lastUpdate", func() {
folderRepo.parentResult = &folder
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
reader, err := newFolderArtworkReader(ctx, a, artID)
Expect(err).ToNot(HaveOccurred())
Expect(reader.LastUpdated()).To(Equal(folder.ImagesUpdatedAt))
})
})
Describe("Reader", func() {
It("returns the matching image file", func() {
createImage("cover.jpg")
folderRepo.parentResult = &folder
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
reader, err := newFolderArtworkReader(ctx, a, artID)
Expect(err).ToNot(HaveOccurred())
rc, path, err := reader.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(rc).ToNot(BeNil())
Expect(path).To(ContainSubstring("cover.jpg"))
rc.Close()
})
It("returns ErrUnavailable when no images match the priority patterns", func() {
conf.Server.CoverArtPriority = "cover.*"
folder.ImageFiles = []string{"other.jpg"}
folderRepo.parentResult = &folder
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
reader, err := newFolderArtworkReader(ctx, a, artID)
Expect(err).ToNot(HaveOccurred())
_, _, err = reader.Reader(ctx)
Expect(err).To(MatchError(ErrUnavailable))
})
It("skips embedded and external patterns without error", func() {
conf.Server.CoverArtPriority = "embedded, external, cover.*"
createImage("cover.jpg")
folderRepo.parentResult = &folder
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
reader, err := newFolderArtworkReader(ctx, a, artID)
Expect(err).ToNot(HaveOccurred())
rc, _, err := reader.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(rc).ToNot(BeNil())
rc.Close()
})
It("returns ErrUnavailable when folder has no images and only external/embedded in priority", func() {
conf.Server.CoverArtPriority = "embedded, external"
folder.ImageFiles = []string{}
folderRepo.parentResult = &folder
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
reader, err := newFolderArtworkReader(ctx, a, artID)
Expect(err).ToNot(HaveOccurred())
_, _, err = reader.Reader(ctx)
Expect(err).To(MatchError(ErrUnavailable))
})
})
})

View File

@ -23,6 +23,7 @@ var (
KindAlbumArtwork = Kind{"al", "album"} KindAlbumArtwork = Kind{"al", "album"}
KindPlaylistArtwork = Kind{"pl", "playlist"} KindPlaylistArtwork = Kind{"pl", "playlist"}
KindDiscArtwork = Kind{"dc", "disc"} KindDiscArtwork = Kind{"dc", "disc"}
KindFolderArtwork = Kind{"fo", "folder"}
) )
var artworkKindMap = map[string]Kind{ var artworkKindMap = map[string]Kind{
@ -31,6 +32,7 @@ var artworkKindMap = map[string]Kind{
KindAlbumArtwork.prefix: KindAlbumArtwork, KindAlbumArtwork.prefix: KindAlbumArtwork,
KindPlaylistArtwork.prefix: KindPlaylistArtwork, KindPlaylistArtwork.prefix: KindPlaylistArtwork,
KindDiscArtwork.prefix: KindDiscArtwork, KindDiscArtwork.prefix: KindDiscArtwork,
KindFolderArtwork.prefix: KindFolderArtwork,
} }
type ArtworkID struct { type ArtworkID struct {
@ -139,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID {
ID: ar.ID, ID: ar.ID,
} }
} }
func artworkIDFromFolder(f Folder) ArtworkID {
return ArtworkID{
Kind: KindFolderArtwork,
ID: f.ID,
LastUpdate: f.ImagesUpdatedAt,
}
}

View File

@ -62,6 +62,18 @@ var _ = Describe("ArtworkID", func() {
) )
}) })
Describe("ParseArtworkID - folder kind", func() {
It("parses a folder artwork ID with fo prefix", func() {
now := time.Now()
id := model.NewArtworkID(model.KindFolderArtwork, "folder-id-123", &now)
parsedId, err := model.ParseArtworkID(id.String())
Expect(err).ToNot(HaveOccurred())
Expect(parsedId.Kind).To(Equal(model.KindFolderArtwork))
Expect(parsedId.ID).To(Equal("folder-id-123"))
Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix()))
})
})
Describe("ParseArtworkID()", func() { Describe("ParseArtworkID()", func() {
It("parses album artwork ids", func() { It("parses album artwork ids", func() {
id, err := model.ParseArtworkID("al-1234") id, err := model.ParseArtworkID("al-1234")

View File

@ -31,6 +31,14 @@ type Folder struct {
CreatedAt time.Time `structs:"created_at"` CreatedAt time.Time `structs:"created_at"`
} }
// CoverArtID returns a non-empty ArtworkID only when the folder contains image files.
func (f Folder) CoverArtID() ArtworkID {
if len(f.ImageFiles) == 0 {
return ArtworkID{}
}
return artworkIDFromFolder(f)
}
func (f Folder) AbsolutePath() string { func (f Folder) AbsolutePath() string {
return filepath.Join(f.LibraryPath, f.Path, f.Name) return filepath.Join(f.LibraryPath, f.Path, f.Name)
} }

View File

@ -11,6 +11,32 @@ import (
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
var _ = Describe("Folder.CoverArtID", func() {
It("returns empty ArtworkID when folder has no images", func() {
f := model.Folder{ID: "folder-1"}
Expect(f.CoverArtID()).To(Equal(model.ArtworkID{}))
Expect(f.CoverArtID().String()).To(BeEmpty())
})
It("returns a folder ArtworkID when folder has images", func() {
now := time.Now().Truncate(time.Second)
f := model.Folder{ID: "folder-1", ImageFiles: []string{"cover.jpg"}, ImagesUpdatedAt: now}
artID := f.CoverArtID()
Expect(artID.Kind).To(Equal(model.KindFolderArtwork))
Expect(artID.ID).To(Equal("folder-1"))
Expect(artID.LastUpdate.Unix()).To(Equal(now.Unix()))
})
It("produces a parseable ArtworkID string", func() {
now := time.Now()
f := model.Folder{ID: "folder-1", ImageFiles: []string{"cover.jpg"}, ImagesUpdatedAt: now}
parsed, err := model.ParseArtworkID(f.CoverArtID().String())
Expect(err).ToNot(HaveOccurred())
Expect(parsed.Kind).To(Equal(model.KindFolderArtwork))
Expect(parsed.ID).To(Equal("folder-1"))
})
})
var _ = Describe("Folder", func() { var _ = Describe("Folder", func() {
var ( var (
lib model.Library lib model.Library

View File

@ -324,6 +324,7 @@ func childFromFolder(_ context.Context, folder model.Folder) responses.Child {
child.IsDir = true child.IsDir = true
child.Title = folder.Name child.Title = folder.Name
child.Name = folder.Name child.Name = folder.Name
child.CoverArt = folder.CoverArtID().String()
return child return child
} }

View File

@ -512,11 +512,17 @@ var _ = Describe("helpers", func() {
Expect(child.Name).To(Equal("Jazz")) Expect(child.Name).To(Equal("Jazz"))
}) })
It("leaves CoverArt empty (populated in task 1.3)", func() { It("leaves CoverArt empty when folder has no images", func() {
child := childFromFolder(ctx, folder) child := childFromFolder(ctx, folder)
Expect(child.CoverArt).To(BeEmpty()) Expect(child.CoverArt).To(BeEmpty())
}) })
It("sets CoverArt when folder has images", func() {
folder.ImageFiles = []string{"cover.jpg"}
child := childFromFolder(ctx, folder)
Expect(child.CoverArt).To(HavePrefix("fo-"))
})
It("works for a root folder with no parent", func() { It("works for a root folder with no parent", func() {
folder.ParentID = "" folder.ParentID = ""
child := childFromFolder(ctx, folder) child := childFromFolder(ctx, folder)