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, OutcomeError, }).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "error"})) Expect(externalCandidate).To(Equal("external")) Expect(ExternalPrefix).To(Equal("external:")) }) }) var _ = Describe("encodeSteps/DecodeTrace", func() { It("round-trips a trace", func() { steps := []TraceStep{ {Candidate: "cover.png", Outcome: OutcomeMiss}, {Candidate: "cover.*", Outcome: OutcomeHit, Detail: "/music/a/cover.jpg"}, } Expect(DecodeTrace(encodeSteps(steps, ""), "")).To(Equal(steps)) }) It("encodes an empty trace as an empty JSON array", func() { Expect(encodeSteps(nil, "")).To(Equal("[]")) Expect(DecodeTrace("[]", "")).To(BeEmpty()) }) It("tolerates a row written before the column existed", func() { Expect(DecodeTrace("", "")).To(BeEmpty()) }) // The hit detail repeats source_path byte for byte, and that column is on the same row. It("drops a hit detail that repeats sourcePath, and restores it on read", func() { path := "/music/artist/album/cover.jpg" steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeHit, Detail: path}} encoded := encodeSteps(steps, path) Expect(encoded).NotTo(ContainSubstring(path)) Expect(DecodeTrace(encoded, path)).To(Equal(steps)) }) It("keeps a detail that differs from sourcePath", func() { steps := []TraceStep{{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "https://cdn/x.jpg"}} Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) }) // A row past ~1kB spills to an overflow page on these WITHOUT ROWID tables, which would // slow every scan; Detail is an error string on the failure paths, so it needs a bound. It("bounds a detail so one long error cannot inflate the row", func() { steps := []TraceStep{{Candidate: "decode", Outcome: OutcomeError, Detail: strings.Repeat("x", 5000)}} got := DecodeTrace(encodeSteps(steps, ""), "") Expect(len(got[0].Detail)).To(BeNumerically("<=", 210)) Expect(got[0].Detail).To(HaveSuffix("...")) Expect(got[0].Candidate).To(Equal("decode"), "truncating the detail must not disturb the step") }) It("only restores sourcePath onto a detail-less hit", func() { steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}} Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) }) }) 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 agent tracing", func() { var ( t *ChainTrace ctx context.Context body io.ReadCloser ) BeforeEach(func() { t = &ChainTrace{} ctx = withTrace(context.Background(), t) body = io.NopCloser(strings.NewReader("x")) }) It("records a hit with the image path", func() { recordAgent(ctx, "deezer", body, "http://img", nil) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"}, })) }) It("records a miss for a not-found", func() { recordAgent(ctx, "deezer", nil, "", agents.ErrNotFound) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss)) }) It("records a miss for a model not-found", func() { recordAgent(ctx, "deezer", nil, "", model.ErrNotFound) 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() { recordAgent(ctx, "apple-music", nil, "", errors.New("returned status 429")) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError)) Expect(t.Steps()[0].Detail).To(ContainSubstring("429")) }) }) 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("resolving", func() { var fake *fakeImageAgent BeforeEach(func() { // Misses, so the chain falls through to the local tier and both are traced. fake = &fakeImageAgent{name: "probe", err: agents.ErrNotFound} 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("asks the agents and records what each answered", func() { source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) Expect(source).To(Equal("embedded")) Expect(fake.albumCalls).To(Equal(1)) Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:probe", Outcome: OutcomeMiss})) }) It("records the local chain steps too", func() { _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).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 tier") Expect(last.Outcome).To(Equal(OutcomeHit)) }) It("never persists artwork state", func() { _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) Expect(artworkRepo.ItemData).To(BeEmpty(), "explain is read-only; a diagnostic walk must never become the stored answer") Expect(queueRepo.Data).To(BeEmpty()) }) It("resolves an artist without persisting anything", func() { source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindArtistArtwork, "ar1") Expect(err).ToNot(HaveOccurred()) Expect(source).To(BeEmpty()) Expect(fake.artistCalls).To(Equal(1)) 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, true).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") }) // Serving falls back disc -> album and track -> disc -> album. The resolver does not, but // if it ever did, an explain without --live would start calling providers uninvited. It("cannot reach a provider without live, whatever the chain does", func() { conf.Server.DiscArtPriority = "external, cover.*" conf.Server.CoverArtPriority = "external, cover.*" conf.Server.EnableMediaFileCoverArt = true mfRepo := tests.CreateMockMediaFileRepo() mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true, Path: "tests/fixtures/artist/an-album/test.mp3"}}) ds.MockedMediaFile = mfRepo offline := NewTracingResolver(ds, imageAgents(fake), ffm, t, false) _, err := offline.Resolve(context.Background(), model.KindDiscArtwork, "al1:1") Expect(err).ToNot(HaveOccurred()) _, err = offline.Resolve(context.Background(), model.KindMediaFileArtwork, "mf1") Expect(err).ToNot(HaveOccurred()) Expect(fake.albumCalls).To(BeZero()) Expect(fake.artistCalls).To(BeZero()) }) It("propagates a lookup error", func() { _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "nope") Expect(err).To(MatchError(model.ErrNotFound)) }) }) }) 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)) }) })