package artwork import ( "context" "encoding/binary" "errors" "hash/crc32" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) // DecodeConfig reads only the header, so the pixel data can be omitted entirely. func pngHeaderWithDims(w, h uint32) []byte { ihdr := make([]byte, 13) binary.BigEndian.PutUint32(ihdr[0:], w) binary.BigEndian.PutUint32(ihdr[4:], h) ihdr[8] = 8 // bit depth ihdr[9] = 2 // color type: truecolor chunk := append([]byte("IHDR"), ihdr...) out := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} out = binary.BigEndian.AppendUint32(out, uint32(len(ihdr))) out = append(out, chunk...) return binary.BigEndian.AppendUint32(out, crc32.ChecksumIEEE(chunk)) } var _ = Describe("processor.acquire", func() { var ( ctx context.Context ds *tests.MockDataStore folderRepo *fakeFolderRepo libRepo *tests.MockLibraryRepo ffm *tests.MockFFmpeg ag *agents.Agents store *ImageStore artRepo *tests.MockArtworkRepo repoRoot string proc *processor ) BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ctx = context.Background() var err error repoRoot, err = os.Getwd() Expect(err).ToNot(HaveOccurred()) folderRepo = &fakeFolderRepo{} libRepo = &tests.MockLibraryRepo{} libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) ffm = tests.NewMockFFmpeg("") ag = agents.GetAgents(&tests.MockDataStore{}, nil) artRepo = tests.CreateMockArtworkRepo() ds = &tests.MockDataStore{ MockedFolder: folderRepo, MockedLibrary: libRepo, MockedArtwork: artRepo, } ds.MockedAlbum = tests.CreateMockAlbumRepo() store = NewImageStore(GinkgoT().TempDir()) proc = &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} conf.Server.CoverArtPriority = "cover.jpg, embedded" }) It("found-folder: persists state from a folder image, writes no store file, keeps sourcePath/refMtime", func() { folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, }} ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, }) out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).ToNot(BeEmpty()) Expect(ia.Source).To(Equal("folder")) Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) Expect(ia.RefMtime).To(BeNumerically(">", 0)) art, err := artRepo.GetImage(ia.Hash) Expect(err).ToNot(HaveOccurred()) _, err = store.Open(ia.Hash, art.Mime) Expect(os.IsNotExist(err)).To(BeTrue(), "folder-backed art must not be duplicated into the store") }) Describe("prune lock scope", func() { var lock *countingLocker BeforeEach(func() { lock = &countingLocker{} proc.pruneLock = lock }) It("holds it once across the write window", func() { folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, }} ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}}, }) out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) Expect(out).To(Equal(outcomeFound)) Expect(lock.locks).To(BeNumerically(">", 0), "the write window must exclude prune") Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns") }) // Resolution can reach the network, so holding the lock across it would let one slow // provider block prune, and every drain queued behind prune's writer. It("never takes it while only resolving", func() { folderRepo.result = nil ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}}, }) out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) Expect(out).To(Equal(outcomeAbsent)) Expect(lock.locks).To(BeZero()) }) }) It("found-embedded: writes a store file and computes a non-empty blurhash from a real fixture", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, }) folderRepo.result = nil out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("embedded")) Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) art, err := artRepo.GetImage(ia.Hash) Expect(err).ToNot(HaveOccurred()) Expect(art.BlurHash).ToNot(BeEmpty()) rc, err := store.Open(ia.Hash, art.Mime) Expect(err).ToNot(HaveOccurred()) rc.Close() }) It("absent: no local source and no external error persists a known-absent state", func() { folderRepo.result = nil ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al3", Name: "Album"}, }) out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) Expect(out).To(Equal(outcomeAbsent)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).To(BeEmpty()) Expect(ia.Source).To(BeEmpty()) Expect(ia.AttemptedAt).To(BeTemporally("~", time.Now(), time.Second)) }) It("failed-on-unreadable-local: a listed cover that will not open never records absent", func() { conf.Server.CoverArtPriority = "cover.jpg" // A folder listing that names a cover the FS will not hand over: a stale mount. libRoot := GinkgoT().TempDir() Expect(os.MkdirAll(filepath.Join(libRoot, "an-album"), 0o755)).To(Succeed()) libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jpg"}}} ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}}, }) out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent") }) // An upload outranks every source, so an unreadable one must not let a lower one take over. It("failed-on-unreadable-upload: an upload that will not open never records absent", func() { if runtime.GOOS == "windows" { // The file would still open, so the spec would pass on the decode error instead. Skip("chmod does not restrict read access on Windows") } radioRepo := tests.CreateMockedRadioRepo() radioRepo.Data = map[string]*model.Radio{} ds.MockedRadio = radioRepo dir := GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(dir) upload := model.UploadedImagePath(consts.EntityRadio, "ra-io.jpg") Expect(os.MkdirAll(filepath.Dir(upload), 0o755)).To(Succeed()) Expect(os.WriteFile(upload, []byte("x"), 0o600)).To(Succeed()) Expect(os.Chmod(upload, 0o000)).To(Succeed()) // present, but unreadable DeferCleanup(func() { _ = os.Chmod(upload, 0o600) }) radioRepo.Data["ra-io"] = &model.Radio{ID: "ra-io", Name: "Station", UploadedImage: "ra-io.jpg"} out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent") }) It("failed-on-extError: leaves the item's state untouched", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al4", Name: "Album"}, }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, }} ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) Expect(out).To(Equal(outcomeFoundStale)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Hash).ToNot(BeEmpty()) Expect(ia.Source).To(Equal("folder")) }) It("found-external: persists source as external: and stores the fetched bytes", func() { conf.Server.CoverArtPriority = "external" imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) Expect(err).ToNot(HaveOccurred()) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(imgBytes) })) DeferCleanup(srv.Close) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia.Source).To(Equal("external:deezerFake")) Expect(ia.Hash).ToNot(BeEmpty()) art, err := artRepo.GetImage(ia.Hash) Expect(err).ToNot(HaveOccurred()) rc, err := store.Open(ia.Hash, art.Mime) Expect(err).ToNot(HaveOccurred()) rc.Close() }) It("dedup: a second item with identical bytes skips decode and reuses the artwork row", func() { folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, }} ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al5", Name: "Album A", FolderIDs: []string{"f1"}}, {ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}}, }) out1, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) Expect(out1).To(Equal(outcomeFound)) ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) // A re-decode instead of a hash dedup would overwrite this sentinel. poisoned := artRepo.Data[ia1.Hash] poisoned.BlurHash = "SENTINEL" artRepo.Data[ia1.Hash] = poisoned out2, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) Expect(out2).To(Equal(outcomeFound)) ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(ia2.Hash).To(Equal(ia1.Hash)) reused, err := artRepo.GetImage(ia1.Hash) Expect(err).ToNot(HaveOccurred()) Expect(reused.BlurHash).To(Equal("SENTINEL")) }) It("two items, two files, identical bytes: each item keeps its own provenance; the shared artwork row is written once", func() { // Byte-identical files share one hash, but provenance is per item. libRoot := GinkgoT().TempDir() imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) Expect(err).ToNot(HaveOccurred()) for sub, mtime := range map[string]int64{"album-a": 1000, "album-b": 2000} { dir := filepath.Join(libRoot, sub) Expect(os.MkdirAll(dir, 0755)).To(Succeed()) img := filepath.Join(dir, "cover.jpg") Expect(os.WriteFile(img, imgBytes, 0600)).To(Succeed()) Expect(os.Chtimes(img, time.Unix(mtime, 0), time.Unix(mtime, 0))).To(Succeed()) } libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "alA", Name: "Album A", FolderIDs: []string{"fa"}}, {ID: "alB", Name: "Album B", FolderIDs: []string{"fb"}}, }) folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} outN, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) Expect(outN).To(Equal(outcomeFound)) iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(iaA.Source).To(Equal("folder")) Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) Expect(iaA.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) // A re-decode instead of a hash dedup would overwrite this sentinel. poisoned := artRepo.Data[iaA.Hash] poisoned.BlurHash = "SENTINEL" artRepo.Data[iaA.Hash] = poisoned folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} outN, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) Expect(outN).To(Equal(outcomeFound)) iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(iaB.Hash).To(Equal(iaA.Hash)) Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg")) Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano())) iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) Expect(err).ToNot(HaveOccurred()) Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) Expect(artRepo.Data).To(HaveLen(1)) reused, err := artRepo.GetImage(iaA.Hash) Expect(err).ToNot(HaveOccurred()) Expect(reused.BlurHash).To(Equal("SENTINEL")) }) It("decode failure on found bytes: fails without writing state", func() { tmpDir := GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg") Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed()) radioRepo := tests.CreateMockedRadioRepo() radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}} ds.MockedRadio = radioRepo out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) It("oversized read: a resolved image larger than the cap fails without writing state", func() { tmpDir := GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg") f, err := os.Create(imgPath) Expect(err).ToNot(HaveOccurred()) Expect(f.Truncate(maxImageBytes + 1)).To(Succeed()) Expect(f.Close()).To(Succeed()) radioRepo := tests.CreateMockedRadioRepo() radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}} ds.MockedRadio = radioRepo out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) Expect(out).To(Equal(outcomeFailed)) _, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) // The cap is compared by division so an overflowing product cannot slip past it; no supported // format can declare these dimensions, so this pins the arithmetic, not a live hole. DescribeTable("rejects out-of-range declared dimensions", func(w, h uint32) { _, err := decodeArtwork(ctx, "bomb", pngHeaderWithDims(w, h)) Expect(err).To(HaveOccurred()) }, Entry("both dimensions at the 32-bit maximum", uint32(0xffffffff), uint32(0xffffffff)), Entry("both dimensions at the signed 32-bit maximum", uint32(0x7fffffff), uint32(0x7fffffff)), Entry("zero width", uint32(0), uint32(100)), Entry("zero height", uint32(100), uint32(0)), ) It("decompression bomb: rejects huge declared dimensions before the full decode", func() { data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap _, err := decodeArtwork(ctx, "bomb", data) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("dimensions")) }) It("store write failure: fails without writing state", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, }) folderRepo.result = nil // A store root that is a plain file makes every MkdirAll under it fail. blockedRoot := filepath.Join(GinkgoT().TempDir(), "not-a-dir") Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed()) proc.store = NewImageStore(blockedRoot) out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) }) type countingLocker struct { locks int unlocks int } func (l *countingLocker) Lock() { l.locks++ } func (l *countingLocker) Unlock() { l.unlocks++ } func (l *countingLocker) held() bool { return l.locks != l.unlocks }