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)) }) }) }) })