diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go new file mode 100644 index 000000000..5059e5fe8 --- /dev/null +++ b/core/artwork/e2e/acquire_serve_test.go @@ -0,0 +1,226 @@ +package e2e + +import ( + "context" + "errors" + "io" + "os" + + "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/core/artwork" + "github.com/navidrome/navidrome/model" + "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" +) + +// These specs wire the real Worker and Service over the same ImageStore and mock repositories, +// then drive the full enqueue → drain → serve loop. They assert the integration of the chain, not +// the per-source resolution rules (which the unit suites in package artwork already cover). +var _ = Describe("Acquisition → serve loop", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + artRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + albumRepo *tests.MockAlbumRepo + artistRepo *tests.MockArtistRepo + mfRepo *tests.MockMediaFileRepo + plRepo *tests.MockPlaylistRepo + radioRepo *tests.MockedRadioRepo + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + store *artwork.ImageStore + svc artwork.Service + worker *artwork.Worker + coverBytes []byte + ) + + // itemFound reports whether the worker has persisted a resolved (hash-bearing) state row. + itemFound := func(kind, id string) func() bool { + return func() bool { + ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + return err == nil && ia.Hash != "" + } + } + itemAbsent := func(kind, id string) func() bool { + return func() bool { + ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + return err == nil && ia.Hash == "" + } + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + coverBytes = readFixture(coverFixture) + + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "artist.png" // upload wins first; kept offline as a safety net + conf.Server.EnableMediaFileCoverArt = true + conf.Server.DevArtworkWorkerConcurrency = 1 + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: repoRoot}}) + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + albumRepo = tests.CreateMockAlbumRepo() + artistRepo = tests.CreateMockArtistRepo() + mfRepo = tests.CreateMockMediaFileRepo() + plRepo = tests.CreateMockPlaylistRepo() + radioRepo = tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{} + ds = &tests.MockDataStore{ + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + MockedAlbum: albumRepo, + MockedArtist: artistRepo, + MockedMediaFile: mfRepo, + MockedPlaylist: plRepo, + MockedRadio: radioRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm := tests.NewMockFFmpeg("") + store = artwork.NewImageStore(GinkgoT().TempDir()) + // 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("ArtworkPipelineE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, errors.New("resize not exercised in e2e") + }) + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + + svc = artwork.NewService(ds, imgCache, store, ffm) + worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) + }) + + // seedFolderAlbum wires an album backed by the real fixture folder cover, shared by the album + // and playlist-grid scenarios. + seedFolderAlbum := func(albumID string) { + folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) + } + + It("acquires album folder art and serves the exact bytes under its hash", func() { + seedFolderAlbum("al1") + worker.Bump("al", "al1") + runWorkerUntil(ctx, worker, itemFound("al", "al1")) + + ia, err := artRepo.GetItemArtwork("al", "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(img.Placeholder).To(BeFalse()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("acquires an artist's uploaded image and serves it", func() { + name := writeUpload(consts.EntityArtist, "artist-e2e.png", artistPngFixture) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: name}}) + worker.Bump("ar", "ar1") + runWorkerUntil(ctx, worker, itemFound("ar", "ar1")) + + ia, err := artRepo.GetItemArtwork("ar", "ar1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("upload")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("ar-ar1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(readAll(img)).To(Equal(readFixture(artistPngFixture))) + }) + + It("generates a playlist grid from its tracks' album art and serves it from the store", func() { + seedFolderAlbum("al1") + plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"al1"}} + worker.Bump("pl", "pl1") + runWorkerUntil(ctx, worker, itemFound("pl", "pl1")) + + ia, err := artRepo.GetItemArtwork("pl", "pl1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("generated")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + // The generated grid is a fresh PNG placed in the content-addressed store. + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Mime).To(Equal("image/png")) + Expect(len(readAll(img))).To(BeNumerically(">", 0)) + }) + + It("acquires a radio station's uploaded image and serves it", func() { + name := writeUpload(consts.EntityRadio, "radio-e2e.jpg", coverFixture) + radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name} + worker.Bump("ra", "ra1") + runWorkerUntil(ctx, worker, itemFound("ra", "ra1")) + + ia, err := artRepo.GetItemArtwork("ra", "ra1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("upload")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("serves an unresolved track provisionally, then upgrades to the worker's state row", func() { + mfRepo.SetData(model.MediaFiles{{ + ID: "mf1", AlbumID: "al1", HasCoverArt: true, LibraryID: 0, Path: mp3Fixture, + }}) + + // First read: no state row yet → extract embedded art provisionally and enqueue the track. + provisional, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(provisional.Placeholder).To(BeFalse()) + Expect(provisional.Hash).ToNot(BeEmpty()) + provisionalBytes := readAll(provisional) + Expect(len(provisionalBytes)).To(BeNumerically(">", 0)) + + _, err = artRepo.GetItemArtwork("mf", "mf1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row") + + // The provisional read enqueued a Bump; drain it and confirm the persisted hash matches. + runWorkerUntil(ctx, worker, itemFound("mf", "mf1")) + ia, err := artRepo.GetItemArtwork("mf", "mf1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + Expect(ia.Hash).To(Equal(provisional.Hash)) + + // Second read: now served from the persisted state row / store, same bytes. + resolved, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(resolved.Hash).To(Equal(ia.Hash)) + Expect(readAll(resolved)).To(Equal(provisionalBytes)) + }) + + It("records an absent state for an entity with no art and reports it unavailable", func() { + albumRepo.SetData(model.Albums{{ID: "alx", Name: "Artless", LibraryID: 0}}) + worker.Bump("al", "alx") + runWorkerUntil(ctx, worker, itemAbsent("al", "alx")) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-alx"), 0, false) + Expect(err).To(MatchError(artwork.ErrUnavailable)) + + img, err := svc.GetOrPlaceholder(ctx, "al-alx", 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeTrue()) + }) +}) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go new file mode 100644 index 000000000..15b3eb6e9 --- /dev/null +++ b/core/artwork/e2e/e2e_suite_test.go @@ -0,0 +1,93 @@ +// Package e2e exercises the artwork pipeline end to end against the NEW acquisition→serve +// path: it enqueues real entities, drives the real Worker to drain the queue, and serves the +// result through the real Service, over a real ImageStore and real library files. It replaces +// the legacy core/artwork/e2e suite that was removed with the old readers in the serving cutover. +package e2e + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor + "github.com/navidrome/navidrome/core/artwork" + _ "github.com/navidrome/navidrome/core/storage/local" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestArtworkE2E(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Artwork Pipeline E2E Suite") +} + +// Fixtures relative to the project root (tests.Init chdirs there). +const ( + coverFixture = "tests/fixtures/artist/an-album/cover.jpg" + mp3Fixture = "tests/fixtures/artist/an-album/test.mp3" + artistPngFixture = "tests/fixtures/artist/an-album/artist.png" + albumFolderPath = "tests/fixtures/artist/an-album" +) + +// readFixture returns the raw bytes of a project-relative fixture file. +func readFixture(rel string) []byte { + GinkgoHelper() + data, err := os.ReadFile(rel) + Expect(err).ToNot(HaveOccurred(), "reading fixture %q", rel) + return data +} + +// readAll drains an artwork image to bytes and closes it. +func readAll(img *artwork.Image) []byte { + GinkgoHelper() + Expect(img).ToNot(BeNil()) + defer img.Close() + data, err := io.ReadAll(img) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// runWorkerUntil starts the real worker loop, waits for a condition, then cancels and joins it, +// mirroring how cmd drives Worker.Run in production. +func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { + GinkgoHelper() + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- worker.Run(runCtx) }() + Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +} + +// fakeFolderRepo is the minimal FolderRepository the album/playlist resolution chains touch: +// GetAll yields the seeded folders and the album-root parent lookup finds nothing. +type fakeFolderRepo struct { + model.FolderRepository + result []model.Folder +} + +func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil } + +func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { + return false, nil +} + +func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound } + +// writeUpload copies a fixture into the per-entity upload folder under the data dir and returns +// the bare filename UploadedImagePath expects. +func writeUpload(entityType, name, srcFixture string) string { + GinkgoHelper() + dst := model.UploadedImagePath(entityType, name) + Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed()) + Expect(os.WriteFile(dst, readFixture(srcFixture), 0o600)).To(Succeed()) + return name +} diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go new file mode 100644 index 000000000..4023ad03e --- /dev/null +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -0,0 +1,308 @@ +package e2e + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "time" + + "github.com/Masterminds/squirrel" + _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + _ "github.com/navidrome/navidrome/core/storage/local" // registers the "file" storage scheme + "github.com/navidrome/navidrome/core/stream" + "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/resources" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/public" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +// The artwork serving path streams folder-backed originals via os.Open, which a fake FS cannot +// back, so this suite scans a small REAL on-disk library and drives the real acquisition worker +// and artwork.Service through the Subsonic and public image handlers. It restores the Task 11 +// full-pipeline coverage (album found/absent + suffix transition + share/img) against the new path. +var _ = Describe("Artwork Serving", Ordered, func() { + var ( + artRouter *subsonic.Router + pubRouter *public.Router + artSvc artwork.Service + worker *artwork.Worker + artfulID string + artlessID string + artfulHash string + placeholder []byte + ) + + albumCoverInList := func(albumID string) string { + GinkgoHelper() + resp := doReq("getAlbumList2", "type", "alphabeticalByName", "size", "500") + Expect(resp.AlbumList2).ToNot(BeNil()) + for _, al := range resp.AlbumList2.Album { + if al.Id == albumID { + return al.CoverArt + } + } + Fail("album " + albumID + " not found in getAlbumList2") + return "" + } + + getCover := func(params ...string) *httptest.ResponseRecorder { + GinkgoHelper() + w := httptest.NewRecorder() + r := buildReq(adminUser, "getCoverArt", params...) + artRouter.ServeHTTP(w, r) + return w + } + + BeforeAll(func() { + DeferCleanup(configtest.SetupConfig()) + DeferCleanup(func() { Eventually(scanner.IsScanning).Should(BeFalse()) }) + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + musicDir := GinkgoT().TempDir() + writeArtworkTrack(musicDir, "Artful Artist", "Artful Album", "01 - Come Together", true) + writeArtworkTrack(musicDir, "Artless Artist", "Artless Album", "01 - Lonely", false) + + conf.Server.MusicFolder = musicDir + conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "artist.png" // offline: artists resolve absent, out of scope here + conf.Server.EnableMediaFileCoverArt = false + conf.Server.DevArtworkWorkerConcurrency = 1 + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.EnableSharing = true + conf.Server.DevArtworkMaxRequests = 100 + conf.Server.DevArtworkThrottleBacklogLimit = 100 + conf.Server.DevArtworkThrottleBacklogTimeout = time.Minute + + goldenDB.Restore() + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + // A re-scan of already-populated data deadlocks the parallel album-refresh phase at the + // harness's MaxOpenConns=1, so wipe the golden content and import ONLY the real artwork + // library fresh (the import path never enters that refresh phase). + wipeScannedContent() + artLib := model.Library{Name: "Artwork Library", Path: musicDir} + Expect(ds.Library(ctx).Put(&artLib)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{artLib.ID})).To(Succeed()) + + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Focus the worker on the entities under test. + _, err = db.Db().Exec("DELETE FROM artwork_queue") + Expect(err).ToNot(HaveOccurred()) + + artfulID = albumIDByName("Artful Album") + artlessID = albumIDByName("Artless Album") + + ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + placeholder, err = io.ReadAll(ph) + Expect(err).ToNot(HaveOccurred()) + _ = ph.Close() + + store := artwork.NewImageStore(GinkgoT().TempDir()) + imgCache := newDummyImageCache(ctx) + ffm := harness.NoopFFmpeg{} + artSvc = artwork.NewService(ds, imgCache, store, ffm) + worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) + + artRouter = buildArtworkRouter(artSvc) + router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router + pubRouter = public.New(ds, artSvc, streamerSpy, core.NewShare(ds), noopArchiver{}) + }) + + It("emits a bare optimistic coverArt id before the queue is drained", func() { + Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID)) + Expect(albumCoverInList(artlessID)).To(Equal("al-" + artlessID)) + }) + + It("drains the queue: folder art is acquired, the artless album settles absent", func() { + worker.Bump("al", artfulID) + worker.Bump("al", artlessID) + runWorkerUntil(ctx, worker, func() bool { + found, err := ds.Artwork(ctx).GetItemArtwork("al", artfulID, model.ImageTypePrimary) + if err != nil || found.Hash == "" { + return false + } + absent, err := ds.Artwork(ctx).GetItemArtwork("al", artlessID, model.ImageTypePrimary) + return err == nil && absent.Hash == "" + }) + ia, err := ds.Artwork(ctx).GetItemArtwork("al", artfulID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + artfulHash = ia.Hash + Expect(artfulHash).ToNot(BeEmpty()) + }) + + It("promotes the list id to the hash suffix and omits the absent album's coverArt", func() { + Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID + "_" + artfulHash)) + Expect(albumCoverInList(artlessID)).To(BeEmpty()) + }) + + It("serves the found album's bytes with revalidation headers for a bare id", func() { + w := getCover("id", "al-"+artfulID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("ETag")).To(Equal(`"` + artfulHash + `"`)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + }) + + It("serves the found album immutably when the exact hash is requested", func() { + w := getCover("id", "al-"+artfulID+"_"+artfulHash) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 304 with an empty body when the client already holds the hash", func() { + w := httptest.NewRecorder() + r := buildReq(adminUser, "getCoverArt", "id", "al-"+artfulID) + r.Header.Set("If-None-Match", `"`+artfulHash+`"`) + artRouter.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotModified)) + Expect(w.Body.Bytes()).To(BeEmpty()) + }) + + It("serves the placeholder with no-store for an absent album", func() { + w := getCover("id", "al-"+artlessID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(placeholder)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + }) + + It("serves /share/img immutably for a JWT whose payload carries the hash", func() { + token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), + auth.Claims{ID: "al-" + artfulID + "_" + artfulHash}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 404 from /share/img for an absent entity", func() { + token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), + auth.Claims{ID: "al-" + artlessID}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) +}) + +// buildArtworkRouter mirrors setupTestDB's Subsonic wiring but with the real artwork.Service. +func buildArtworkRouter(art artwork.Service) *subsonic.Router { + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), metrics.NewNoopInstance()) + return subsonic.New( + ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s, + events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService(ds)), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), + playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, + ) +} + +// wipeScannedContent clears all scanned library content (including the golden fake library) so the +// next scan is a clean import. FKs are disabled for the bulk delete, mirroring harness.Restore. +func wipeScannedContent() { + GinkgoHelper() + _, err := db.Db().Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + for _, t := range []string{"media_file", "album", "artist", "folder", "item_artwork", "artwork_queue", "artwork", "library"} { + _, err = db.Db().Exec(`DELETE FROM "` + t + `"`) + Expect(err).ToNot(HaveOccurred(), "wiping %s", t) + } + _, err = db.Db().Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} + +func albumIDByName(name string) string { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album named %q", name) + return albums[0].ID +} + +// writeArtworkTrack lays down one tag-distinct track (and optionally a folder cover) so the scanner +// creates a self-contained album; distinct ALBUM/ALBUMARTIST tags keep the two albums from merging. +func writeArtworkTrack(root, artist, album, title string, withCover bool) { + GinkgoHelper() + dir := filepath.Join(root, artist, album) + Expect(os.MkdirAll(dir, 0o755)).To(Succeed()) + mp3 := filepath.Join(dir, title+".mp3") + Expect(os.WriteFile(mp3, readArtworkFixture("test.mp3"), 0o600)).To(Succeed()) + Expect(taglib.WriteTags(mp3, map[string][]string{ + "ALBUM": {album}, "ALBUMARTIST": {artist}, "ARTIST": {artist}, "TITLE": {title}, + }, taglib.Clear)).To(Succeed()) + if withCover { + Expect(os.WriteFile(filepath.Join(dir, "cover.jpg"), readArtworkFixture("cover.jpg"), 0o600)).To(Succeed()) + } +} + +func readArtworkFixture(name string) []byte { + GinkgoHelper() + data, err := os.ReadFile(filepath.Join("tests", "fixtures", "artist", "an-album", name)) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// newDummyImageCache backs the artwork.Service's resize cache. size=0 requests stream originals +// and never invoke the reader, so it only needs to satisfy the constructor; resize behavior is +// covered by the artwork package's own suites. +func newDummyImageCache(ctx context.Context) cache.FileCache { + GinkgoHelper() + c := cache.NewFileCache("SubsonicArtworkE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, errors.New("resize not exercised in subsonic artwork e2e") + }) + Eventually(func() bool { return c.Available(ctx) }).Should(BeTrue()) + return c +} + +// runWorkerUntil drives the real worker loop until a condition holds, then cancels and joins it. +func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { + GinkgoHelper() + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- worker.Run(runCtx) }() + Eventually(until, 10*time.Second, 20*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +}