diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 4a0a32afc..652e823c0 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -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 } diff --git a/core/artwork/reader_folder.go b/core/artwork/reader_folder.go new file mode 100644 index 000000000..63d8bfeba --- /dev/null +++ b/core/artwork/reader_folder.go @@ -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...) +} diff --git a/core/artwork/reader_folder_test.go b/core/artwork/reader_folder_test.go new file mode 100644 index 000000000..edaa44e81 --- /dev/null +++ b/core/artwork/reader_folder_test.go @@ -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)) + }) + }) +}) diff --git a/model/artwork_id.go b/model/artwork_id.go index 8d935f427..ab4cd1aa8 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -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, + } +} diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index b634e7cbc..24284d447 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -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") diff --git a/model/folder.go b/model/folder.go index 7a769735e..f28f507b1 100644 --- a/model/folder.go +++ b/model/folder.go @@ -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) } diff --git a/model/folder_test.go b/model/folder_test.go index 0535f6987..c136dfa96 100644 --- a/model/folder_test.go +++ b/model/folder_test.go @@ -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 diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 1fd2c2795..7637a1344 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -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 } diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 7935ac351..353ded060 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -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)