navidrome/core/artwork/reader_disc_test.go
Deluan Quintão 5d1c1157b5
refactor(artwork): migrate readers to storage.MusicFS and add e2e suite (#5379)
* test(artwork): add e2e suite documenting album/disc resolution

Adds core/artwork/e2e/ with a real-tempdir + scanner harness that exercises
artwork resolution end-to-end. Covers album and disc kinds; pending (PIt)
cases document two known bugs in reader_album.go for regression-guard
flipping once they are fixed.

* refactor(artwork): add libraryFS helper to resolve MusicFS for a library

* test(artwork): tighten libraryFS test isolation and add scheme-error case

* test(artwork): update libraryFS test description to match implementation

* refactor(artwork): convert fromExternalFile to use fs.FS

Add a temporary fromExternalFileAbs shim so existing absolute-path callers
still compile; the shim is removed once all readers are migrated.

* refactor(artwork): make fromExternalFileAbs a thin delegator

Introduce a minimal osDirectFS adapter so the shim no longer duplicates
the matching loop. Both will be removed in Task 9.

* refactor(artwork): convert fromTag to taglib.OpenStream over fs.FS

Add a temporary fromTagAbs shim so existing absolute-path callers still
compile; removed in Task 9. Reuses the osDirectFS adapter from Task 2.

* refactor(artwork): defer fs.File close until after taglib reads finish

Mirror the lifetime pattern used by adapters/gotaglib/gotaglib.go:
keep the underlying fs.File open until taglib.File is closed, and
pass WithFilename so format detection doesn't rely on content sniffing.

* docs(artwork): note ffmpeg's path-based API limitation

* refactor(artwork): migrate album reader to MusicFS

- Add libFS (storage.MusicFS) field to albumArtworkReader; resolved
  once at construction time via libraryFS()
- Switch fromCoverArtPriority from abs-path shims to FS-based
  fromTag/fromExternalFile; only fromFFmpegTag retains absolute path
- Build imgFiles as library-relative forward-slash paths in
  loadAlbumFoldersPaths using path.Join(f.Path, f.Name, img)
- Guard embedAbs so that an empty EmbedArtPath never produces a
  non-empty absolute path (prevents accidental ffmpeg invocation)
- Register testfile:// storage scheme in artwork test suite to provide
  an os.DirFS-backed MusicFS without requiring the taglib extractor
- Update test assertions from filepath.FromSlash(abs) to bare
  forward-slash relative strings

* fix(artwork): use path package in compareImageFiles for forward-slash relative paths

* refactor(artwork): migrate disc reader to MusicFS

Replace os.Open absolute-path access with libFS.Open on library-relative
forward-slash paths. Rename discFolders→discFoldersRel, split
firstTrackPath into firstTrackRelPath (for fromTag) and firstTrackAbsPath
(for fromFFmpegTag), and switch path.Dir/Base/Ext for forward-slash safety.

* refactor(artwork): build discFoldersRel directly and guard empty first track

* refactor(artwork): migrate mediafile reader to MusicFS

* refactor(artwork): migrate artist album-art lookup to MusicFS

* refactor(artwork): remove temporary path-based shims

All readers now use the FS-based fromTag and fromExternalFile directly,
so the absolute-path adapters and the osDirectFS helper that backed
them can go away.

* test(artwork): rewrite e2e suite to use storagetest.FakeFS

Switches from real-tempdir + local storage to FakeFS via the storage
registry. Adds a proper multi-disc scenario using the disc tag, which
previously required curated MP3 fixtures we did not have.

* test(artwork): use maps.Copy in trackFile tag merge

Lint cleanup: replace the manual map-copy loop flagged by mapsloop.

* test(artwork): reuse tests.MockFFmpeg in e2e harness

Replace the hand-rolled noopFFmpeg stub with tests.NewMockFFmpeg, which
already satisfies the full ffmpeg.FFmpeg interface and won't drift when
new methods are added. Also tie imageBytes to imageFile so they cannot
silently disagree on the on-disk encoding.

* test(artwork): add e2e scenarios from artwork documentation

Covers the behaviors documented at
https://www.navidrome.org/docs/usage/library/artwork/:

- Album: folder.*/front.* fallbacks and priority order with cover.*.
- Disc: cd*.* match, cover.* inside disc folder, DiscArtPriority="" skip
  path, the documented multi-disc layout, and the discsubtitle keyword.
- MediaFile: disc-level fallback for multi-disc tracks and album-level
  fallback for single-disc tracks (doc section "MediaFiles" items 2-3).
- Artist: album/artist.* lookup via libFS (passes). The artist-folder
  branch is XIt-marked because fromArtistFolder still calls os.DirFS
  directly on an absolute path and can't read from a FakeFS-backed
  library — migrating that to storage.MusicFS is a follow-up.

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(artwork): scope artist folder traversal to library root

Route fromArtistFolder reads through storage.MusicFS and bound the
parent-directory walk at the library root. This keeps artwork
resolution scoped to the configured library and unblocks FakeFS-backed
e2e scenarios that depend on the artist folder.

Also consolidate the libraryFS + core.AbsolutePath pairing (used by
three readers) into a single libraryFSAndRoot helper.

* test(artwork): add ASCII file-tree diagrams to e2e scenarios

Each It/PIt block now shows the on-disk layout it exercises, with
arrows indicating which file wins (or should win, for the known-bug
PIt cases). Makes scenarios readable at a glance without having to
parse the MapFS map.

* test(artwork): add e2e tests for playlist and radio artwork resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* test(artwork): enhance e2e tests with real MP3 fixtures for embedded artwork

Signed-off-by: Deluan <deluan@navidrome.org>

* test(ffmpeg): add support for animated WebP encoder detection and fallback handling

Signed-off-by: Deluan <deluan@navidrome.org>

* test(artwork): cover additional edge cases in e2e suite

Add high-value scenarios uncovered by the existing specs:

- Album: three-way basename tie (unsuffixed wins), unknown pattern in
  CoverArtPriority is skipped, embedded-first with no embedded art
  falls through.
- Disc: discsubtitle with no matching image falls through.
- Artist: ArtistArtPriority can reach images via album/<pattern>.
- Playlist: generates a 2x2 tiled cover from album art when the playlist
  has no uploaded/sidecar/external image.

New helper realPNG() produces real taglib/image-decodable bytes so the
tiled-cover test can exercise the generator's decode + compose path.

* test(artwork): refactor image upload logic in e2e tests for consistency

Signed-off-by: Deluan <deluan@navidrome.org>

* test(ffmpeg): simplify animated WebP encoder check by removing context parameter

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(artwork): normalize rel path for fs.Glob on Windows

filepath.Rel returns backslash-separated paths on Windows, but fs.Glob
and path.Join require forward slashes. Convert with filepath.ToSlash
after computing the relative path and use path.Dir for the parent walk
so the artist-folder lookup works cross-platform.

* fix(ffmpeg): retry animated WebP probe on transient failure

The probe previously used the caller's request context inside sync.Once,
so a single cancelled first request would permanently disable animated
WebP for the rest of the process. Switch to a mutex + probed flag, use
a fresh background context with its own timeout, and only cache the
result when the probe actually succeeds.

* test(ffmpeg): reset ffOnce so ConvertAnimatedImage test is order-independent

The ConvertAnimatedImage stand-in test sets ffmpegPath directly but
does not reset ffOnce. If ffmpegCmd() has not been called earlier in
the test process, the next call inside hasAnimatedWebPEncoder runs
ffOnce.Do and re-resolves the real ffmpeg binary, overwriting the
stand-in and breaking the test. Reset ffOnce and conf.Server.FFmpegPath
alongside the other globals to pin resolution to the stand-in.

* test(artwork): unblock Windows CI — forward-slash fs paths and suite-level DB lifetime

The internal artwork test planted a Windows absolute path (backslashes) into
Folder.Path and then fed it through libFS.Open, which fs.ValidPath rejects.
Rooting the testfile library at the temp dir directly and using
filepath.ToSlash keeps the path model library-relative and forward-slash,
matching production.

The e2e suite opened a per-spec DB in a per-spec TempDir, but the go-sqlite3
singleton kept the file open across specs. Ginkgo's per-spec TempDir cleanup
then tried to unlink a file still held by that handle — fine on POSIX, fails
on Windows. Moving the DB to a suite-level tempdir and closing it in
AfterSuite avoids the race.

* test(artwork): keep Windows drive letters intact in testfile library URLs

url.Parse on `testfile://C:/path` reads `C` as the host and the path loses
the drive letter, so Windows libFS lookups go to `/path` and fail.
testFileLibPath now prepends a `/` when the OS path has no leading slash,
and the testfile constructor strips that extra slash back off before
handing the path to os.Stat / os.DirFS.

* refactor(artwork): consolidate libFS + root into libraryView helper

Collapses the per-reader libFS/libPath/rootFolder/firstTrackAbsPath fields
into a single libraryView{FS, absRoot} with an Abs(rel) method. Also folds
the two library lookups (ds.Library.Get + core.AbsolutePath) into one, and
uses mf.Path directly instead of stripping libRoot off an absolute path.

* refactor(ffmpeg): replace hasAnimatedWebPEncoder with encoderProbe for state management

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: escape artist folder names in artwork glob

Escape glob metacharacters in the library-relative artist folder path before composing the fs.Glob pattern for artist image lookup. This preserves literal folder names such as Artist [Live] while keeping the configured filename pattern behavior unchanged, and adds a regression test for bracketed artist folders.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(artwork): correct test path assertions after MusicFS migration

Source functions (fromTag, fromExternalFile) now return forward-slash
fs.FS-relative paths, so test assertions should compare against plain
forward-slash strings, not filepath.FromSlash(). The artistArtPriority
test needs filepath.FromSlash() on the suffix because findImageInFolder
returns OS-native absolute paths via filepath.Join.

* fix(artwork): normalize path separators in artistArtPriority assertion

The two table entries exercise different code paths: entry 1 goes through
fromArtistFolder (returns OS-native paths via filepath.Join), while entry 2
goes through fromExternalFile (returns forward-slash fs.FS paths). Using
filepath.FromSlash on the expected value only works for entry 1.

Normalize the actual path to forward slashes with filepath.ToSlash so a
single HaveSuffix assertion works for both code paths on all platforms.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-26 18:16:14 -04:00

493 lines
16 KiB
Go

package artwork
import (
"context"
"os"
"path/filepath"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Disc Artwork Reader", func() {
Describe("extractDiscNumber", func() {
DescribeTable("extracts disc number from filename based on glob pattern",
func(pattern, filename string, expectedNum int, expectedOk bool) {
num, ok := extractDiscNumber(pattern, filename)
Expect(ok).To(Equal(expectedOk))
if expectedOk {
Expect(num).To(Equal(expectedNum))
}
},
// Standard disc patterns
Entry("disc1.jpg", "disc*.*", "disc1.jpg", 1, true),
Entry("disc2.png", "disc*.*", "disc2.png", 2, true),
Entry("disc01.jpg", "disc*.*", "disc01.jpg", 1, true),
Entry("disc02.png", "disc*.*", "disc02.png", 2, true),
Entry("disc10.jpg", "disc*.*", "disc10.jpg", 10, true),
// CD patterns
Entry("cd1.jpg", "cd*.*", "cd1.jpg", 1, true),
Entry("cd02.png", "cd*.*", "cd02.png", 2, true),
// No number in filename
Entry("disc.jpg has no number", "disc*.*", "disc.jpg", 0, false),
Entry("cd.jpg has no number", "cd*.*", "cd.jpg", 0, false),
// Extra text after number
Entry("disc2-bonus.jpg", "disc*.*", "disc2-bonus.jpg", 2, true),
Entry("disc01_front.png", "disc*.*", "disc01_front.png", 1, true),
// Case insensitive (filename already lowered by caller)
Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true),
// HasPrefix guard: filename doesn't share the pattern's literal prefix
Entry("cover.jpg with disc*.* (no prefix match)", "disc*.*", "cover.jpg", 0, false),
// Pattern with no wildcard before dot
Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true),
// '?' single-char wildcard
Entry("disc?.jpg with disc1.jpg", "disc?.jpg", "disc1.jpg", 1, true),
Entry("disc?.jpg with disc2.jpg", "disc?.jpg", "disc2.jpg", 2, true),
Entry("cd??.jpg with cd07.jpg", "cd??.jpg", "cd07.jpg", 7, true),
// '[...]' character class wildcard
Entry("cd[12].jpg with cd1.jpg", "cd[12].jpg", "cd1.jpg", 1, true),
Entry("cd[12].jpg with cd2.jpg", "cd[12].jpg", "cd2.jpg", 2, true),
Entry("disc[0-9].jpg with disc5.jpg", "disc[0-9].jpg", "disc5.jpg", 5, true),
// Literal pattern (no wildcard) returns false
Entry("shellac.png literal", "shellac.png", "shellac.png", 0, false),
)
})
Describe("fromExternalFile", func() {
var (
ctx context.Context
tmpDir string
)
BeforeEach(func() {
ctx = context.Background()
tmpDir = GinkgoT().TempDir()
})
// createFile creates the file on disk and returns its library-relative forward-slash path.
createFile := func(relPath string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
return relPath
}
// removeFile removes a library-relative file from disk.
removeFile := func(relPath string) {
Expect(os.Remove(filepath.Join(tmpDir, filepath.FromSlash(relPath)))).To(Succeed())
}
It("matches file with disc number in single-folder album", func() {
f1 := createFile("album/disc1.jpg")
f2 := createFile("album/disc2.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1))
})
It("matches file without number in single-folder album (shared disc art)", func() {
f1 := createFile("album/cover.png")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "cover.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1))
})
It("returns shared disc art for every disc number in single-folder album", func() {
f1 := createFile("album/shellac.png")
makeReader := func(discNum int) *discArtworkReader {
return &discArtworkReader{
discNumber: discNum,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
}
for _, disc := range []int{1, 2, 5} {
sf := makeReader(disc).fromExternalFile(ctx, "shellac.png")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred(), "disc %d", disc)
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1), "disc %d", disc)
}
})
It("numbered and unnumbered patterns both resolve against the same reader", func() {
f1 := createFile("album/cover.png")
f2 := createFile("album/disc1.jpg")
f3 := createFile("album/disc2.jpg")
reader := &discArtworkReader{
discNumber: 2,
imgFiles: []string{f1, f2, f3},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f3))
sf = reader.fromExternalFile(ctx, "cover.*")
r, path, err = sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1))
})
It("respects DiscArtPriority order when both numbered and unnumbered patterns match", func() {
f1 := createFile("album/cover.png")
f2 := createFile("album/disc1.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
Expect(ff).To(HaveLen(2))
r, path, err := ff[0]()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(f2))
r.Close()
ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*")
Expect(ff).To(HaveLen(2))
r, path, err = ff[0]()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(f1))
r.Close()
})
DescribeTable("numbered match wins over shared fallback within a pattern",
func(discNumber, expectedIdx int) {
files := []string{
createFile("album/disc.jpg"),
createFile("album/disc1.jpg"),
createFile("album/disc2.jpg"),
}
reader := &discArtworkReader{
discNumber: discNumber,
imgFiles: files,
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(files[expectedIdx]))
},
Entry("disc 2 picks disc2.jpg over the shared disc.jpg", 2, 2),
Entry("disc 3 falls back to disc.jpg when no numbered match exists", 3, 0),
)
It("tries the next fallback candidate when the first one cannot be opened", func() {
f1 := createFile("album/cover.jpg")
f2 := createFile("album/cover.png")
// Remove f1 so Open will fail on it; f2 should still win.
removeFile(f1)
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "cover.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f2))
})
It("keeps scanning literal-pattern matches so fallback retry still works", func() {
// Guards against an 'early break on first literal match' optimization.
// Multiple imgFiles entries can share a basename (symlinks, case-variant
// duplicates on case-sensitive filesystems). If the loop breaks after
// recording just the first, the fallback retry cannot recover when
// that first file is unreadable.
f1 := createFile("album/stale/cover.png")
f2 := createFile("album/cover.png")
removeFile(f1)
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{
"album": true,
"album/stale": true,
},
isMultiFolder: true,
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "cover.png")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f2))
})
DescribeTable("filters by disc number for non-'*' wildcard patterns",
func(pattern string, discNumber, expectedIdx int) {
files := []string{
createFile("album/disc1.jpg"),
createFile("album/disc2.jpg"),
}
reader := &discArtworkReader{
discNumber: discNumber,
imgFiles: files,
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, pattern)
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(files[expectedIdx]))
},
Entry("disc?.jpg, target disc 1 → disc1.jpg", "disc?.jpg", 1, 0),
Entry("disc?.jpg, target disc 2 → disc2.jpg", "disc?.jpg", 2, 1),
Entry("disc[0-9].jpg, target disc 1 → disc1.jpg", "disc[0-9].jpg", 1, 0),
Entry("disc[0-9].jpg, target disc 2 → disc2.jpg", "disc[0-9].jpg", 2, 1),
)
It("matches file without number in multi-folder album by folder", func() {
f1 := createFile("album/cd1/disc.jpg")
f2 := createFile("album/cd2/disc.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
discFoldersRel: map[string]bool{"album/cd1": true},
isMultiFolder: true,
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1))
})
It("prefers disc number over folder when number is present", func() {
// disc2.jpg in cd1 folder should match disc 2, not disc 1
f1 := createFile("album/cd1/disc2.jpg")
reader := &discArtworkReader{
discNumber: 2,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album/cd1": true},
isMultiFolder: true,
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1))
})
It("does not match disc2.jpg when looking for disc 1", func() {
f1 := createFile("album/disc2.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
discFoldersRel: map[string]bool{"album": true},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
r, _, _ := sf()
Expect(r).To(BeNil())
})
})
Describe("fromDiscSubtitle", func() {
var (
ctx context.Context
tmpDir string
)
BeforeEach(func() {
ctx = context.Background()
tmpDir = GinkgoT().TempDir()
})
createFile := func(relPath string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath))
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
return relPath
}
It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() {
f1 := createFile("album/The Blue Disc.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1))
})
It("matches case-insensitively", func() {
f1 := createFile("album/bonus tracks.png")
reader := &discArtworkReader{
discNumber: 2,
imgFiles: []string{f1},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1))
})
It("returns error when no matching file found", func() {
f1 := createFile("album/cover.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
_, _, err := sf()
Expect(err).To(HaveOccurred())
})
It("matches first file when multiple extensions exist", func() {
f1 := createFile("album/The Blue Disc.jpg")
f2 := createFile("album/The Blue Disc.png")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1, f2},
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
sf := reader.fromDiscSubtitle(ctx, "The Blue Disc")
r, path, err := sf()
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
r.Close()
Expect(path).To(Equal(f1))
})
})
Describe("discArtworkReader", func() {
Describe("fromDiscArtPriority", func() {
var (
reader *discArtworkReader
tmpDir string
)
BeforeEach(func() {
tmpDir = GinkgoT().TempDir()
reader = &discArtworkReader{
discNumber: 2,
isMultiFolder: true,
discFoldersRel: map[string]bool{"music/album/cd2": true},
imgFiles: []string{
"music/album/cd1/disc.jpg",
"music/album/cd2/disc.jpg",
"music/album/cd2/disc2.jpg",
},
firstTrackRel: "music/album/cd2/track1.flac",
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
})
It("returns source funcs for glob patterns", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
Expect(ff).To(HaveLen(1))
})
It("returns source funcs for embedded pattern", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded")
Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag
})
It("handles multiple comma-separated patterns", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded")
Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag
})
It("ignores 'external' pattern silently", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "external")
Expect(ff).To(HaveLen(0))
})
It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() {
reader.imgFiles = nil
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
Expect(ff).To(HaveLen(0))
})
It("returns source func for discsubtitle pattern", func() {
reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}}
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
Expect(ff).To(HaveLen(1))
})
It("returns no source func for discsubtitle when disc has no subtitle", func() {
reader.album = model.Album{Discs: model.Discs{2: ""}}
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
Expect(ff).To(HaveLen(0))
})
})
})
})