mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Add folder artwork support and SongsByFolder filter
Signed-off-by: Patrik Wallström <pawal@amplitut.de>
This commit is contained in:
parent
32200abeb9
commit
0addb23bf5
@ -124,6 +124,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
||||
artReader, err = newPlaylistArtworkReader(ctx, a, artID)
|
||||
case model.KindDiscArtwork:
|
||||
artReader, err = newDiscArtworkReader(ctx, a, artID)
|
||||
case model.KindFolderArtwork:
|
||||
artReader, err = newFolderArtworkReader(ctx, a, artID)
|
||||
default:
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
|
||||
64
core/artwork/reader_folder.go
Normal file
64
core/artwork/reader_folder.go
Normal file
@ -0,0 +1,64 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type folderArtworkReader struct {
|
||||
cacheKey
|
||||
folder model.Folder
|
||||
imgFiles []string
|
||||
}
|
||||
|
||||
func newFolderArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*folderArtworkReader, error) {
|
||||
folder, err := a.ds.Folder(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
absPath := folder.AbsolutePath()
|
||||
imgFiles := make([]string, len(folder.ImageFiles))
|
||||
for i, img := range folder.ImageFiles {
|
||||
imgFiles[i] = filepath.Join(absPath, img)
|
||||
}
|
||||
|
||||
r := &folderArtworkReader{
|
||||
folder: *folder,
|
||||
imgFiles: imgFiles,
|
||||
}
|
||||
r.cacheKey.artID = artID
|
||||
r.cacheKey.lastUpdate = folder.ImagesUpdatedAt
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (f *folderArtworkReader) Key() string {
|
||||
hash := md5.Sum([]byte(conf.Server.CoverArtPriority))
|
||||
return fmt.Sprintf("%s.%x", f.cacheKey.Key(), hash)
|
||||
}
|
||||
|
||||
func (f *folderArtworkReader) LastUpdated() time.Time {
|
||||
return f.folder.ImagesUpdatedAt
|
||||
}
|
||||
|
||||
func (f *folderArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
var ff []sourceFunc
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded" || pattern == "external":
|
||||
// Folders have no embedded tags and no external artwork sources
|
||||
case len(f.imgFiles) > 0:
|
||||
ff = append(ff, fromExternalFile(ctx, f.imgFiles, pattern))
|
||||
}
|
||||
}
|
||||
return selectImageReader(ctx, f.cacheKey.artID, ff...)
|
||||
}
|
||||
134
core/artwork/reader_folder_test.go
Normal file
134
core/artwork/reader_folder_test.go
Normal file
@ -0,0 +1,134 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("folderArtworkReader", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
a *artwork
|
||||
tmpDir string
|
||||
folderRepo *fakeFolderRepo
|
||||
folder model.Folder
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.CoverArtPriority = "cover.*, front.*, *"
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
ds := &fakeDataStore{folderRepo: folderRepo}
|
||||
a = &artwork{ds: ds}
|
||||
|
||||
folder = model.Folder{
|
||||
ID: "folder-1",
|
||||
LibraryPath: tmpDir,
|
||||
Path: ".",
|
||||
Name: "Jazz",
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
ImagesUpdatedAt: time.Now().Truncate(time.Second),
|
||||
}
|
||||
})
|
||||
|
||||
createImage := func(name string) {
|
||||
fullPath := filepath.Join(folder.AbsolutePath(), name)
|
||||
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
|
||||
}
|
||||
|
||||
Describe("newFolderArtworkReader", func() {
|
||||
It("returns a reader when the folder is found", func() {
|
||||
folderRepo.parentResult = &folder
|
||||
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
|
||||
reader, err := newFolderArtworkReader(ctx, a, artID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns an error when the folder is not found", func() {
|
||||
artID := model.NewArtworkID(model.KindFolderArtwork, "missing", nil)
|
||||
_, err := newFolderArtworkReader(ctx, a, artID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("builds absolute image file paths from folder.ImageFiles", func() {
|
||||
folderRepo.parentResult = &folder
|
||||
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
|
||||
reader, err := newFolderArtworkReader(ctx, a, artID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader.imgFiles).To(ConsistOf(
|
||||
filepath.Join(folder.AbsolutePath(), "cover.jpg"),
|
||||
))
|
||||
})
|
||||
|
||||
It("uses ImagesUpdatedAt as the cache key lastUpdate", func() {
|
||||
folderRepo.parentResult = &folder
|
||||
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
|
||||
reader, err := newFolderArtworkReader(ctx, a, artID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader.LastUpdated()).To(Equal(folder.ImagesUpdatedAt))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Reader", func() {
|
||||
It("returns the matching image file", func() {
|
||||
createImage("cover.jpg")
|
||||
folderRepo.parentResult = &folder
|
||||
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
|
||||
reader, err := newFolderArtworkReader(ctx, a, artID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, path, err := reader.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rc).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("cover.jpg"))
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("returns ErrUnavailable when no images match the priority patterns", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
folder.ImageFiles = []string{"other.jpg"}
|
||||
folderRepo.parentResult = &folder
|
||||
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
|
||||
reader, err := newFolderArtworkReader(ctx, a, artID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = reader.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
|
||||
It("skips embedded and external patterns without error", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, external, cover.*"
|
||||
createImage("cover.jpg")
|
||||
folderRepo.parentResult = &folder
|
||||
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
|
||||
reader, err := newFolderArtworkReader(ctx, a, artID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rc, _, err := reader.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rc).ToNot(BeNil())
|
||||
rc.Close()
|
||||
})
|
||||
|
||||
It("returns ErrUnavailable when folder has no images and only external/embedded in priority", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, external"
|
||||
folder.ImageFiles = []string{}
|
||||
folderRepo.parentResult = &folder
|
||||
artID := model.NewArtworkID(model.KindFolderArtwork, "folder-1", nil)
|
||||
reader, err := newFolderArtworkReader(ctx, a, artID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = reader.Reader(ctx)
|
||||
Expect(err).To(MatchError(ErrUnavailable))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -23,6 +23,7 @@ var (
|
||||
KindAlbumArtwork = Kind{"al", "album"}
|
||||
KindPlaylistArtwork = Kind{"pl", "playlist"}
|
||||
KindDiscArtwork = Kind{"dc", "disc"}
|
||||
KindFolderArtwork = Kind{"fo", "folder"}
|
||||
)
|
||||
|
||||
var artworkKindMap = map[string]Kind{
|
||||
@ -31,6 +32,7 @@ var artworkKindMap = map[string]Kind{
|
||||
KindAlbumArtwork.prefix: KindAlbumArtwork,
|
||||
KindPlaylistArtwork.prefix: KindPlaylistArtwork,
|
||||
KindDiscArtwork.prefix: KindDiscArtwork,
|
||||
KindFolderArtwork.prefix: KindFolderArtwork,
|
||||
}
|
||||
|
||||
type ArtworkID struct {
|
||||
@ -139,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID {
|
||||
ID: ar.ID,
|
||||
}
|
||||
}
|
||||
|
||||
func artworkIDFromFolder(f Folder) ArtworkID {
|
||||
return ArtworkID{
|
||||
Kind: KindFolderArtwork,
|
||||
ID: f.ID,
|
||||
LastUpdate: f.ImagesUpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,18 @@ var _ = Describe("ArtworkID", func() {
|
||||
)
|
||||
})
|
||||
|
||||
Describe("ParseArtworkID - folder kind", func() {
|
||||
It("parses a folder artwork ID with fo prefix", func() {
|
||||
now := time.Now()
|
||||
id := model.NewArtworkID(model.KindFolderArtwork, "folder-id-123", &now)
|
||||
parsedId, err := model.ParseArtworkID(id.String())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(parsedId.Kind).To(Equal(model.KindFolderArtwork))
|
||||
Expect(parsedId.ID).To(Equal("folder-id-123"))
|
||||
Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ParseArtworkID()", func() {
|
||||
It("parses album artwork ids", func() {
|
||||
id, err := model.ParseArtworkID("al-1234")
|
||||
|
||||
@ -31,6 +31,14 @@ type Folder struct {
|
||||
CreatedAt time.Time `structs:"created_at"`
|
||||
}
|
||||
|
||||
// CoverArtID returns a non-empty ArtworkID only when the folder contains image files.
|
||||
func (f Folder) CoverArtID() ArtworkID {
|
||||
if len(f.ImageFiles) == 0 {
|
||||
return ArtworkID{}
|
||||
}
|
||||
return artworkIDFromFolder(f)
|
||||
}
|
||||
|
||||
func (f Folder) AbsolutePath() string {
|
||||
return filepath.Join(f.LibraryPath, f.Path, f.Name)
|
||||
}
|
||||
|
||||
@ -11,6 +11,32 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Folder.CoverArtID", func() {
|
||||
It("returns empty ArtworkID when folder has no images", func() {
|
||||
f := model.Folder{ID: "folder-1"}
|
||||
Expect(f.CoverArtID()).To(Equal(model.ArtworkID{}))
|
||||
Expect(f.CoverArtID().String()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns a folder ArtworkID when folder has images", func() {
|
||||
now := time.Now().Truncate(time.Second)
|
||||
f := model.Folder{ID: "folder-1", ImageFiles: []string{"cover.jpg"}, ImagesUpdatedAt: now}
|
||||
artID := f.CoverArtID()
|
||||
Expect(artID.Kind).To(Equal(model.KindFolderArtwork))
|
||||
Expect(artID.ID).To(Equal("folder-1"))
|
||||
Expect(artID.LastUpdate.Unix()).To(Equal(now.Unix()))
|
||||
})
|
||||
|
||||
It("produces a parseable ArtworkID string", func() {
|
||||
now := time.Now()
|
||||
f := model.Folder{ID: "folder-1", ImageFiles: []string{"cover.jpg"}, ImagesUpdatedAt: now}
|
||||
parsed, err := model.ParseArtworkID(f.CoverArtID().String())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(parsed.Kind).To(Equal(model.KindFolderArtwork))
|
||||
Expect(parsed.ID).To(Equal("folder-1"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Folder", func() {
|
||||
var (
|
||||
lib model.Library
|
||||
|
||||
@ -324,6 +324,7 @@ func childFromFolder(_ context.Context, folder model.Folder) responses.Child {
|
||||
child.IsDir = true
|
||||
child.Title = folder.Name
|
||||
child.Name = folder.Name
|
||||
child.CoverArt = folder.CoverArtID().String()
|
||||
return child
|
||||
}
|
||||
|
||||
|
||||
@ -512,11 +512,17 @@ var _ = Describe("helpers", func() {
|
||||
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)
|
||||
Expect(child.CoverArt).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("sets CoverArt when folder has images", func() {
|
||||
folder.ImageFiles = []string{"cover.jpg"}
|
||||
child := childFromFolder(ctx, folder)
|
||||
Expect(child.CoverArt).To(HavePrefix("fo-"))
|
||||
})
|
||||
|
||||
It("works for a root folder with no parent", func() {
|
||||
folder.ParentID = ""
|
||||
child := childFromFolder(ctx, folder)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user