🏡 index : quine-global/quintodrome.git

package agents

import (
	"context"
	"errors"
	"slices"
	"time"

	"github.com/navidrome/navidrome/conf/configtest"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/tests"

	"github.com/navidrome/navidrome/conf"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("cooldowns", func() {
	// Calls to one agent overlap, so a short cooldown can land after a long one started.
	It("keeps the longer deadline when a shorter park lands after it", func() {
		c := cooldowns{until: map[string]time.Time{}}

		c.park("fake", time.Hour)
		c.park("fake", time.Millisecond)

		time.Sleep(10 * time.Millisecond)
		Expect(c.active("fake")).To(BeTrue())
	})

	It("extends the deadline when the later park is longer", func() {
		c := cooldowns{until: map[string]time.Time{}}

		c.park("fake", time.Millisecond)
		c.park("fake", time.Hour)

		time.Sleep(10 * time.Millisecond)
		Expect(c.active("fake")).To(BeTrue())
	})
})

var _ = Describe("Agents", func() {
	var ctx context.Context
	var cancel context.CancelFunc
	var ds model.DataStore
	var mfRepo *tests.MockMediaFileRepo
	BeforeEach(func() {
		DeferCleanup(configtest.SetupConfig())
		ctx, cancel = context.WithCancel(context.Background())
		mfRepo = tests.CreateMockMediaFileRepo()
		ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
	})

	Describe("Local", func() {
		var ag *Agents
		BeforeEach(func() {
			conf.Server.Agents = ""
			ag = createAgents(ds, nil)
		})

		It("calls the placeholder GetArtistImages", func() {
			mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}})
			songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2)
			Expect(err).ToNot(HaveOccurred())
			Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}}))
		})
	})

	Describe("Agents", func() {
		var ag *Agents
		var mock *mockAgent
		BeforeEach(func() {
			mock = &mockAgent{}
			Register("fake", func(model.DataStore) Interface { return mock })
			Register("disabled", func(model.DataStore) Interface { return nil })
			Register("empty", func(model.DataStore) Interface { return &emptyAgent{} })
			conf.Server.Agents = "empty,fake,disabled"
			ag = createAgents(ds, nil)
			Expect(ag.AgentName()).To(Equal("agents"))
		})

		It("does not register disabled agents", func() {
			var ags []string
			for _, enabledAgent := range ag.getEnabledAgentNames() {
				agent := ag.getAgent(enabledAgent)
				if agent != nil {
					ags = append(ags, agent.AgentName())
				}
			}
			// local agent is always appended to the end of the agents list
			Expect(ags).To(HaveExactElements("empty", "fake", "local"))
			Expect(ags).ToNot(ContainElement("disabled"))
		})

		Describe("availableAgentNames", func() {
			It("combines built-in agents with the given plugins", func() {
				names := availableAgentNames([]string{"apple-music"})
				Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty"))
			})

			It("returns the names sorted", func() {
				names := availableAgentNames([]string{"zz-plugin", "aa-plugin"})
				Expect(slices.IsSorted(names)).To(BeTrue())
			})

			It("works when there are no plugins", func() {
				Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName))
			})
		})

		Describe("GetArtistMBID", func() {
			It("returns on first match", func() {
				Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
				Expect(mock.Args).To(HaveExactElements("123", "test"))
			})
			It("returns empty if artist is Various Artists", func() {
				mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
				Expect(err).ToNot(HaveOccurred())
				Expect(mbid).To(BeEmpty())
				Expect(mock.Args).To(BeEmpty())
			})
			It("returns not found if artist is Unknown Artist", func() {
				mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
				Expect(err).ToNot(HaveOccurred())
				Expect(mbid).To(BeEmpty())
				Expect(mock.Args).To(BeEmpty())
			})
			It("skips the agent if it returns an error", func() {
				mock.Err = errors.New("error")
				_, err := ag.GetArtistMBID(ctx, "123", "test")
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(HaveExactElements("123", "test"))
			})
			It("interrupts if the context is canceled", func() {
				cancel()
				_, err := ag.GetArtistMBID(ctx, "123", "test")
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(BeEmpty())
			})
		})

		Describe("GetArtistURL", func() {
			It("returns on first match", func() {
				Expect(ag.GetArtistURL(ctx, "123", "test", "mb123")).To(Equal("url"))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
			})
			It("returns empty if artist is Various Artists", func() {
				url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
				Expect(err).ToNot(HaveOccurred())
				Expect(url).To(BeEmpty())
				Expect(mock.Args).To(BeEmpty())
			})
			It("returns not found if artist is Unknown Artist", func() {
				url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
				Expect(err).ToNot(HaveOccurred())
				Expect(url).To(BeEmpty())
				Expect(mock.Args).To(BeEmpty())
			})
			It("skips the agent if it returns an error", func() {
				mock.Err = errors.New("error")
				_, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
			})
			It("interrupts if the context is canceled", func() {
				cancel()
				_, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(BeEmpty())
			})
		})

		Describe("GetArtistBiography", func() {
			It("returns on first match", func() {
				Expect(ag.GetArtistBiography(ctx, "123", "test", "mb123")).To(Equal("bio"))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
			})
			It("returns empty if artist is Various Artists", func() {
				bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
				Expect(err).ToNot(HaveOccurred())
				Expect(bio).To(BeEmpty())
				Expect(mock.Args).To(BeEmpty())
			})
			It("returns not found if artist is Unknown Artist", func() {
				bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
				Expect(err).ToNot(HaveOccurred())
				Expect(bio).To(BeEmpty())
				Expect(mock.Args).To(BeEmpty())
			})
			It("skips the agent if it returns an error", func() {
				mock.Err = errors.New("error")
				_, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
			})
			It("interrupts if the context is canceled", func() {
				cancel()
				_, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(BeEmpty())
			})
		})

		Describe("cooldown", func() {
			It("skips an agent that returned RetryLaterError until the deadline", func() {
				mock.Err = &RetryLaterError{RetryIn: time.Hour}
				_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
				Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())

				// Immediately after: agent is skipped, not called
				mock.Err = nil
				calls := mock.Calls
				_, err = ag.GetArtistBiography(ctx, "id", "name", "mbid")
				Expect(mock.Calls).To(Equal(calls))
				Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
			})

			// Providers that throttle without saying for how long (Last.fm sends no delay at all)
			// must still be parked, or the aggregate keeps calling them on every request.
			It("parks an agent that asked to be retried without a delay", func() {
				mock.Err = ErrRetryLater
				_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
				Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())

				mock.Err = nil
				calls := mock.Calls
				_, err = ag.GetArtistBiography(ctx, "id", "name", "mbid")
				Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request")
				Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
			})

			It("calls the agent again once the cooldown expires", func() {
				mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond}
				_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
				Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())

				mock.Err = nil
				Eventually(func() (string, error) {
					return ag.GetArtistBiography(ctx, "id", "name", "mbid")
				}, 5*time.Second, 10*time.Millisecond).Should(Equal("bio"))
			})

			It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() {
				mock.Err = errors.New("boom")
				_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
				Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
				Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
			})

			// ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive
			// answer from any other agent is an answer, throttled peer or not.
			It("returns ErrNotFound when another agent answered with a definitive miss", func() {
				other := &mockAgent{Err: ErrNotFound}
				Register("fake2", func(model.DataStore) Interface { return other })
				conf.Server.Agents = "fake,fake2"
				ag = createAgents(ds, nil)
				mock.Err = &RetryLaterError{RetryIn: time.Hour}

				_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
				Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
				Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())

				// The cooldown was still recorded for the throttled agent
				calls := mock.Calls
				_, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid")
				Expect(mock.Calls).To(Equal(calls))
			})

			It("returns ErrNotFound when another agent answered with an empty slice", func() {
				empty := &testImageAgent{Name: "emptyImages"}
				Register("emptyImages", func(model.DataStore) Interface { return empty })
				conf.Server.Agents = "fake,emptyImages"
				ag = createAgents(ds, nil)
				mock.Err = &RetryLaterError{RetryIn: time.Hour}

				_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
				Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
				Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
			})

			It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() {
				mock.Err = &RetryLaterError{RetryIn: time.Hour}
				_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2)
				Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
			})

			It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() {
				other := &mockAgent{Err: ErrNotFound}
				Register("fake2", func(model.DataStore) Interface { return other })
				conf.Server.Agents = "fake,fake2"
				ag = createAgents(ds, nil)
				mock.Err = &RetryLaterError{RetryIn: time.Hour}

				_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2)
				Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
				Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
			})
		})

		Describe("GetArtistImages", func() {
			It("returns on first match", func() {
				Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{
					URL:  "imageUrl",
					Size: 100,
				}}))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
			})
			It("skips the agent if it returns an error", func() {
				mock.Err = errors.New("error")
				_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
				Expect(err).To(MatchError("not found"))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
			})
			It("interrupts if the context is canceled", func() {
				cancel()
				_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(BeEmpty())
			})

			Context("with multiple image agents", func() {
				var first *testImageAgent
				var second *testImageAgent

				BeforeEach(func() {
					first = &testImageAgent{Name: "imgFail", Err: errors.New("fail")}
					second = &testImageAgent{Name: "imgOk", Images: []ExternalImage{{URL: "ok", Size: 1}}}
					Register("imgFail", func(model.DataStore) Interface { return first })
					Register("imgOk", func(model.DataStore) Interface { return second })
				})

				It("falls back to the next agent on error", func() {
					conf.Server.Agents = "imgFail,imgOk"
					ag = createAgents(ds, nil)

					images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
					Expect(err).ToNot(HaveOccurred())
					Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}}))
					Expect(first.Args).To(HaveExactElements("id", "artist", "mbid"))
					Expect(second.Args).To(HaveExactElements("id", "artist", "mbid"))
				})

				It("falls back if the first agent returns no images", func() {
					first.Err = nil
					first.Images = []ExternalImage{}
					conf.Server.Agents = "imgFail,imgOk"
					ag = createAgents(ds, nil)

					images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
					Expect(err).ToNot(HaveOccurred())
					Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}}))
					Expect(first.Args).To(HaveExactElements("id", "artist", "mbid"))
					Expect(second.Args).To(HaveExactElements("id", "artist", "mbid"))
				})
			})
		})

		Describe("GetSimilarArtists", func() {
			It("returns on first match", func() {
				Expect(ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)).To(Equal([]Artist{{
					Name: "Joe Dohn",
					MBID: "mbid321",
				}}))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
			})
			It("skips the agent if it returns an error", func() {
				mock.Err = errors.New("error")
				_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
			})
			It("interrupts if the context is canceled", func() {
				cancel()
				_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(BeEmpty())
			})
		})

		Describe("GetArtistTopSongs", func() {
			It("returns on first match", func() {
				conf.Server.DevExternalArtistFetchMultiplier = 1
				Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
					Name: "A Song",
					MBID: "mbid444",
				}}))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
			})
			It("skips the agent if it returns an error", func() {
				conf.Server.DevExternalArtistFetchMultiplier = 1
				mock.Err = errors.New("error")
				_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
			})
			It("interrupts if the context is canceled", func() {
				cancel()
				_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(BeEmpty())
			})
			It("fetches with multiplier", func() {
				conf.Server.DevExternalArtistFetchMultiplier = 2
				Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
					Name: "A Song",
					MBID: "mbid444",
				}}))
				Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 4))
			})
		})

		Describe("GetAlbumInfo", func() {
			It("returns meaningful data", func() {
				Expect(ag.GetAlbumInfo(ctx, "album", "artist", "mbid")).To(Equal(&AlbumInfo{
					Name:        "A Song",
					MBID:        "mbid444",
					Description: "A Description",
					URL:         "External URL",
				}))
				Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
			})
			It("skips the agent if it returns an error", func() {
				mock.Err = errors.New("error")
				_, err := ag.GetAlbumInfo(ctx, "album", "artist", "mbid")
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
			})
			It("interrupts if the context is canceled", func() {
				cancel()
				_, err := ag.GetAlbumInfo(ctx, "album", "artist", "mbid")
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(BeEmpty())
			})
		})

		Describe("GetSimilarSongsByTrack", func() {
			It("returns on first match", func() {
				Expect(ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)).To(Equal([]Song{{
					Name: "Similar Song",
					MBID: "mbid555",
				}}))
				Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
			})
			It("skips the agent if it returns an error", func() {
				mock.Err = errors.New("error")
				_, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
			})
			It("interrupts if the context is canceled", func() {
				cancel()
				_, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(BeEmpty())
			})
		})

		Describe("GetSimilarSongsByAlbum", func() {
			It("returns on first match", func() {
				Expect(ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)).To(Equal([]Song{{
					Name: "Album Similar Song",
					MBID: "mbid666",
				}}))
				Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
			})
			It("skips the agent if it returns an error", func() {
				mock.Err = errors.New("error")
				_, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
			})
			It("interrupts if the context is canceled", func() {
				cancel()
				_, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(BeEmpty())
			})
		})

		Describe("GetSimilarSongsByArtist", func() {
			It("returns on first match", func() {
				Expect(ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)).To(Equal([]Song{{
					Name: "Artist Similar Song",
					MBID: "mbid777",
				}}))
				Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
			})
			It("skips the agent if it returns an error", func() {
				mock.Err = errors.New("error")
				_, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
			})
			It("interrupts if the context is canceled", func() {
				cancel()
				_, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
				Expect(err).To(MatchError(ErrNotFound))
				Expect(mock.Args).To(BeEmpty())
			})
		})
	})

	Describe("Image retriever enumeration", func() {
		var ag *Agents
		var artistImg, artistImg2 *testImageAgent
		var albumImg, albumImg2 *testAlbumImageAgent

		BeforeEach(func() {
			artistImg = &testImageAgent{Name: "artistImg"}
			artistImg2 = &testImageAgent{Name: "artistImg2"}
			albumImg = &testAlbumImageAgent{name: "albumImg"}
			albumImg2 = &testAlbumImageAgent{name: "albumImg2"}
			Register("artistImg", func(model.DataStore) Interface { return artistImg })
			Register("artistImg2", func(model.DataStore) Interface { return artistImg2 })
			Register("albumImg", func(model.DataStore) Interface { return albumImg })
			Register("albumImg2", func(model.DataStore) Interface { return albumImg2 })
			Register("noImages", func(model.DataStore) Interface { return &emptyAgent{} })
		})

		Describe("ArtistImageAgents", func() {
			It("returns only ArtistImageRetriever agents, named, in configured order", func() {
				conf.Server.Agents = "artistImg,noImages,artistImg2"
				ag = createAgents(ds, nil)

				result := ag.ArtistImageAgents()
				Expect(result).To(HaveLen(2))
				Expect(result[0].Name).To(Equal("artistImg"))
				Expect(result[0].Retriever).To(BeIdenticalTo(artistImg))
				Expect(result[1].Name).To(Equal("artistImg2"))
				Expect(result[1].Retriever).To(BeIdenticalTo(artistImg2))
			})

			It("is empty when external services are disabled", func() {
				conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
				ag = createAgents(ds, nil)
				Expect(ag.ArtistImageAgents()).To(BeEmpty())
			})
		})

		Describe("AlbumImageAgents", func() {
			It("returns only AlbumImageRetriever agents, named, in configured order", func() {
				conf.Server.Agents = "albumImg,noImages,albumImg2"
				ag = createAgents(ds, nil)

				result := ag.AlbumImageAgents()
				Expect(result).To(HaveLen(2))
				Expect(result[0].Name).To(Equal("albumImg"))
				Expect(result[0].Retriever).To(BeIdenticalTo(albumImg))
				Expect(result[1].Name).To(Equal("albumImg2"))
				Expect(result[1].Retriever).To(BeIdenticalTo(albumImg2))
			})

			It("is empty when external services are disabled", func() {
				conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
				ag = createAgents(ds, nil)
				Expect(ag.AlbumImageAgents()).To(BeEmpty())
			})
		})
	})
})

type mockAgent struct {
	Args  []any
	Err   error
	Calls int
}

func (a *mockAgent) AgentName() string {
	return "fake"
}

func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
	a.Args = []any{id, name}
	if a.Err != nil {
		return "", a.Err
	}
	return "mbid", nil
}

func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
	a.Args = []any{id, name, mbid}
	if a.Err != nil {
		return "", a.Err
	}
	return "url", nil
}

func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
	a.Args = []any{id, name, mbid}
	a.Calls++
	if a.Err != nil {
		return "", a.Err
	}
	return "bio", nil
}

func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
	a.Args = []any{id, name, mbid}
	if a.Err != nil {
		return nil, a.Err
	}
	return []ExternalImage{{
		URL:  "imageUrl",
		Size: 100,
	}}, nil
}

func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) {
	a.Args = []any{id, name, mbid, limit}
	if a.Err != nil {
		return nil, a.Err
	}
	return []Artist{{
		Name: "Joe Dohn",
		MBID: "mbid321",
	}}, nil
}

func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) {
	a.Args = []any{id, artistName, mbid, count}
	if a.Err != nil {
		return nil, a.Err
	}
	return []Song{{
		Name: "A Song",
		MBID: "mbid444",
	}}, nil
}

func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
	a.Args = []any{name, artist, mbid}
	if a.Err != nil {
		return nil, a.Err
	}
	return &AlbumInfo{
		Name:        "A Song",
		MBID:        "mbid444",
		Description: "A Description",
		URL:         "External URL",
	}, nil
}

func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
	a.Args = []any{id, name, artist, mbid, count}
	if a.Err != nil {
		return nil, a.Err
	}
	return []Song{{
		Name: "Similar Song",
		MBID: "mbid555",
	}}, nil
}

func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
	a.Args = []any{id, name, artist, mbid, count}
	if a.Err != nil {
		return nil, a.Err
	}
	return []Song{{
		Name: "Album Similar Song",
		MBID: "mbid666",
	}}, nil
}

func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) {
	a.Args = []any{id, name, mbid, count}
	if a.Err != nil {
		return nil, a.Err
	}
	return []Song{{
		Name: "Artist Similar Song",
		MBID: "mbid777",
	}}, nil
}

type emptyAgent struct {
	Interface
}

func (e *emptyAgent) AgentName() string {
	return "empty"
}

type testImageAgent struct {
	Name   string
	Images []ExternalImage
	Err    error
	Args   []any
}

func (t *testImageAgent) AgentName() string { return t.Name }

func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
	t.Args = []any{id, name, mbid}
	return t.Images, t.Err
}

type testAlbumImageAgent struct {
	name   string
	Images []ExternalImage
	Err    error
	Args   []any
}

func (t *testAlbumImageAgent) AgentName() string { return t.name }

func (t *testAlbumImageAgent) GetAlbumImages(_ context.Context, name, artist, mbid string) ([]ExternalImage, error) {
	t.Args = []any{name, artist, mbid}
	return t.Images, t.Err
}