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)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
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"}
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user