Files
navidrome/core/artwork/reader_disc_test.go
T
Deluan Quintão 49a14d4583 feat(artwork): add per-disc cover art support (#5182)
* feat(artwork): add KindDiscArtwork and ParseDiscArtworkID

Add new disc artwork kind with 'dc' prefix for per-disc cover art
support. The composite ID format is albumID:discNumber, parsed by
the new ParseDiscArtworkID helper.

* feat(conf): add DiscArtPriority configuration option

Default: 'disc*.*, cd*.*, embedded'. Controls how per-disc cover
art is resolved, following the same pattern as CoverArtPriority
and ArtistArtPriority.

* feat(artwork): implement extractDiscNumber helper

Extracts disc number from filenames based on glob patterns by
parsing leading digits from the wildcard-matched portion.
Used for matching disc-specific artwork files like disc1.jpg.

* feat(artwork): implement fromDiscExternalFile source function

Disc-aware variant of fromExternalFile that filters image files
by disc number (extracted from filename) or folder association
(for multi-folder albums).

* feat(artwork): implement discArtworkReader

Resolves disc artwork using DiscArtPriority config patterns.
Supports glob patterns with disc number extraction, embedded
images from first track, and falls back to album cover art.
Handles both multi-folder and single-folder multi-disc albums.

* feat(artwork): register disc artwork reader in dispatcher

Add KindDiscArtwork case to getArtworkReader switch, routing
disc artwork requests to the new discArtworkReader.

* feat(subsonic): add CoverArt field to DiscTitle response

Implements OpenSubsonic PR #220: optional cover art ID in
DiscTitle responses for per-disc artwork support.

* feat(subsonic): populate CoverArt in DiscTitle responses

Each DiscTitle now includes a disc artwork ID (dc-albumID:discNum)
that clients can use with getCoverArt to retrieve per-disc artwork.

* style: fix file permission in test to satisfy gosec

* feat(ui): add disc cover art display and lightbox functionality

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

* refactor: simplify disc artwork code

- Add DiscArtworkID constructor to encapsulate the "albumID:discNumber"
  format in one place
- Convert fromDiscExternalFile to a method on discArtworkReader,
  reducing parameter count from 6 to 2
- Remove unused rootFolder field from discArtworkReader

* style: fix prettier formatting in subsonic index

* style(ui): move cursor style to makeStyles in SongDatagrid

* feat(artwork): add discsubtitle option to DiscArtPriority

Allow matching disc cover art by the disc's subtitle/name.
When the "discsubtitle" keyword is in the priority list, image files
whose stem matches the disc subtitle (case-insensitive) are used.
This is useful for box sets with named discs (e.g., "The Blue Disc.jpg").

* feat(configuration): update discartpriority to include cover art options

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-13 18:33:18 -04:00

286 lines
8.3 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),
// Pattern doesn't match
Entry("cover.jpg doesn't match disc*.*", "disc*.*", "cover.jpg", 0, false),
// Pattern with no wildcard before dot
Entry("front1.jpg with front*.*", "front*.*", "front1.jpg", 1, true),
)
})
Describe("fromExternalFile", func() {
var (
ctx context.Context
tmpDir string
)
BeforeEach(func() {
ctx = context.Background()
tmpDir = GinkgoT().TempDir()
})
createFile := func(path string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
return fullPath
}
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},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
}
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("skips file without number in single-folder album", func() {
f1 := createFile("album/disc.jpg")
reader := &discArtworkReader{
discNumber: 1,
imgFiles: []string{f1},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
}
sf := reader.fromExternalFile(ctx, "disc*.*")
r, _, _ := sf()
Expect(r).To(BeNil())
})
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},
discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true},
isMultiFolder: true,
}
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},
discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true},
isMultiFolder: true,
}
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},
discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true},
}
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(path string) string {
fullPath := filepath.Join(tmpDir, filepath.FromSlash(path))
Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed())
Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed())
return fullPath
}
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},
}
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},
}
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},
}
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},
}
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
BeforeEach(func() {
reader = &discArtworkReader{
discNumber: 2,
isMultiFolder: true,
discFolders: map[string]bool{"/music/album/cd2": true},
imgFiles: []string{
"/music/album/cd1/disc.jpg",
"/music/album/cd2/disc.jpg",
"/music/album/cd2/disc2.jpg",
},
firstTrackPath: "/music/album/cd2/track1.flac",
}
})
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))
})
})
})
})