ab8a58157a
* feat: add shared ImageUploadService for entity image management * feat: add UploadedImage field and methods to Artist model * feat: add uploaded_image column to artist table * feat: add ArtistImageFolder config option * refactor: wire ImageUploadService and delegate playlist file ops to it Wire ImageUploadService into the DI container and refactor the playlist service to delegate image file operations (SetImage/RemoveImage) to the shared ImageUploadService, removing duplicated file I/O logic. A local ImageUploadService interface is defined in core/playlists to avoid an import cycle between core and core/playlists. * feat: artist artwork reader checks uploaded image first * feat: add image-folder priority source for artist artwork * feat: cache key invalidation for image-folder and uploaded images * refactor: extract shared image upload HTTP helpers * feat: add artist image upload/delete API endpoints * refactor: playlist handlers use shared image upload helpers * feat: add shared ImageUploadOverlay component * feat: add i18n keys for artist image upload * feat: add image upload overlay to artist detail pages * refactor: playlist details uses shared ImageUploadOverlay component * fix: add gosec nolint directive for ParseMultipartForm * refactor: deduplicate image upload code and optimize dir scanning - Remove dead ImageFilename methods from Artist and Playlist models (production code uses core.imageFilename exclusively) - Extract shared uploadedImagePath helper in model/image.go - Extract findImageInArtistFolder to deduplicate dir-scanning logic between fromArtistImageFolder and getArtistImageFolderModTime - Fix fileInputRef in useCallback dependency array * fix: include artist UpdatedAt in artwork cache key Without this, uploading or deleting an artist image would not invalidate the cached artwork because the cache key was only based on album folder timestamps, not the artist's own UpdatedAt field. * feat: add Portuguese translations for artist image upload * refactor: use shared i18n keys for cover art upload messages Move cover art upload/remove translations from per-entity sections (artist, playlist) to a shared top-level "message" section, avoiding duplication across entity types and translation files. * refactor: move cover art i18n keys to shared message section for all languages * refactor: simplify image upload code and eliminate redundancies Extracted duplicate image loading/lightbox state logic from DesktopArtistDetails and MobileArtistDetails into a shared useArtistImageState hook. Moved entity type constants to the consts package and replaced raw string literals throughout model, core, and nativeapi packages. Exported model.UploadedImagePath and reused it in core/image_upload.go to consolidate path construction. Cached the ArtistImageFolder lookup result in artistReader to eliminate a redundant os.ReadDir call on every artwork request. Signed-off-by: Deluan <deluan@navidrome.org> * style: fix prettier formatting in ImageUploadOverlay * fix: address code review feedback on image upload error handling - RemoveImage now returns errors instead of swallowing them - Artist handlers distinguish not-found from other DB errors - Defer multipart temp file cleanup after parsing * fix: enforce hard request size limit with MaxBytesReader for image uploads Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
714 lines
23 KiB
Go
714 lines
23 KiB
Go
package artwork
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/core"
|
|
"github.com/navidrome/navidrome/model"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("artistArtworkReader", func() {
|
|
var _ = Describe("loadArtistFolder", func() {
|
|
var (
|
|
ctx context.Context
|
|
fds *fakeDataStore
|
|
repo *fakeFolderRepo
|
|
albums model.Albums
|
|
paths []string
|
|
now time.Time
|
|
expectedUpdTime time.Time
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctx = context.Background()
|
|
DeferCleanup(stubCoreAbsolutePath())
|
|
|
|
now = time.Now().Truncate(time.Second)
|
|
expectedUpdTime = now.Add(5 * time.Minute)
|
|
repo = &fakeFolderRepo{
|
|
result: []model.Folder{
|
|
{
|
|
ImagesUpdatedAt: expectedUpdTime,
|
|
},
|
|
},
|
|
err: nil,
|
|
}
|
|
fds = &fakeDataStore{
|
|
folderRepo: repo,
|
|
}
|
|
albums = model.Albums{
|
|
{LibraryID: 1, ID: "album1", Name: "Album 1"},
|
|
}
|
|
})
|
|
|
|
When("no albums provided", func() {
|
|
It("returns empty and zero time", func() {
|
|
folder, upd, err := loadArtistFolder(ctx, fds, model.Albums{}, []string{"/dummy/path"})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(folder).To(BeEmpty())
|
|
Expect(upd).To(BeZero())
|
|
})
|
|
})
|
|
|
|
When("artist has only one album", func() {
|
|
It("returns the parent folder", func() {
|
|
paths = []string{
|
|
filepath.FromSlash("/music/artist/album1"),
|
|
}
|
|
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(folder).To(Equal("/music/artist"))
|
|
Expect(upd).To(Equal(expectedUpdTime))
|
|
})
|
|
})
|
|
|
|
When("the artist have multiple albums", func() {
|
|
It("returns the common prefix for the albums paths", func() {
|
|
paths = []string{
|
|
filepath.FromSlash("/music/library/artist/one"),
|
|
filepath.FromSlash("/music/library/artist/two"),
|
|
}
|
|
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(folder).To(Equal(filepath.FromSlash("/music/library/artist")))
|
|
Expect(upd).To(Equal(expectedUpdTime))
|
|
})
|
|
})
|
|
|
|
When("the album paths contain same prefix", func() {
|
|
It("returns the common prefix", func() {
|
|
paths = []string{
|
|
filepath.FromSlash("/music/artist/album1"),
|
|
filepath.FromSlash("/music/artist/album2"),
|
|
}
|
|
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(folder).To(Equal("/music/artist"))
|
|
Expect(upd).To(Equal(expectedUpdTime))
|
|
})
|
|
})
|
|
|
|
When("ds.Folder().GetAll returns an error", func() {
|
|
It("returns an error", func() {
|
|
paths = []string{
|
|
filepath.FromSlash("/music/artist/album1"),
|
|
filepath.FromSlash("/music/artist/album2"),
|
|
}
|
|
repo.err = errors.New("fake error")
|
|
folder, upd, err := loadArtistFolder(ctx, fds, albums, paths)
|
|
Expect(err).To(MatchError(ContainSubstring("fake error")))
|
|
// Folder and time are empty on error.
|
|
Expect(folder).To(BeEmpty())
|
|
Expect(upd).To(BeZero())
|
|
})
|
|
})
|
|
})
|
|
|
|
var _ = Describe("fromArtistFolder", func() {
|
|
var (
|
|
ctx context.Context
|
|
tempDir string
|
|
testFunc sourceFunc
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctx = context.Background()
|
|
tempDir = GinkgoT().TempDir()
|
|
})
|
|
|
|
When("artist folder contains matching image", func() {
|
|
BeforeEach(func() {
|
|
// Create test structure: /temp/artist/artist.jpg
|
|
artistDir := filepath.Join(tempDir, "artist")
|
|
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
|
|
|
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
|
Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed())
|
|
|
|
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
|
})
|
|
|
|
It("finds and returns the image", func() {
|
|
reader, path, err := testFunc()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reader).ToNot(BeNil())
|
|
Expect(path).To(ContainSubstring("artist.jpg"))
|
|
|
|
// Verify we can read the content
|
|
data, err := io.ReadAll(reader)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("fake image data"))
|
|
reader.Close()
|
|
})
|
|
})
|
|
|
|
When("artist folder is empty but parent contains image", func() {
|
|
BeforeEach(func() {
|
|
// Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/
|
|
parentDir := filepath.Join(tempDir, "parent")
|
|
artistDir := filepath.Join(parentDir, "artist")
|
|
albumDir := filepath.Join(artistDir, "album")
|
|
Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
|
|
|
|
// Put artist image in parent directory
|
|
artistImagePath := filepath.Join(parentDir, "artist.jpg")
|
|
Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed())
|
|
|
|
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
|
})
|
|
|
|
It("finds image in parent directory", func() {
|
|
reader, path, err := testFunc()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reader).ToNot(BeNil())
|
|
Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg"))
|
|
|
|
data, err := io.ReadAll(reader)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("parent image"))
|
|
reader.Close()
|
|
})
|
|
})
|
|
|
|
When("image is two levels up", func() {
|
|
BeforeEach(func() {
|
|
// Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/
|
|
grandparentDir := filepath.Join(tempDir, "grandparent")
|
|
parentDir := filepath.Join(grandparentDir, "parent")
|
|
artistDir := filepath.Join(parentDir, "artist")
|
|
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
|
|
|
// Put artist image in grandparent directory
|
|
artistImagePath := filepath.Join(grandparentDir, "artist.jpg")
|
|
Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed())
|
|
|
|
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
|
})
|
|
|
|
It("finds image in grandparent directory", func() {
|
|
reader, path, err := testFunc()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reader).ToNot(BeNil())
|
|
Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg"))
|
|
|
|
data, err := io.ReadAll(reader)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("grandparent image"))
|
|
reader.Close()
|
|
})
|
|
})
|
|
|
|
When("images exist at multiple levels", func() {
|
|
BeforeEach(func() {
|
|
// Create test structure with images at multiple levels
|
|
grandparentDir := filepath.Join(tempDir, "grandparent")
|
|
parentDir := filepath.Join(grandparentDir, "parent")
|
|
artistDir := filepath.Join(parentDir, "artist")
|
|
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
|
|
|
// Put artist images at all levels
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed())
|
|
|
|
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
|
})
|
|
|
|
It("prioritizes the closest (artist folder) image", func() {
|
|
reader, path, err := testFunc()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reader).ToNot(BeNil())
|
|
Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg"))
|
|
|
|
data, err := io.ReadAll(reader)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("artist level"))
|
|
reader.Close()
|
|
})
|
|
})
|
|
|
|
When("pattern matches multiple files", func() {
|
|
BeforeEach(func() {
|
|
artistDir := filepath.Join(tempDir, "artist")
|
|
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
|
|
|
// Create multiple matching files
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
|
|
|
|
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
|
})
|
|
|
|
It("returns the first valid image file in sorted order", func() {
|
|
reader, path, err := testFunc()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reader).ToNot(BeNil())
|
|
|
|
// Should return an image file,
|
|
// Files are sorted: jpg comes before png alphabetically.
|
|
// .abc comes first, but it's not an image.
|
|
Expect(path).To(ContainSubstring("artist.jpg"))
|
|
reader.Close()
|
|
})
|
|
})
|
|
|
|
When("prioritizing files without numeric suffixes", func() {
|
|
BeforeEach(func() {
|
|
// Test case for issue #4683: artist.jpg should come before artist.1.jpg
|
|
artistDir := filepath.Join(tempDir, "artist")
|
|
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
|
|
|
// Create multiple matches with and without numeric suffixes
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed())
|
|
|
|
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
|
})
|
|
|
|
It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() {
|
|
reader, path, err := testFunc()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reader).ToNot(BeNil())
|
|
Expect(path).To(ContainSubstring("artist.jpg"))
|
|
|
|
// Verify it's the main file, not a numbered variant
|
|
data, err := io.ReadAll(reader)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("artist main"))
|
|
reader.Close()
|
|
})
|
|
})
|
|
|
|
When("handling case-insensitive sorting", func() {
|
|
BeforeEach(func() {
|
|
// Test case to ensure case-insensitive natural sorting
|
|
artistDir := filepath.Join(tempDir, "artist")
|
|
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
|
|
|
// Create files with mixed case names
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed())
|
|
|
|
testFunc = fromArtistFolder(ctx, artistDir, "*.*")
|
|
})
|
|
|
|
It("sorts case-insensitively", func() {
|
|
reader, path, err := testFunc()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reader).ToNot(BeNil())
|
|
|
|
// Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder")
|
|
Expect(path).To(ContainSubstring("artist.jpg"))
|
|
|
|
data, err := io.ReadAll(reader)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("artist"))
|
|
reader.Close()
|
|
})
|
|
})
|
|
|
|
When("no matching files exist anywhere", func() {
|
|
BeforeEach(func() {
|
|
artistDir := filepath.Join(tempDir, "artist")
|
|
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
|
|
|
// Create non-matching files
|
|
Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed())
|
|
|
|
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
|
})
|
|
|
|
It("returns an error", func() {
|
|
reader, path, err := testFunc()
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(reader).To(BeNil())
|
|
Expect(path).To(BeEmpty())
|
|
Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'"))
|
|
Expect(err.Error()).To(ContainSubstring("parent directories"))
|
|
})
|
|
})
|
|
|
|
When("directory traversal reaches filesystem root", func() {
|
|
BeforeEach(func() {
|
|
// Start from a shallow directory to test root boundary
|
|
artistDir := filepath.Join(tempDir, "artist")
|
|
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
|
|
|
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
|
})
|
|
|
|
It("handles root boundary gracefully", func() {
|
|
reader, path, err := testFunc()
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(reader).To(BeNil())
|
|
Expect(path).To(BeEmpty())
|
|
// Should not panic or cause infinite loop
|
|
})
|
|
})
|
|
|
|
When("file exists but cannot be opened", func() {
|
|
BeforeEach(func() {
|
|
artistDir := filepath.Join(tempDir, "artist")
|
|
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
|
|
|
// Create a file that cannot be opened (permission denied)
|
|
restrictedFile := filepath.Join(artistDir, "artist.jpg")
|
|
Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed())
|
|
|
|
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
|
})
|
|
|
|
It("logs warning and continues searching", func() {
|
|
// This test depends on the ability to restrict file permissions
|
|
// For now, we'll just ensure it doesn't panic and returns appropriate error
|
|
reader, _, err := testFunc()
|
|
// The file should be readable in test environment, so this will succeed
|
|
// In a real scenario with permission issues, it would continue searching
|
|
if err == nil {
|
|
Expect(reader).ToNot(BeNil())
|
|
reader.Close()
|
|
}
|
|
})
|
|
})
|
|
|
|
When("single album artist scenario (original issue)", func() {
|
|
BeforeEach(func() {
|
|
// Simulate the exact folder structure from the issue:
|
|
// /music/artist/album1/ (single album)
|
|
// /music/artist/artist.jpg (artist image that should be found)
|
|
artistDir := filepath.Join(tempDir, "music", "artist")
|
|
albumDir := filepath.Join(artistDir, "album1")
|
|
Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
|
|
|
|
// Create artist.jpg in the artist folder (this was not being found before)
|
|
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
|
Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed())
|
|
|
|
// The fromArtistFolder is called with the artist folder path
|
|
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
|
})
|
|
|
|
It("finds artist.jpg in artist folder for single album artist", func() {
|
|
reader, path, err := testFunc()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(reader).ToNot(BeNil())
|
|
Expect(path).To(ContainSubstring("artist.jpg"))
|
|
Expect(path).To(ContainSubstring("artist"))
|
|
|
|
// Verify the content
|
|
data, err := io.ReadAll(reader)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("single album artist image"))
|
|
reader.Close()
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("fromArtistUploadedImage", func() {
|
|
var (
|
|
tempDir string
|
|
reader *artistReader
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
DeferCleanup(configtest.SetupConfig())
|
|
tempDir = GinkgoT().TempDir()
|
|
conf.Server.DataFolder = tempDir
|
|
|
|
// Create the artwork/artist directory
|
|
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())
|
|
|
|
reader = &artistReader{}
|
|
})
|
|
|
|
When("artist has an uploaded image", func() {
|
|
It("returns the uploaded image", func() {
|
|
imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg")
|
|
Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed())
|
|
|
|
reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"}
|
|
sf := reader.fromArtistUploadedImage()
|
|
r, path, err := sf()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(r).ToNot(BeNil())
|
|
Expect(path).To(Equal(imgPath))
|
|
|
|
data, err := io.ReadAll(r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("uploaded artist image"))
|
|
r.Close()
|
|
})
|
|
})
|
|
|
|
When("artist has no uploaded image", func() {
|
|
It("returns nil reader (falls through)", func() {
|
|
reader.artist = model.Artist{ID: "ar-1"}
|
|
sf := reader.fromArtistUploadedImage()
|
|
r, path, err := sf()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(r).To(BeNil())
|
|
Expect(path).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("fromArtistImageFolder", func() {
|
|
var (
|
|
ctx context.Context
|
|
tempDir string
|
|
ar *artistReader
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
ctx = context.Background()
|
|
DeferCleanup(configtest.SetupConfig())
|
|
tempDir = GinkgoT().TempDir()
|
|
ar = &artistReader{}
|
|
})
|
|
|
|
When("ArtistImageFolder is not configured", func() {
|
|
It("returns nil (skips)", func() {
|
|
conf.Server.ArtistImageFolder = ""
|
|
ar.artist = model.Artist{Name: "Test Artist"}
|
|
sf := ar.fromArtistImageFolder(ctx)
|
|
r, path, err := sf()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(r).To(BeNil())
|
|
Expect(path).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
When("image exists matching MBID", func() {
|
|
It("finds the image by MBID", func() {
|
|
conf.Server.ArtistImageFolder = tempDir
|
|
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
|
imgPath := filepath.Join(tempDir, mbid+".jpg")
|
|
Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed())
|
|
|
|
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
|
sf := ar.fromArtistImageFolder(ctx)
|
|
r, path, err := sf()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(r).ToNot(BeNil())
|
|
Expect(path).To(Equal(imgPath))
|
|
|
|
data, err := io.ReadAll(r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("mbid image"))
|
|
r.Close()
|
|
})
|
|
})
|
|
|
|
When("MBID match is case-insensitive", func() {
|
|
It("finds the image regardless of case", func() {
|
|
conf.Server.ArtistImageFolder = tempDir
|
|
mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E"
|
|
imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png")
|
|
Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed())
|
|
|
|
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
|
sf := ar.fromArtistImageFolder(ctx)
|
|
r, path, err := sf()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(r).ToNot(BeNil())
|
|
Expect(path).To(Equal(imgPath))
|
|
r.Close()
|
|
})
|
|
})
|
|
|
|
When("no MBID file exists but artist name file does", func() {
|
|
It("falls back to artist name match", func() {
|
|
conf.Server.ArtistImageFolder = tempDir
|
|
imgPath := filepath.Join(tempDir, "Test Artist.jpg")
|
|
Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed())
|
|
|
|
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"}
|
|
sf := ar.fromArtistImageFolder(ctx)
|
|
r, path, err := sf()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(r).ToNot(BeNil())
|
|
Expect(path).To(Equal(imgPath))
|
|
|
|
data, err := io.ReadAll(r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("name image"))
|
|
r.Close()
|
|
})
|
|
})
|
|
|
|
When("artist name match is case-insensitive", func() {
|
|
It("matches regardless of case", func() {
|
|
conf.Server.ArtistImageFolder = tempDir
|
|
imgPath := filepath.Join(tempDir, "test artist.jpg")
|
|
Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed())
|
|
|
|
ar.artist = model.Artist{Name: "Test Artist"}
|
|
sf := ar.fromArtistImageFolder(ctx)
|
|
r, path, err := sf()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(r).ToNot(BeNil())
|
|
Expect(path).To(Equal(imgPath))
|
|
r.Close()
|
|
})
|
|
})
|
|
|
|
When("both MBID and name files exist", func() {
|
|
It("prefers MBID over name match", func() {
|
|
conf.Server.ArtistImageFolder = tempDir
|
|
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
|
mbidPath := filepath.Join(tempDir, mbid+".jpg")
|
|
namePath := filepath.Join(tempDir, "Test Artist.jpg")
|
|
Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed())
|
|
Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed())
|
|
|
|
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
|
sf := ar.fromArtistImageFolder(ctx)
|
|
r, path, err := sf()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(r).ToNot(BeNil())
|
|
Expect(path).To(Equal(mbidPath))
|
|
|
|
data, err := io.ReadAll(r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("mbid image"))
|
|
r.Close()
|
|
})
|
|
})
|
|
|
|
When("no matching image found", func() {
|
|
It("returns an error", func() {
|
|
conf.Server.ArtistImageFolder = tempDir
|
|
// Create an unrelated file
|
|
Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed())
|
|
|
|
ar.artist = model.Artist{Name: "Test Artist"}
|
|
sf := ar.fromArtistImageFolder(ctx)
|
|
r, _, err := sf()
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(r).To(BeNil())
|
|
Expect(err.Error()).To(ContainSubstring("no image found"))
|
|
})
|
|
})
|
|
|
|
When("cached imgFolderImgPath is set", func() {
|
|
It("uses cached path instead of scanning", func() {
|
|
conf.Server.ArtistImageFolder = tempDir
|
|
imgPath := filepath.Join(tempDir, "cached.jpg")
|
|
Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed())
|
|
|
|
ar.artist = model.Artist{Name: "Test Artist"}
|
|
ar.imgFolderImgPath = imgPath
|
|
sf := ar.fromArtistImageFolder(ctx)
|
|
r, path, err := sf()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(r).ToNot(BeNil())
|
|
Expect(path).To(Equal(imgPath))
|
|
|
|
data, err := io.ReadAll(r)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(data)).To(Equal("cached image"))
|
|
r.Close()
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("findImageInArtistFolder", func() {
|
|
var tempDir string
|
|
|
|
BeforeEach(func() {
|
|
tempDir = GinkgoT().TempDir()
|
|
})
|
|
|
|
When("matching file exists by MBID", func() {
|
|
It("returns the file path", func() {
|
|
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
|
imgPath := filepath.Join(tempDir, mbid+".jpg")
|
|
Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
|
|
|
|
path := findImageInArtistFolder(tempDir, mbid, "Test")
|
|
Expect(path).To(Equal(imgPath))
|
|
})
|
|
})
|
|
|
|
When("matching file exists by name", func() {
|
|
It("returns the file path", func() {
|
|
imgPath := filepath.Join(tempDir, "Test Artist.png")
|
|
Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
|
|
|
|
path := findImageInArtistFolder(tempDir, "", "Test Artist")
|
|
Expect(path).To(Equal(imgPath))
|
|
})
|
|
})
|
|
|
|
When("no matching file exists", func() {
|
|
It("returns empty string", func() {
|
|
path := findImageInArtistFolder(tempDir, "", "Unknown Artist")
|
|
Expect(path).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
When("folder does not exist", func() {
|
|
It("returns empty string", func() {
|
|
path := findImageInArtistFolder("/nonexistent/path", "", "Test")
|
|
Expect(path).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
type fakeFolderRepo struct {
|
|
model.FolderRepository
|
|
result []model.Folder
|
|
parentResult *model.Folder
|
|
getErr error
|
|
getCallCount int
|
|
err error
|
|
}
|
|
|
|
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
|
return f.result, f.err
|
|
}
|
|
|
|
func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) {
|
|
f.getCallCount++
|
|
if f.getErr != nil {
|
|
return nil, f.getErr
|
|
}
|
|
if f.parentResult != nil {
|
|
return f.parentResult, nil
|
|
}
|
|
return nil, model.ErrNotFound
|
|
}
|
|
|
|
type fakeDataStore struct {
|
|
model.DataStore
|
|
folderRepo *fakeFolderRepo
|
|
}
|
|
|
|
func (fds *fakeDataStore) Folder(_ context.Context) model.FolderRepository {
|
|
return fds.folderRepo
|
|
}
|
|
|
|
func stubCoreAbsolutePath() func() {
|
|
// Override core.AbsolutePath to return a fixed string during tests.
|
|
original := core.AbsolutePath
|
|
core.AbsolutePath = func(_ context.Context, ds model.DataStore, libID int, p string) string {
|
|
return filepath.FromSlash("/music")
|
|
}
|
|
return func() {
|
|
core.AbsolutePath = original
|
|
}
|
|
}
|