package artwork import ( "context" "errors" "io" "os" "path/filepath" "runtime" "strings" "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" ) var _ = Describe("trace vocabulary", func() { // The CLI renders these verbatim and branches on them; a value change is a change to // what `artwork explain` tells an operator, so it must be made deliberately. It("pins the wire values the CLI reads", func() { Expect([]Outcome{ OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeWouldTry, OutcomeError, }).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "would-try", "error"})) Expect(externalCandidate).To(Equal("external")) Expect(ExternalPrefix).To(Equal("external:")) }) }) var _ = Describe("chainTrace", func() { It("returns nil when no trace is attached", func() { Expect(traceFrom(context.Background())).To(BeNil()) }) It("collects steps in order", func() { t := &ChainTrace{} ctx := withTrace(context.Background(), t) traceFrom(ctx).add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) traceFrom(ctx).add(TraceStep{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "cover.*", Outcome: OutcomeMiss}, {Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}, })) s := t.Steps() s[0].Candidate = "mutated" Expect(t.Steps()[0].Candidate).To(Equal("cover.*")) }) It("does not panic when the trace is nil", func() { var t *ChainTrace Expect(func() { t.add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) }).ToNot(Panic()) Expect(t.Steps()).To(BeEmpty(), "a nil trace collects nothing, so reading it must be as safe as writing it") }) It("is safe to use concurrently", func() { t := &ChainTrace{} done := make(chan struct{}) for range 10 { go func() { defer GinkgoRecover() t.add(TraceStep{Candidate: "x", Outcome: OutcomeMiss}) done <- struct{}{} }() } for range 10 { <-done } Expect(t.Steps()).To(HaveLen(10)) }) }) var _ = Describe("chainState tracing", func() { It("records a miss when the candidate was absent", func() { t := &ChainTrace{} c := chainState{trace: t} _, ok := c.try("cover.*", resolution{}, false) Expect(ok).To(BeFalse()) Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}})) }) It("records unreadable when the candidate existed but could not be read", func() { t := &ChainTrace{} c := chainState{trace: t} _, ok := c.try("cover.*", resolution{localError: true}, false) Expect(ok).To(BeFalse()) Expect(t.Steps()).To(HaveLen(1)) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable), "a candidate that existed and failed to decode must be distinguishable from one that was absent") }) It("records a hit with the backing path", func() { t := &ChainTrace{} c := chainState{trace: t} res, ok := c.try("embedded", resolution{reader: nil, source: "embedded", sourcePath: "/music/a.flac"}, true) Expect(ok).To(BeTrue()) Expect(res.source).To(Equal("embedded")) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}, })) }) }) var _ = Describe("external gate tracing", func() { hit := func() (io.ReadCloser, string, error) { return io.NopCloser(strings.NewReader("x")), "http://img", nil } miss := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound } boom := func() (io.ReadCloser, string, error) { return nil, "", errors.New("returned status 429") } It("records a hit with the image path", func() { t := &ChainTrace{} g := tracingGate(t, passthroughGate) r, _, err := g("deezer", hit) Expect(err).ToNot(HaveOccurred()) Expect(r).ToNot(BeNil()) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"}, })) }) It("records a miss for a not-found", func() { t := &ChainTrace{} _, _, _ = tracingGate(t, passthroughGate)("deezer", miss) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss)) }) It("records a miss for a model not-found", func() { t := &ChainTrace{} notFound := func() (io.ReadCloser, string, error) { return nil, "", model.ErrNotFound } _, _, _ = tracingGate(t, passthroughGate)("deezer", notFound) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss), "both not-found flavours are definitive answers, not faults") }) It("records an error with its reason", func() { t := &ChainTrace{} _, _, _ = tracingGate(t, passthroughGate)("apple-music", boom) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError)) Expect(t.Steps()[0].Detail).To(ContainSubstring("429")) }) It("never calls the agent in offline mode", func() { t := &ChainTrace{} called := false counting := func() (io.ReadCloser, string, error) { called = true return hit() } _, _, err := offlineGate(t)("deezer", counting) Expect(called).To(BeFalse(), "offline mode must not perform external requests") Expect(err).To(MatchError(errOfflineSkipped)) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "external:deezer", Outcome: OutcomeWouldTry}, })) }) }) var _ = Describe("resolveAlbum tracing", func() { var ( ctx context.Context ds *tests.MockDataStore albumRepo *tests.MockAlbumRepo folderRepo *fakeFolderRepo ffm *tests.MockFFmpeg ag *agents.Agents t *ChainTrace ) BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.CoverArtPriority = "cover.jpg, embedded" repoRoot, err := os.Getwd() Expect(err).ToNot(HaveOccurred()) libRepo := &tests.MockLibraryRepo{} libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) albumRepo = tests.CreateMockAlbumRepo() folderRepo = &fakeFolderRepo{} ds = &tests.MockDataStore{ MockedAlbum: albumRepo, MockedFolder: folderRepo, MockedLibrary: libRepo, } ffm = tests.NewMockFFmpeg("") ag = agents.GetAgents(&tests.MockDataStore{}, nil) t = &ChainTrace{} ctx = withTrace(context.Background(), t) }) It("records a pattern skipped because the album folder holds no images", func() { albumRepo.SetData(model.Albums{ {ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, }) res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(t.Steps()).To(HaveLen(2), "a configured pattern must appear even when the chain never evaluated it") Expect(t.Steps()[0]).To(Equal(TraceStep{ Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder", })) Expect(t.Steps()[1].Candidate).To(Equal("embedded")) Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) }) It("records an evaluated pattern that matched nothing as a miss, not a skip", func() { folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"artist.png"}, }} albumRepo.SetData(model.Albums{ {ID: "al3", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, }) res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "cover.jpg", Outcome: OutcomeMiss}), "the folder was searched and held no cover.jpg, which is not the same as never looking") }) It("ignores an empty priority token", func() { conf.Server.CoverArtPriority = "cover.jpg," albumRepo.SetData(model.Albums{{ID: "al2", Name: "Album", FolderIDs: []string{"f1"}}}) _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"}, })) }) }) var _ = Describe("resolveArtist tracing", func() { var ( ctx context.Context ds *tests.MockDataStore artistRepo *tests.MockArtistRepo albumRepo *tests.MockAlbumRepo folderRepo *fakeFolderRepo ffm *tests.MockFFmpeg ag *agents.Agents t *ChainTrace repoRoot string ) uploadPath := func(file string) string { path := model.UploadedImagePath(consts.EntityArtist, file) Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed()) Expect(os.WriteFile(path, []byte("uploaded artist image"), 0o600)).To(Succeed()) return path } BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) conf.Server.ArtistArtPriority = "album/artist.*" var err error repoRoot, err = os.Getwd() Expect(err).ToNot(HaveOccurred()) libRepo := &tests.MockLibraryRepo{} libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) artistRepo = tests.CreateMockArtistRepo() albumRepo = tests.CreateMockAlbumRepo() folderRepo = &fakeFolderRepo{} ds = &tests.MockDataStore{ MockedArtist: artistRepo, MockedAlbum: albumRepo, MockedFolder: folderRepo, MockedLibrary: libRepo, } ffm = tests.NewMockFFmpeg("") ag = agents.GetAgents(&tests.MockDataStore{}, nil) t = &ChainTrace{} ctx = withTrace(context.Background(), t) }) It("records the upload short-circuit as a hit", func() { path := uploadPath("ar1_test.jpg") artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "upload", Outcome: OutcomeHit, Detail: path}})) }) It("records an upload miss before walking the chain", func() { artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}}) _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}) Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "upload", Outcome: OutcomeMiss})) }) It("labels each step with the configured priority token", func() { folderRepo.result = []model.Folder{{ LibraryPath: testFileLibPath(repoRoot), Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"artist.png"}, }} artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) albumRepo.All = model.Albums{{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(t.Steps()).To(HaveLen(2)) Expect(t.Steps()[1].Candidate).To(Equal("album/artist.*"), "the step must be labelled with the priority token, not the pattern it was rewritten into") Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) Expect(filepath.ToSlash(t.Steps()[1].Detail)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) }) It("records a configured pattern that could not be evaluated", func() { artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "upload", Outcome: OutcomeMiss}, {Candidate: "album/artist.*", Outcome: OutcomeSkipped, Detail: "artist has no albums"}, }), "a configured pattern that was never evaluated must still appear, and say why") }) It("records why an artist folder pattern was skipped", func() { conf.Server.ArtistArtPriority = "artist.*" artistRepo.SetData(model.Artists{{ID: "ar6", Name: "Artist"}}) albumRepo.All = model.Albums{{ID: "al10", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar6"}) Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "upload", Outcome: OutcomeMiss}, {Candidate: "artist.*", Outcome: OutcomeSkipped, Detail: "no artist folder"}, })) }) It("records an upload that exists but cannot be read as unreadable", func() { if runtime.GOOS == "windows" { Skip("chmod does not restrict read access on Windows") } path := uploadPath("ar3_test.jpg") Expect(os.Chmod(path, 0o000)).To(Succeed()) DeferCleanup(func() { _ = os.Chmod(path, 0o600) }) artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist", UploadedImage: "ar3_test.jpg"}}) _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(HaveLen(1)) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable), "an upload that exists and will not open must not look like an absent upload") }) }) var _ = Describe("NewTracingResolver", func() { var ( ds *tests.MockDataStore albumRepo *tests.MockAlbumRepo artistRepo *tests.MockArtistRepo artworkRepo *tests.MockArtworkRepo queueRepo *tests.MockArtworkQueueRepo ffm *tests.MockFFmpeg t *ChainTrace ) BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) conf.Server.CoverArtPriority = "external, embedded" conf.Server.ArtistArtPriority = "external" repoRoot, err := os.Getwd() Expect(err).ToNot(HaveOccurred()) libRepo := &tests.MockLibraryRepo{} libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) albumRepo = tests.CreateMockAlbumRepo() artistRepo = tests.CreateMockArtistRepo() artworkRepo = tests.CreateMockArtworkRepo() queueRepo = tests.CreateMockArtworkQueueRepo() ds = &tests.MockDataStore{ MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedFolder: &fakeFolderRepo{}, MockedLibrary: libRepo, MockedArtwork: artworkRepo, MockedArtworkQueue: queueRepo, } ffm = tests.NewMockFFmpeg("") t = &ChainTrace{} }) Context("offline", func() { var fake *fakeImageAgent BeforeEach(func() { fake = &fakeImageAgent{name: "offline-probe"} albumRepo.SetData(model.Albums{{ ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, }}) artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) }) It("reports the external tier without asking any agent", func() { source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) Expect(source).To(Equal("embedded")) Expect(fake.albumCalls).To(BeZero(), "offline mode must not add load to an external provider") Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry})) }) It("records the local chain steps too", func() { _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) last := t.Steps()[len(t.Steps())-1] Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external gate") Expect(last.Outcome).To(Equal(OutcomeHit)) }) It("never persists artwork state", func() { _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) Expect(artworkRepo.ItemData).To(BeEmpty(), "an offline resolution carries extError, which must never be recorded as a real provider failure") Expect(queueRepo.Data).To(BeEmpty()) }) It("resolves an artist without persisting anything", func() { source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindArtistArtwork, "ar1") Expect(err).ToNot(HaveOccurred()) Expect(source).To(BeEmpty()) Expect(fake.artistCalls).To(BeZero()) Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry})) Expect(artworkRepo.ItemData).To(BeEmpty()) Expect(queueRepo.Data).To(BeEmpty()) }) It("closes the reader it does not hand back", func() { conf.Server.CoverArtPriority = "embedded" ffm = tests.NewMockFFmpeg("fake image bytes") albumRepo.SetData(model.Albums{{ ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"}, }}) source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al2") Expect(err).ToNot(HaveOccurred()) Expect(source).To(Equal("embedded")) Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation") }) It("propagates a lookup error", func() { _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "nope") Expect(err).To(MatchError(model.ErrNotFound)) }) }) It("asks the agents when live is true", func() { fake := &fakeImageAgent{name: "live-probe", err: agents.ErrNotFound} albumRepo.SetData(model.Albums{{ ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, }}) _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) Expect(fake.albumCalls).To(Equal(1)) Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:live-probe", Outcome: OutcomeMiss})) }) }) var _ = Describe("resolveDisc tracing", func() { var ( ctx context.Context ds *tests.MockDataStore albumRepo *tests.MockAlbumRepo folderRepo *fakeFolderRepo ffm *tests.MockFFmpeg t *ChainTrace ) BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) repoRoot, err := os.Getwd() Expect(err).ToNot(HaveOccurred()) libRepo := &tests.MockLibraryRepo{} libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) albumRepo = tests.CreateMockAlbumRepo() albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}}) folderRepo = &fakeFolderRepo{} mfRepo := tests.CreateMockMediaFileRepo() mfRepo.SetData(model.MediaFiles{{ID: "mf1", AlbumID: "al1", DiscNumber: 2, Path: "tests/fixtures/artist/an-album/test.mp3"}}) ds = &tests.MockDataStore{ MockedAlbum: albumRepo, MockedMediaFile: mfRepo, MockedFolder: folderRepo, MockedLibrary: libRepo, } ffm = tests.NewMockFFmpeg("") t = &ChainTrace{} ctx = withTrace(context.Background(), t) }) It("accounts for every configured entry, including the ones that map to no source", func() { conf.Server.DiscArtPriority = "external, discsubtitle, cover.jpg" res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).To(BeNil()) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "external", Outcome: OutcomeSkipped, Detail: "external sources are not supported for disc artwork"}, {Candidate: "discsubtitle", Outcome: OutcomeSkipped, Detail: "disc has no subtitle"}, {Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"}, })) }) It("records the entry that won and stops there", func() { conf.Server.DiscArtPriority = "disc*.*, cover.jpg, embedded" folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, }} res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("folder")) Expect(t.Steps()).To(HaveLen(2), "the walk must stop at the winner, and record nothing below it") Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "disc*.*", Outcome: OutcomeMiss})) Expect(t.Steps()[1].Candidate).To(Equal("cover.jpg")) Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) Expect(t.Steps()[1].Detail).To(HaveSuffix(filepath.FromSlash("tests/fixtures/artist/an-album/cover.jpg"))) Expect(t.Steps()[1].Detail).ToNot(Equal("tests/fixtures/artist/an-album/cover.jpg"), "a library-relative path sends the operator looking in the wrong place") }) It("reports an unparseable disc id rather than explaining another disc", func() { conf.Server.DiscArtPriority = "cover.jpg" _, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, "al1") Expect(err).To(HaveOccurred()) }) }) var _ = Describe("resolveMediaFile tracing", func() { var ( ctx context.Context ds *tests.MockDataStore mfRepo *tests.MockMediaFileRepo ffm *tests.MockFFmpeg t *ChainTrace ) BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) conf.Server.EnableMediaFileCoverArt = true repoRoot, err := os.Getwd() Expect(err).ToNot(HaveOccurred()) libRepo := &tests.MockLibraryRepo{} libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) mfRepo = tests.CreateMockMediaFileRepo() mfRepo.SetData(model.MediaFiles{{ ID: "mf1", Title: "Song", HasCoverArt: true, Path: "tests/fixtures/artist/an-album/test.mp3", }}) ds = &tests.MockDataStore{MockedMediaFile: mfRepo, MockedLibrary: libRepo} ffm = tests.NewMockFFmpeg("") t = &ChainTrace{} ctx = withTrace(context.Background(), t) }) It("separates a disabled setting from a track with nothing embedded", func() { conf.Server.EnableMediaFileCoverArt = false _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "embedded", Outcome: OutcomeSkipped, Detail: "EnableMediaFileCoverArt is off"}, })) }) It("records a track with no embedded art as a miss", func() { mfRepo.SetData(model.MediaFiles{{ID: "mf2", Title: "Song", HasCoverArt: false}}) _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf2") Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "embedded", Outcome: OutcomeMiss, Detail: "the track has no embedded cover art"}, })) }) It("records the embedded hit", func() { res, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") Expect(err).ToNot(HaveOccurred()) Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("embedded")) Expect(t.Steps()).To(HaveLen(1)) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeHit)) }) })