Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
447 lines
15 KiB
Go
447 lines
15 KiB
Go
package artwork
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"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()
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
type fakeFolderRepo struct {
|
|
model.FolderRepository
|
|
result []model.Folder
|
|
err error
|
|
}
|
|
|
|
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
|
return f.result, f.err
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|