mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
test(artwork): restore resolution edge-case e2e coverage
The serving cutover removed the album/disc/artist/mediafile/playlist/radio e2e specs that documented the folder-selection rules and guarded the #5376/#5456/ #5451/#5457 regressions; nothing replaced them, so compareImageFiles and the parent-fallback logic were left untested. Restore them driving the real pipeline: a real scanner populates the folder graph from an in-memory library, the real Worker drains the queue, and the real Service serves. Folder-backed art is file-backed (served via os.Open, which the in-memory FS can't satisfy) so its selection is asserted on the persisted state row; store-backed and real-disk sources are asserted byte-for-byte. Single-disc disc resolution now serves album art directly, so only multi-disc disc scenarios are ported.
This commit is contained in:
parent
fc8b4a7ea3
commit
0064de7bd5
435
core/artwork/e2e/album_test.go
Normal file
435
core/artwork/e2e/album_test.go
Normal file
@ -0,0 +1,435 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Folder-backed album art is served via os.Open(SourcePath), which the in-memory library FS
|
||||
// cannot satisfy; the worker's persisted state row (Source + SourcePath) is the resolver's
|
||||
// selection, so folder scenarios assert on it. Embedded art lands in the content-addressed store
|
||||
// and is asserted byte-for-byte; a no-art album settles absent.
|
||||
var _ = Describe("Album artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
expectFolderCover := expectAlbumFolderCover
|
||||
expectAbsent := expectAlbumAbsent
|
||||
|
||||
When("an album has a single folder with cover.jpg at the album root", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg ← matched by cover.*
|
||||
It("returns the album-root cover", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/navidrome/navidrome/issues/5376
|
||||
// cover.* basenames tie across album-root and per-disc folders;
|
||||
// compareImageFiles must prefer shallower paths.
|
||||
When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.jpg ← should not win
|
||||
// ├── CD2/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.jpg
|
||||
// └── cover.jpg ← should win (album-root fallback)
|
||||
It("prefers the album-root cover over per-disc covers", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
"Artist/Album/CD1/cover.jpg": smallPNG("disc1"),
|
||||
"Artist/Album/CD2/cover.jpg": smallPNG("disc2"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
Expect(al.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: the two disc subfolders should form one multi-disc album")
|
||||
expectFolderCover(al, "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/navidrome/navidrome/issues/5376
|
||||
// folder.jpg basenames tie across album-root and per-disc folders;
|
||||
// compareImageFiles must prefer shallower paths.
|
||||
When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg ← should not win
|
||||
// ├── CD2/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// └── folder.jpg ← should win (album-root fallback)
|
||||
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||
"Artist/Album/folder.jpg": smallPNG("album-root"),
|
||||
"Artist/Album/CD1/folder.jpg": smallPNG("disc1"),
|
||||
"Artist/Album/CD2/folder.jpg": smallPNG("disc2"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/navidrome/navidrome/issues/5376
|
||||
// Single-subfolder albums must still consider the parent folder's images.
|
||||
When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── disc1/
|
||||
// │ └── 01 - Track.mp3
|
||||
// └── cover.jpg ← should win (parent-folder fallback)
|
||||
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/navidrome/navidrome/issues/5456
|
||||
When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() {
|
||||
// Album/ (top-level folder, Path=".")
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// ├── CD2/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// └── cover.jpg ← should win (album-root)
|
||||
It("prefers the album-root cover.jpg", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||
"Album/cover.jpg": smallPNG("album-root"),
|
||||
"Album/CD1/folder.jpg": smallPNG("disc1"),
|
||||
"Album/CD2/folder.jpg": smallPNG("disc2"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
|
||||
// └── cover.jpg
|
||||
It("returns the embedded image", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||
"Artist/Album/cover.jpg": smallPNG("external"),
|
||||
})
|
||||
scan()
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
ia := acquire(model.KindAlbumArtwork, firstAlbum().ID)
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
|
||||
When("CoverArtPriority lists external first but no external file is present", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
|
||||
It("falls through to embedded artwork", func() {
|
||||
conf.Server.CoverArtPriority = "external, embedded"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||
})
|
||||
scan()
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
ia := acquire(model.KindAlbumArtwork, firstAlbum().ID)
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
|
||||
When("the only cover file uses uppercase extension and a different case in its name", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── Cover.JPG ← matched case-insensitively by cover.*
|
||||
It("matches case-insensitively against cover.*", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/Cover.JPG": smallPNG("case-insensitive"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/Cover.JPG")
|
||||
})
|
||||
})
|
||||
|
||||
When("two cover files have basenames that tie under the natural-sort tiebreaker", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// ├── cover.jpg ← wins (no numeric suffix)
|
||||
// └── cover.1.jpg
|
||||
It("prefers the file without a numeric suffix", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("primary"),
|
||||
"Artist/Album/cover.1.jpg": smallPNG("secondary"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("the album has no cover and CoverArtPriority lists only file patterns", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 (no image files — settles absent)
|
||||
It("settles absent", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
})
|
||||
scan()
|
||||
expectAbsent(firstAlbum())
|
||||
})
|
||||
})
|
||||
|
||||
// Doc scenarios from:
|
||||
// https://www.navidrome.org/docs/usage/library/artwork/#albums
|
||||
// Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external".
|
||||
When("only folder.jpg is present (cover.* and front.* missing)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── folder.jpg ← matched by folder.*
|
||||
It("falls through to folder.jpg", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("only front.jpg is present (cover.* and folder.* missing)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── front.jpg ← matched by front.*
|
||||
It("falls through to front.jpg", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/front.jpg": smallPNG("front"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/front.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("cover.*, folder.*, and front.* all exist in the same folder", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// ├── cover.jpg ← wins (cover.* is first in priority)
|
||||
// ├── folder.jpg
|
||||
// └── front.jpg
|
||||
It("prefers cover.* (first in CoverArtPriority)", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||
"Artist/Album/front.jpg": smallPNG("front"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("only folder.* and front.* exist (priority order check)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// ├── folder.jpg ← wins (folder.* comes before front.*)
|
||||
// └── front.jpg
|
||||
It("prefers folder.* over front.*", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||
"Artist/Album/front.jpg": smallPNG("front"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/folder.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("three cover files tie by basename and differ only by numeric suffix", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// ├── cover.jpg ← wins (no numeric suffix)
|
||||
// ├── cover.1.jpg
|
||||
// └── cover.2.jpg
|
||||
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.2.jpg": smallPNG("second"),
|
||||
"Artist/Album/cover.jpg": smallPNG("primary"),
|
||||
"Artist/Album/cover.1.jpg": smallPNG("first"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("CoverArtPriority contains an unknown pattern before a matching one", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
|
||||
It("skips the unknown pattern and falls through to the matching one", func() {
|
||||
conf.Server.CoverArtPriority = "bogus.*, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
// Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder
|
||||
// fallback can pick up images from the ARTIST folder, serving the artist
|
||||
// thumbnail as album art for any album without its own image files.
|
||||
When("an album has no images and the artist folder has folder.jpg", func() {
|
||||
// Artist/
|
||||
// ├── folder.jpg ← artist thumbnail, must NOT become album art
|
||||
// ├── Album A/
|
||||
// │ └── 01 - Track.mp3 (no images)
|
||||
// └── Album B/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg
|
||||
It("does not use the artist image as album art", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
"Artist/Album B/cover.jpg": smallPNG("album-b"),
|
||||
})
|
||||
scan()
|
||||
|
||||
expectAbsent(albumByName("Album A"))
|
||||
expectFolderCover(albumByName("Album B"), "Artist/Album B/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("a single-disc album is spread across sibling folders under the artist folder", func() {
|
||||
// Artist/
|
||||
// ├── folder.jpg ← artist thumbnail, must NOT become album art
|
||||
// ├── Album A/
|
||||
// │ └── 01 - Track.mp3 (album: "Album A")
|
||||
// ├── Album A bonus/
|
||||
// │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder)
|
||||
// └── Album B/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg
|
||||
It("does not use the artist image as album art for the spread album", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
"Artist/Album B/cover.jpg": smallPNG("album-b"),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
Expect(alA.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: the two sibling folders should form one spread album")
|
||||
expectAbsent(alA)
|
||||
})
|
||||
})
|
||||
|
||||
When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() {
|
||||
// Artist/
|
||||
// ├── cover.jpg ← artist image; matches cover.* (first pattern),
|
||||
// │ must NOT shadow the album's own front.jpg
|
||||
// ├── Album A/
|
||||
// │ ├── 01 - Track.mp3 (album: "Album A")
|
||||
// │ └── front.jpg ← should win
|
||||
// ├── Album A bonus/
|
||||
// │ └── 02 - Track.mp3 (album: "Album A")
|
||||
// └── Album B/
|
||||
// └── 01 - Track.mp3 other-album audio, so the artist folder is
|
||||
// correctly rejected as Album A's root
|
||||
It("prefers the album's own art over the artist image", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/cover.jpg": smallPNG("artist-image"),
|
||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album A/front.jpg": smallPNG("album-a-front"),
|
||||
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
|
||||
})
|
||||
scan()
|
||||
|
||||
alA := albumByName("Album A")
|
||||
Expect(alA.FolderIDs).To(HaveLen(2),
|
||||
"sanity check: the two sibling folders should form one spread album")
|
||||
expectFolderCover(alA, "Artist/Album A/front.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 (no embedded picture)
|
||||
// └── cover.jpg ← wins (embedded skipped, falls through)
|
||||
It("falls through to the next priority entry", func() {
|
||||
conf.Server.CoverArtPriority = "embedded, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||
})
|
||||
scan()
|
||||
expectFolderCover(firstAlbum(), "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
})
|
||||
159
core/artwork/e2e/artist_test.go
Normal file
159
core/artwork/e2e/artist_test.go
Normal file
@ -0,0 +1,159 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Doc reference:
|
||||
// https://www.navidrome.org/docs/usage/library/artwork/#artists
|
||||
// Default ArtistArtPriority is "artist.*, album/artist.*, external".
|
||||
// Library-folder images are file-backed (asserted on the worker state row); uploaded and
|
||||
// image-folder images are real files on disk (asserted byte-for-byte).
|
||||
var _ = Describe("Artist artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("the artist folder contains an artist.jpg", func() {
|
||||
// Artist/
|
||||
// ├── artist.jpg ← matched by artist.*
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3
|
||||
It("returns the artist.* image from the artist folder", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("artist.* only exists inside an album folder", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── artist.jpg ← matched by album/artist.*
|
||||
It("falls through to album/artist.* and returns that image", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("both the artist folder and an album folder have an artist.* image", func() {
|
||||
// Artist/
|
||||
// ├── artist.jpg ← wins (artist.* before album/artist.*)
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── artist.jpg
|
||||
It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("ArtistArtPriority uses album/<arbitrary pattern> (not just album/artist.*)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── artist.jpg ← matched by album/artist.*
|
||||
It("resolves the pattern against the artist's album image files", func() {
|
||||
conf.Server.ArtistArtPriority = "album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||
})
|
||||
scan()
|
||||
expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("an artist has an uploaded image and a matching artist.* file", func() {
|
||||
// <DataFolder>/
|
||||
// └── artwork/
|
||||
// └── artist/
|
||||
// └── <id>_upload.jpg ← wins (uploaded image beats the priority chain)
|
||||
// Library:
|
||||
// Artist/
|
||||
// ├── artist.jpg (ignored — uploaded image comes first)
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3
|
||||
It("prefers the uploaded image over any priority-chain match", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||
})
|
||||
scan()
|
||||
ar := soleArtist()
|
||||
|
||||
uploaded := ar.ID + "_upload.jpg"
|
||||
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
||||
ar.UploadedImage = uploaded
|
||||
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("artist-uploaded")))
|
||||
})
|
||||
})
|
||||
|
||||
When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() {
|
||||
// <ArtistImageFolder>/
|
||||
// └── Artist.jpg ← matched by artist name (image-folder source)
|
||||
// Library:
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 (no artist.* present in library)
|
||||
It("returns the image from the configured artist image folder", func() {
|
||||
imgFolder := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), pngBytes("image-folder"), 0o600)).To(Succeed())
|
||||
conf.Server.ArtistImageFolder = imgFolder
|
||||
conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
|
||||
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||
})
|
||||
scan()
|
||||
|
||||
ar := soleArtist()
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("image-folder")))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func soleArtist() model.Artist {
|
||||
GinkgoHelper()
|
||||
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
if len(artists) == 0 {
|
||||
Fail("sole artist not found")
|
||||
return model.Artist{}
|
||||
}
|
||||
return artists[0]
|
||||
}
|
||||
328
core/artwork/e2e/disc_test.go
Normal file
328
core/artwork/e2e/disc_test.go
Normal file
@ -0,0 +1,328 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Disc art is a serve-time read through the library FS (no worker state row), so per-disc images
|
||||
// are asserted byte-for-byte. Only multi-disc albums use the disc chain; a single-disc album serves
|
||||
// its album art directly (a deliberate change from the legacy reader), so those cases are covered by
|
||||
// the album suite. Album-root covers here are folder-backed and asserted on the album state row.
|
||||
var _ = Describe("Disc artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("a multi-disc album has per-disc covers", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── disc1.jpg ← matches request for disc 1
|
||||
// └── CD2/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── disc2.jpg ← matches request for disc 2
|
||||
It("returns the requested disc's image", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 2, "disc-2")
|
||||
})
|
||||
})
|
||||
|
||||
When("multiple disc images exist in the same folder (disc1 vs disc10)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 (disc 1)
|
||||
// ├── 02 - Track.mp3 (disc 10)
|
||||
// ├── disc1.jpg ← matches request for disc 1
|
||||
// └── disc10.jpg
|
||||
It("matches the requested disc number, not a higher-numbered one", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/02 - Track.mp3": trackFile(2, "T10", map[string]any{"disc": "10"}),
|
||||
"Artist/Album/disc1.jpg": smallPNG("disc-one"),
|
||||
"Artist/Album/disc10.jpg": smallPNG("disc-ten"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "disc-one")
|
||||
})
|
||||
})
|
||||
|
||||
When("a multi-disc album has no per-disc image but has an album cover", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ └── 01 - Track.mp3
|
||||
// ├── CD2/
|
||||
// │ └── 01 - Track.mp3
|
||||
// └── cover.jpg ← album-level fallback (no disc art present)
|
||||
It("falls back to the album cover", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "album-cover")
|
||||
})
|
||||
})
|
||||
|
||||
When("a multi-disc album has no per-disc image and no album cover", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ └── 01 - Track.mp3
|
||||
// └── CD2/
|
||||
// └── 01 - Track.mp3 (no images anywhere — nothing to serve)
|
||||
It("reports the disc lookup as unavailable", func() {
|
||||
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
})
|
||||
scan()
|
||||
Expect(serveErr(discArtID(firstAlbum(), 1))).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
|
||||
When("DiscArtPriority is the empty string", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── disc1.jpg (ignored — DiscArtPriority is empty)
|
||||
// ├── CD2/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cd2.png (ignored — DiscArtPriority is empty)
|
||||
// └── cover.jpg ← used for every disc (album-level fallback)
|
||||
It("skips every disc-level source and returns the album cover", func() {
|
||||
conf.Server.DiscArtPriority = ""
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": smallPNG("cd-2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
for _, n := range []int{1, 2} {
|
||||
expectDiscImage(al, n, "album-cover")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Doc scenarios from:
|
||||
// https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art
|
||||
// Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded".
|
||||
When("a disc subfolder has a cd2.png image", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── disc1.jpg
|
||||
// └── CD2/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cd2.png ← matched by cd*.* for disc 2
|
||||
It("matches via the cd*.* pattern", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/cd2.png": smallPNG("cd-2"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 2, "cd-2")
|
||||
})
|
||||
})
|
||||
|
||||
When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.jpg ← matched by cover.* inside disc folder
|
||||
// └── CD2/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.jpg
|
||||
It("falls through to cover.* inside the disc folder", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/cover.jpg": smallPNG("disc1-cover"),
|
||||
"Artist/Album/CD2/cover.jpg": smallPNG("disc2-cover"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "disc1-cover")
|
||||
})
|
||||
})
|
||||
|
||||
When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── disc1/
|
||||
// │ ├── disc1.jpg ← matched by disc*.* for disc 1
|
||||
// │ └── 01 - Track.mp3
|
||||
// ├── disc2/
|
||||
// │ ├── cd2.png ← matched by cd*.* for disc 2
|
||||
// │ └── 01 - Track.mp3
|
||||
// └── cover.jpg ← album-level cover
|
||||
It("matches the per-disc image for each disc and the album-root cover for the album", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/disc1/disc1.jpg": smallPNG("disc-1"),
|
||||
"Artist/Album/disc2/cd2.png": smallPNG("cd-2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
expectDiscImage(al, 1, "disc-1")
|
||||
expectDiscImage(al, 2, "cd-2")
|
||||
expectAlbumFolderCover(al, "Artist/Album/cover.jpg")
|
||||
})
|
||||
})
|
||||
|
||||
When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ └── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
||||
// ├── CD2/
|
||||
// │ └── 01 - Track.mp3
|
||||
// └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword for disc 1
|
||||
It("selects the subtitle-named image", func() {
|
||||
conf.Server.DiscArtPriority = "discsubtitle"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/Bonus Tracks.jpg": smallPNG("bonus-tracks"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "bonus-tracks")
|
||||
})
|
||||
})
|
||||
|
||||
When("discsubtitle is set but no image filename matches the subtitle", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
||||
// │ └── cover.jpg ← wins (discsubtitle has no match, falls through)
|
||||
// └── CD2/
|
||||
// └── 01 - Track.mp3
|
||||
It("falls through to the next priority entry", func() {
|
||||
conf.Server.DiscArtPriority = "discsubtitle, cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/cover.jpg": smallPNG("disc1-cover"),
|
||||
})
|
||||
scan()
|
||||
expectDiscImage(firstAlbum(), 1, "disc1-cover")
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/navidrome/navidrome/issues/5456
|
||||
// Top-level album variant — album folder at library root (Path=".").
|
||||
When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() {
|
||||
// Album/ (top-level, Path=".")
|
||||
// ├── cover.jpg ← album-level cover
|
||||
// ├── Disc 01/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg ← disc 1 art
|
||||
// ├── Disc 02/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// └── Disc 03/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── folder.jpg
|
||||
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
layout := fstest.MapFS{
|
||||
"Album/cover.jpg": smallPNG("album-root-cover"),
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
prefix := fmt.Sprintf("Album/Disc %02d/", i)
|
||||
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)})
|
||||
layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", i))
|
||||
}
|
||||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
expectAlbumFolderCover(al, "Album/cover.jpg")
|
||||
for i := 1; i <= 3; i++ {
|
||||
expectDiscImage(al, i, fmt.Sprintf("disc-%02d-folder", i))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Reproduces https://github.com/navidrome/navidrome/issues/5456
|
||||
// Deeply nested layout matching the reporter's actual structure.
|
||||
When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() {
|
||||
// Pop; Rock/Grateful Dead/(2001) The Golden Road/ ← album root with cover.jpg
|
||||
// ├── cover.jpg ← album-level cover
|
||||
// ├── Disc 01 (Subtitle)/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg ← disc 1 art
|
||||
// ├── Disc 02 (Subtitle)/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── folder.jpg
|
||||
// └── ... (6 discs)
|
||||
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
discNames := []string{
|
||||
"Disc 01 (Birth of the Dead - The Studio Sides)",
|
||||
"Disc 02 (Birth of the Dead - The Live Sides)",
|
||||
"Disc 03 (The Grateful Dead)",
|
||||
"Disc 04 (Anthem of the Sun)",
|
||||
"Disc 05 (Aoxomoxoa)",
|
||||
"Disc 06 (Live; Dead)",
|
||||
}
|
||||
layout := fstest.MapFS{
|
||||
"Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": smallPNG("album-root-cover"),
|
||||
}
|
||||
for i, name := range discNames {
|
||||
discNum := i + 1
|
||||
prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name)
|
||||
layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)})
|
||||
layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", discNum))
|
||||
}
|
||||
setLayout(layout)
|
||||
scan()
|
||||
|
||||
al := firstAlbum()
|
||||
expectAlbumFolderCover(al, "(2001) The Golden Road/cover.jpg")
|
||||
for i := range discNames {
|
||||
discNum := i + 1
|
||||
expectDiscImage(al, discNum, fmt.Sprintf("disc-%02d-folder", discNum))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
127
core/artwork/e2e/mediafile_test.go
Normal file
127
core/artwork/e2e/mediafile_test.go
Normal file
@ -0,0 +1,127 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Doc reference:
|
||||
// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles
|
||||
// Navidrome resolves mediafile artwork in this order:
|
||||
// 1. Embedded image from the mediafile itself
|
||||
// 2. For multi-disc albums, disc-level artwork
|
||||
// 3. Album cover art
|
||||
//
|
||||
// Embedded art lands in the content-addressed store (asserted byte-for-byte); disc-level art is a
|
||||
// serve-time read through the library FS.
|
||||
var _ = Describe("MediaFile artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("a multi-disc album track has no embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── disc1.jpg
|
||||
// ├── CD2/
|
||||
// │ ├── 01 - Track.mp3 ← track requested
|
||||
// │ └── disc2.jpg ← wins (disc-level before album-level)
|
||||
// └── cover.jpg
|
||||
It("falls back to the disc-level artwork (not the album cover)", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"),
|
||||
"Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
|
||||
Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("disc-2")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a single-disc album track has no embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── 01 - Track.mp3 ← track requested
|
||||
// └── cover.jpg ← wins (album-level fallback, no disc subfolder)
|
||||
It("falls back to the album cover", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
||||
Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-cover")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// ├── CD1/
|
||||
// │ └── 01 - Track.mp3
|
||||
// ├── CD2/
|
||||
// │ └── 01 - Track.mp3 ← track requested
|
||||
// └── cover.jpg ← wins (no disc image → album-level fallback)
|
||||
It("falls through from disc to album cover", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3")
|
||||
Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-root")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a track has its own embedded art", func() {
|
||||
// Artist/
|
||||
// └── Album/
|
||||
// └── 01 - Track.mp3 ← has embedded picture (wins over every fallback)
|
||||
It("resolves the track's embedded image into the store", func() {
|
||||
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||
})
|
||||
scan()
|
||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||
|
||||
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
||||
ia := acquire(model.KindMediaFileArtwork, mf.ID)
|
||||
Expect(ia.Source).To(Equal("embedded"))
|
||||
Expect(storedBytes(ia)).To(Equal(embeddedArtBytes))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func mediafileOn(relPath string) model.MediaFile {
|
||||
GinkgoHelper()
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"media_file.path": relPath},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
if len(mfs) == 0 {
|
||||
Fail("mediafile not found: " + relPath)
|
||||
return model.MediaFile{}
|
||||
}
|
||||
return mfs[0]
|
||||
}
|
||||
167
core/artwork/e2e/playlist_test.go
Normal file
167
core/artwork/e2e/playlist_test.go
Normal file
@ -0,0 +1,167 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Playlist artwork resolves in this priority order:
|
||||
// 1. Uploaded image (<DataFolder>/artwork/playlist/<file>)
|
||||
// 2. Sidecar image next to the .m3u file (same basename, any image ext)
|
||||
// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed)
|
||||
// 4. Generated 2x2 tiled cover from the playlist's albums
|
||||
// 5. Absent
|
||||
//
|
||||
// The library is an in-memory FS, but uploaded/sidecar/local-external images are real files on
|
||||
// disk — the resolver reads them via os.Open, so those tests place them in a real tempdir.
|
||||
var _ = Describe("Playlist artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("a playlist has an uploaded image", func() {
|
||||
// <DataFolder>/
|
||||
// └── artwork/
|
||||
// └── playlist/
|
||||
// └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority)
|
||||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", pngBytes("playlist-upload"))
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"})
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("playlist-upload")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() {
|
||||
// <tempdir>/
|
||||
// ├── MyList.m3u
|
||||
// └── MyList.jpg ← matched by sidecar (same basename, case-insensitive)
|
||||
It("returns the sidecar image", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
m3uPath := filepath.Join(dir, "MyList.m3u")
|
||||
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), pngBytes("sidecar"), 0o600)).To(Succeed())
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath})
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist's sidecar uses a different extension case", func() {
|
||||
// <tempdir>/
|
||||
// ├── MyList.m3u
|
||||
// └── MyList.PNG ← matched case-insensitively
|
||||
It("matches case-insensitively", func() {
|
||||
dir := GinkgoT().TempDir()
|
||||
m3uPath := filepath.Join(dir, "MyList.m3u")
|
||||
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), pngBytes("sidecar-png"), 0o600)).To(Succeed())
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath})
|
||||
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar-png")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has an ExternalImageURL pointing to a local file", func() {
|
||||
// <tempdir>/
|
||||
// └── cover.jpg ← absolute path stored in ExternalImageURL
|
||||
It("returns the local file regardless of EnableM3UExternalAlbumArt", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle
|
||||
dir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(dir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, pngBytes("external-local"), 0o600)).To(Succeed())
|
||||
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath})
|
||||
|
||||
Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("external-local")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() {
|
||||
// (no local files — the http source is gated off, so resolution settles absent)
|
||||
It("skips the URL and settles absent", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"})
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
|
||||
|
||||
img, err := rsvc.GetOrPlaceholder(rctx, pl.CoverArtID().String(), 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer img.Close()
|
||||
Expect(img.Placeholder).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has no images and no tracks", func() {
|
||||
// (no uploaded/sidecar/external image and no album art to sample)
|
||||
It("settles absent", func() {
|
||||
pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"})
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
|
||||
When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() {
|
||||
// Library:
|
||||
// Artist/
|
||||
// ├── AlbumA/
|
||||
// │ ├── 01 - Track.mp3
|
||||
// │ └── cover.png ← tile 1 source
|
||||
// └── AlbumB/
|
||||
// ├── 01 - Track.mp3
|
||||
// └── cover.png ← tile 2 source
|
||||
// Playlist "pl-7" references tracks from both albums, so the worker generates a tiled
|
||||
// cover from 2 distinct album art tiles (mirrored to fill the 2x2 grid).
|
||||
It("generates a tiled cover from album art", func() {
|
||||
conf.Server.CoverArtPriority = "cover.*"
|
||||
setLayout(fstest.MapFS{
|
||||
"Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}),
|
||||
"Artist/AlbumA/cover.png": smallPNG("albumA"),
|
||||
"Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}),
|
||||
"Artist/AlbumB/cover.png": smallPNG("albumB"),
|
||||
})
|
||||
scan()
|
||||
|
||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mfs).To(HaveLen(2))
|
||||
|
||||
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
||||
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||
Expect(ia.Source).To(Equal("generated"))
|
||||
data := storedBytes(ia)
|
||||
// The tiled cover is a PNG-encoded image; exact bytes vary (random album order).
|
||||
Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func putPlaylist(pl model.Playlist) model.Playlist {
|
||||
GinkgoHelper()
|
||||
if pl.OwnerID == "" {
|
||||
pl.OwnerID = "admin-1"
|
||||
}
|
||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||
return pl
|
||||
}
|
||||
45
core/artwork/e2e/radio_test.go
Normal file
45
core/artwork/e2e/radio_test.go
Normal file
@ -0,0 +1,45 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Radio art is uploaded-image-only, with no fallback. Uploads are real files on disk, so they
|
||||
// serve back byte-for-byte; a radio with no upload settles absent.
|
||||
var _ = Describe("Radio artwork resolution", func() {
|
||||
BeforeEach(func() {
|
||||
setupResolutionHarness()
|
||||
})
|
||||
|
||||
When("a radio has an uploaded image", func() {
|
||||
// <DataFolder>/
|
||||
// └── artwork/
|
||||
// └── radio/
|
||||
// └── rd-1_logo.jpg ← matched by UploadedImagePath()
|
||||
It("returns the uploaded image bytes", func() {
|
||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
Expect(ia.Source).To(Equal("upload"))
|
||||
Expect(serveBytes(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(Equal(pngBytes("radio-logo")))
|
||||
})
|
||||
})
|
||||
|
||||
When("a radio has no uploaded image", func() {
|
||||
// (no files on disk — the resolver has no sources to fall back to)
|
||||
It("settles absent", func() {
|
||||
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||
|
||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(MatchError(artwork.ErrUnavailable))
|
||||
})
|
||||
})
|
||||
})
|
||||
340
core/artwork/e2e/resolution_harness_test.go
Normal file
340
core/artwork/e2e/resolution_harness_test.go
Normal file
@ -0,0 +1,340 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
// This harness restores the pre-cutover artwork resolution edge-case coverage, but drives it
|
||||
// through the real pipeline: a real scanner populates the folder graph from an in-memory library,
|
||||
// the real Worker drains the queue to resolve/persist state, and the real Service serves it.
|
||||
// It documents the folder-selection rules (album/disc/artist priority, #5376/#5456/#5451/#5457)
|
||||
// that the lightweight acquire_serve_test.go intentionally leaves to this suite.
|
||||
|
||||
const fakeLibScheme = "artworkfake"
|
||||
const fakeLibPath = fakeLibScheme + ":///music"
|
||||
|
||||
const (
|
||||
defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external"
|
||||
defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded"
|
||||
)
|
||||
|
||||
var (
|
||||
rctx context.Context
|
||||
rds *tests.MockDataStore
|
||||
rstore *artwork.ImageStore
|
||||
rsvc artwork.Service
|
||||
rworker *artwork.Worker
|
||||
fakeFS *storagetest.FakeFS
|
||||
)
|
||||
|
||||
// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps the file open for the
|
||||
// whole suite, and Ginkgo's per-spec TempDir cleanup can't unlink a file with a live handle on
|
||||
// Windows. A suite-level tempdir plus an AfterSuite close avoids the lock conflict.
|
||||
var suiteDBTempDir string
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
suiteDBTempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
db.Close(context.Background())
|
||||
})
|
||||
|
||||
func setupResolutionHarness() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
tempDir := GinkgoT().TempDir()
|
||||
conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL"
|
||||
conf.Server.DataFolder = conf.NewDir(tempDir)
|
||||
conf.Server.MusicFolder = fakeLibPath
|
||||
conf.Server.DevExternalScanner = false
|
||||
conf.Server.ImageCacheSize = "0"
|
||||
conf.Server.EnableExternalServices = false
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
conf.Server.ArtworkWorkerConcurrency = 1
|
||||
|
||||
db.Db().SetMaxOpenConns(1)
|
||||
rctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true})
|
||||
db.Init(rctx)
|
||||
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
|
||||
|
||||
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
|
||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||
Expect(rds.User(rctx).Put(&adminUser)).To(Succeed())
|
||||
|
||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||
Expect(rds.Library(rctx).Put(&lib)).To(Succeed())
|
||||
Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadEmbeddedFixture()
|
||||
|
||||
fakeFS = &storagetest.FakeFS{}
|
||||
storagetest.Register(fakeLibScheme, fakeFS)
|
||||
|
||||
ffm := tests.NewMockFFmpeg("")
|
||||
rstore = artwork.NewImageStore(filepath.Join(tempDir, "store"))
|
||||
// size=0 requests stream originals and never touch the resize cache, so this reader is a
|
||||
// compile-time stand-in only; the resize path is covered by the package's serving_test.
|
||||
imgCache := cache.NewFileCache("ArtworkResolutionE2E", "100MB", "images", 0,
|
||||
func(context.Context, cache.Item) (io.Reader, error) {
|
||||
return nil, fmt.Errorf("resize not exercised in e2e")
|
||||
})
|
||||
Eventually(func() bool { return imgCache.Available(rctx) }).Should(BeTrue())
|
||||
|
||||
rsvc = artwork.NewService(rds, imgCache, rstore, ffm)
|
||||
rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache)
|
||||
}
|
||||
|
||||
// setLayout populates the fake library. All paths must be forward-slash and relative.
|
||||
func setLayout(files fstest.MapFS) {
|
||||
GinkgoHelper()
|
||||
fakeFS.SetFiles(files)
|
||||
}
|
||||
|
||||
func scan() {
|
||||
GinkgoHelper()
|
||||
s := scanner.New(rctx, rds, events.NoopBroker(),
|
||||
playlists.NewPlaylists(rds, artwork.NewUploader(rds)), metrics.NewNoopInstance())
|
||||
_, err := s.ScanAll(rctx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
// acquire drives the worker to resolve one entity and returns its persisted state row. It fails if
|
||||
// the worker never settles (found or absent) within the timeout.
|
||||
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
||||
GinkgoHelper()
|
||||
rworker.Bump(kind.Prefix(), id)
|
||||
var ia *model.ItemArtwork
|
||||
runResolutionWorkerUntil(func() bool {
|
||||
got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ia = got
|
||||
return true
|
||||
})
|
||||
return *ia
|
||||
}
|
||||
|
||||
func runResolutionWorkerUntil(until func() bool) {
|
||||
GinkgoHelper()
|
||||
runCtx, cancel := context.WithCancel(rctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- rworker.Run(runCtx) }()
|
||||
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||
cancel()
|
||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||
}
|
||||
|
||||
// serveBytes reads an artwork ID through the real Service at full size and returns its bytes.
|
||||
func serveBytes(artID model.ArtworkID) []byte {
|
||||
GinkgoHelper()
|
||||
img, err := rsvc.Get(rctx, artID, 0, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer img.Close()
|
||||
data, err := io.ReadAll(img)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
func serveErr(artID model.ArtworkID) error {
|
||||
img, err := rsvc.Get(rctx, artID, 0, false)
|
||||
if img != nil {
|
||||
img.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// expectAlbumFolderCover asserts the worker selected a folder image at the given path suffix as the
|
||||
// album cover. Folder art is file-backed (served via os.Open, which the in-memory FS can't satisfy),
|
||||
// so the persisted state row — which captures the resolver's selection — is what we assert.
|
||||
func expectAlbumFolderCover(al model.Album, suffix string) {
|
||||
GinkgoHelper()
|
||||
ia := acquire(model.KindAlbumArtwork, al.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(ia.SourcePath).To(HaveSuffix(suffix))
|
||||
}
|
||||
|
||||
// expectAlbumAbsent asserts the album settled absent (no source resolved) and serves unavailable.
|
||||
func expectAlbumAbsent(al model.Album) {
|
||||
GinkgoHelper()
|
||||
ia := acquire(model.KindAlbumArtwork, al.ID)
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(serveErr(al.CoverArtID())).To(MatchError(artwork.ErrUnavailable))
|
||||
}
|
||||
|
||||
// expectArtistFolder asserts the worker selected a library folder image (artist.*, album/artist.*)
|
||||
// as the artist image; like album folder art it is file-backed, so it is asserted on the state row.
|
||||
func expectArtistFolder(ar model.Artist, suffix string) {
|
||||
GinkgoHelper()
|
||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||
Expect(ia.Source).To(Equal("folder"))
|
||||
Expect(ia.SourcePath).To(HaveSuffix(suffix))
|
||||
}
|
||||
|
||||
// writeUploadedImage drops raw bytes into the per-entity upload folder under DataFolder, matching
|
||||
// the layout model.UploadedImagePath expects. Uploads are real files on disk, so they serve back.
|
||||
func writeUploadedImage(entity, filename string, data []byte) {
|
||||
GinkgoHelper()
|
||||
dst := model.UploadedImagePath(entity, filename)
|
||||
Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed())
|
||||
Expect(os.WriteFile(dst, data, 0o600)).To(Succeed())
|
||||
}
|
||||
|
||||
// discArtID is the artwork ID for one disc of an album.
|
||||
func discArtID(al model.Album, disc int) model.ArtworkID {
|
||||
return model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, disc), &al.UpdatedAt)
|
||||
}
|
||||
|
||||
// expectDiscImage asserts a multi-disc album serves the given disc's art byte-for-byte. Disc art
|
||||
// is a pure serve-time read through the library FS (no worker/state row), so this serves it live.
|
||||
func expectDiscImage(al model.Album, disc int, label string) {
|
||||
GinkgoHelper()
|
||||
Expect(serveBytes(discArtID(al, disc))).To(Equal(pngBytes(label)))
|
||||
}
|
||||
|
||||
// storedBytes returns the bytes the worker placed in the content-addressed store for a
|
||||
// store-backed resolution (embedded/generated). Folder/upload sources are file-backed and are
|
||||
// not in the store; assert those on ia.SourcePath instead.
|
||||
func storedBytes(ia model.ItemArtwork) []byte {
|
||||
GinkgoHelper()
|
||||
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, err := rstore.Open(ia.Hash, art.Mime)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer r.Close()
|
||||
data, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return data
|
||||
}
|
||||
|
||||
// smallPNG builds a tiny valid PNG whose pixel color is derived from label, so the bytes are
|
||||
// distinct per label (a resolver picking a different file yields a different hash/path) while
|
||||
// still decoding cleanly for the worker's blurhash step.
|
||||
func smallPNG(label string) *fstest.MapFile {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(label))
|
||||
sum := h.Sum32()
|
||||
c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255}
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
for y := range 2 {
|
||||
for x := range 2 {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
Expect(png.Encode(&buf, img)).To(Succeed())
|
||||
return &fstest.MapFile{Data: buf.Bytes()}
|
||||
}
|
||||
|
||||
// pngBytes returns the bytes smallPNG(label) writes, for byte-for-byte serve assertions.
|
||||
func pngBytes(label string) []byte {
|
||||
GinkgoHelper()
|
||||
return smallPNG(label).Data
|
||||
}
|
||||
|
||||
// trackFile builds a fake MP3 entry with optional tag overrides (album, disc, discsubtitle, ...).
|
||||
func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile {
|
||||
tags := storagetest.Track(num, title)
|
||||
for _, e := range extra {
|
||||
for k, v := range e {
|
||||
tags[k] = v
|
||||
}
|
||||
}
|
||||
return storagetest.MP3(tags)
|
||||
}
|
||||
|
||||
// embeddedArtFixture is a real MP3 with an embedded picture; FakeFS's JSON-encoded tags aren't
|
||||
// taglib-readable, so embedded-art scenarios swap these bytes in after scanning. embeddedArtBytes
|
||||
// is the exact image taglib extracts from it. Both load lazily (after tests.Init chdirs to the
|
||||
// project root and registers Gomega), via loadEmbeddedFixture from setupResolutionHarness.
|
||||
var (
|
||||
embeddedFixtureOnce sync.Once
|
||||
embeddedArtFixture []byte
|
||||
embeddedArtBytes []byte
|
||||
)
|
||||
|
||||
func loadEmbeddedFixture() {
|
||||
embeddedFixtureOnce.Do(func() {
|
||||
embeddedArtFixture = readFixture(mp3Fixture)
|
||||
embeddedArtBytes = extractEmbeddedArt(embeddedArtFixture)
|
||||
})
|
||||
}
|
||||
|
||||
func extractEmbeddedArt(mp3 []byte) []byte {
|
||||
tf, err := taglib.OpenStream(bytes.NewReader(mp3))
|
||||
if err != nil {
|
||||
panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error())
|
||||
}
|
||||
defer tf.Close()
|
||||
images := tf.Properties().Images
|
||||
if len(images) == 0 {
|
||||
panic("embedded-art fixture has no embedded images")
|
||||
}
|
||||
data, err := tf.Image(0)
|
||||
if err != nil || len(data) == 0 {
|
||||
panic("embedded-art fixture: could not read image 0")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// replaceWithRealMP3 swaps the fake entry at relPath for the real embedded-art MP3, so the
|
||||
// library FS returns a taglib-parseable stream during resolution.
|
||||
func replaceWithRealMP3(relPath string) {
|
||||
GinkgoHelper()
|
||||
fakeFS.MapFS[relPath] = &fstest.MapFile{Data: embeddedArtFixture}
|
||||
}
|
||||
|
||||
func firstAlbum() model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||
return albums[0]
|
||||
}
|
||||
|
||||
func albumByName(name string) model.Album {
|
||||
GinkgoHelper()
|
||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, al := range albums {
|
||||
if al.Name == name {
|
||||
return al
|
||||
}
|
||||
}
|
||||
Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums)))
|
||||
return model.Album{}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user