🏡 index : quine-global/quintodrome.git

package external_test

import (
	"context"
	"errors"
	"time"

	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	"github.com/navidrome/navidrome/core/agents"
	"github.com/navidrome/navidrome/core/external"
	"github.com/navidrome/navidrome/core/matcher"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
	"github.com/stretchr/testify/mock"
)

func init() {
	log.SetLevel(log.LevelDebug)
}

var _ = Describe("Provider - UpdateArtistInfo", func() {
	var (
		ctx            context.Context
		p              external.Provider
		ds             *tests.MockDataStore
		ag             *mockAgents
		mockArtistRepo *tests.MockArtistRepo
	)

	BeforeEach(func() {
		DeferCleanup(configtest.SetupConfig())
		conf.Server.DevArtistInfoTimeToLive = 1 * time.Hour
		ctx = GinkgoT().Context()
		ds = new(tests.MockDataStore)
		ag = new(mockAgents)
		p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
		mockArtistRepo = ds.Artist().(*tests.MockArtistRepo)
	})

	It("returns error when artist is not found", func() {
		artist, err := p.UpdateArtistInfo(ctx, "ar-not-found", 10, false)

		Expect(err).To(MatchError(model.ErrNotFound))
		Expect(artist).To(BeNil())
		ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
		ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
		ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
		ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
		ag.AssertNotCalled(GinkgoT(), "GetSimilarArtists")
	})

	It("populates info when artist exists but has no external info", func() {
		originalArtist := &model.Artist{
			ID:   "ar-existing",
			Name: "Test Artist",
		}
		mockArtistRepo.SetData(model.Artists{*originalArtist})

		expectedMBID := "mbid-artist-123"
		expectedBio := "Artist Bio"
		expectedURL := "http://artist.url"
		expectedImages := []agents.ExternalImage{
			{URL: "http://large.jpg", Size: 300},
			{URL: "http://medium.jpg", Size: 200},
			{URL: "http://small.jpg", Size: 100},
		}
		rawSimilar := []agents.Artist{
			{Name: "Similar Artist 1", MBID: "mbid-similar-1"},
			{Name: "Similar Artist 2", MBID: "mbid-similar-2"},
			{Name: "Similar Artist 3", MBID: "mbid-similar-3"},
		}
		similarInDS := model.Artist{ID: "ar-similar-2", Name: "Similar Artist 2"}

		ag.On("GetArtistMBID", ctx, "ar-existing", "Test Artist").Return(expectedMBID, nil).Once()
		ag.On("GetArtistImages", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedImages, nil).Once()
		ag.On("GetArtistBiography", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedBio, nil).Once()
		ag.On("GetArtistURL", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedURL, nil).Once()
		ag.On("GetSimilarArtists", ctx, "ar-existing", "Test Artist", expectedMBID, 100).Return(rawSimilar, nil).Once()

		mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})

		updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-existing", 10, false)

		Expect(err).NotTo(HaveOccurred())
		Expect(updatedArtist).NotTo(BeNil())
		Expect(updatedArtist.ID).To(Equal("ar-existing"))
		Expect(updatedArtist.MbzArtistID).To(Equal(expectedMBID))
		Expect(updatedArtist.Biography).To(Equal("Artist Bio"))
		Expect(updatedArtist.ExternalUrl).To(Equal(expectedURL))
		Expect(updatedArtist.LargeImageUrl).To(Equal("http://large.jpg"))
		Expect(updatedArtist.MediumImageUrl).To(Equal("http://medium.jpg"))
		Expect(updatedArtist.SmallImageUrl).To(Equal("http://small.jpg"))
		Expect(updatedArtist.ExternalInfoUpdatedAt).NotTo(BeNil())
		Expect(*updatedArtist.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))

		Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
		Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-2"))
		Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar Artist 2"))

		ag.AssertExpectations(GinkgoT())
	})

	// Stamping a throttled round would cache the empty result for the whole TTL.
	It("does not stamp ExternalInfoUpdatedAt when the agents are throttled", func() {
		originalArtist := &model.Artist{ID: "ar-throttled", Name: "Throttled Artist"}
		mockArtistRepo.SetData(model.Artists{*originalArtist})

		ag.On("GetArtistMBID", ctx, "ar-throttled", "Throttled Artist").Return("", agents.ErrRetryLater).Once()
		ag.On("GetArtistImages", ctx, "ar-throttled", "Throttled Artist", "").Return(nil, agents.ErrRetryLater).Once()
		ag.On("GetArtistBiography", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once()
		ag.On("GetArtistURL", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once()
		ag.On("GetSimilarArtists", ctx, "ar-throttled", "Throttled Artist", "", 100).Return(nil, agents.ErrRetryLater).Once()

		updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-throttled", 10, false)

		Expect(err).ToNot(HaveOccurred())
		Expect(updatedArtist).NotTo(BeNil())
		Expect(updatedArtist.ExternalInfoUpdatedAt).To(BeNil())
		ag.AssertExpectations(GinkgoT())
	})

	It("preserves decoded plain text in biography storage", func() {
		originalArtist := &model.Artist{
			ID:   "ar-encoded-bio",
			Name: "Encoded Bio Artist",
		}
		mockArtistRepo.SetData(model.Artists{*originalArtist})

		expectedMBID := "mbid-encoded-bio"
		expectedBio := "R&B"

		ag.On("GetArtistMBID", ctx, "ar-encoded-bio", "Encoded Bio Artist").Return(expectedMBID, nil).Once()
		ag.On("GetArtistImages", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(nil, nil).Maybe()
		ag.On("GetArtistBiography", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(expectedBio, nil).Once()
		ag.On("GetArtistURL", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return("", nil).Maybe()
		ag.On("GetSimilarArtists", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID, 100).Return(nil, nil).Maybe()

		updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-encoded-bio", 10, false)

		Expect(err).NotTo(HaveOccurred())
		Expect(updatedArtist).NotTo(BeNil())
		Expect(updatedArtist.Biography).To(Equal("R&B"))
	})

	It("returns cached info when artist exists and info is not expired", func() {
		now := time.Now()
		originalArtist := &model.Artist{
			ID:                    "ar-cached",
			Name:                  "Cached Artist",
			MbzArtistID:           "mbid-cached",
			ExternalUrl:           "http://cached.url",
			Biography:             "Cached Bio",
			LargeImageUrl:         "http://cached_large.jpg",
			ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
			SimilarArtists: model.Artists{
				{ID: "ar-similar-present", Name: "Similar Present"},
				{ID: "ar-similar-absent", Name: "Similar Absent"},
			},
		}
		similarInDS := model.Artist{ID: "ar-similar-present", Name: "Similar Present Updated"}
		mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})

		updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-cached", 5, false)

		Expect(err).NotTo(HaveOccurred())
		Expect(updatedArtist).NotTo(BeNil())
		Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
		Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
		Expect(updatedArtist.MbzArtistID).To(Equal(originalArtist.MbzArtistID))
		Expect(updatedArtist.ExternalUrl).To(Equal(originalArtist.ExternalUrl))
		Expect(updatedArtist.Biography).To(Equal(originalArtist.Biography))
		Expect(updatedArtist.LargeImageUrl).To(Equal(originalArtist.LargeImageUrl))
		Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))

		Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
		Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
		Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))

		ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
		ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
		ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
		ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
	})

	It("returns cached info and triggers background refresh when info is expired", func() {
		now := time.Now()
		expiredTime := now.Add(-conf.Server.DevArtistInfoTimeToLive * 2)
		originalArtist := &model.Artist{
			ID:                    "ar-expired",
			Name:                  "Expired Artist",
			ExternalInfoUpdatedAt: new(expiredTime),
			SimilarArtists: model.Artists{
				{ID: "ar-exp-similar", Name: "Expired Similar"},
			},
		}
		similarInDS := model.Artist{ID: "ar-exp-similar", Name: "Expired Similar Updated"}
		mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})

		updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-expired", 5, false)

		Expect(err).NotTo(HaveOccurred())
		Expect(updatedArtist).NotTo(BeNil())
		Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
		Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
		Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))

		Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
		Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
		Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))

		ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
		ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
		ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
		ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
	})

	It("includes non-present similar artists when includeNotPresent is true", func() {
		now := time.Now()
		originalArtist := &model.Artist{
			ID:                    "ar-similar-test",
			Name:                  "Similar Test Artist",
			ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
			SimilarArtists: model.Artists{
				{ID: "ar-sim-present", Name: "Similar Present"},
				{ID: "", Name: "Similar Absent Raw"},
				{ID: "ar-sim-absent-lookup", Name: "Similar Absent Lookup"},
			},
		}
		similarInDS := model.Artist{ID: "ar-sim-present", Name: "Similar Present Updated"}
		mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})

		updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-similar-test", 5, true)

		Expect(err).NotTo(HaveOccurred())
		Expect(updatedArtist).NotTo(BeNil())

		Expect(updatedArtist.SimilarArtists).To(HaveLen(3))
		Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
		Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
		Expect(updatedArtist.SimilarArtists[1].ID).To(BeEmpty())
		Expect(updatedArtist.SimilarArtists[1].Name).To(Equal("Similar Absent Raw"))
		Expect(updatedArtist.SimilarArtists[2].ID).To(BeEmpty())
		Expect(updatedArtist.SimilarArtists[2].Name).To(Equal("Similar Absent Lookup"))
	})

	It("updates ArtistInfo even if an optional agent call fails", func() {
		originalArtist := &model.Artist{
			ID:   "ar-agent-fail",
			Name: "Agent Fail Artist",
		}
		mockArtistRepo.SetData(model.Artists{*originalArtist})

		expectedErr := errors.New("agent MBID failed")
		ag.On("GetArtistMBID", ctx, "ar-agent-fail", "Agent Fail Artist").Return("", expectedErr).Once()
		ag.On("GetArtistImages", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return(nil, nil).Maybe()
		ag.On("GetArtistBiography", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
		ag.On("GetArtistURL", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
		ag.On("GetSimilarArtists", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything, 100).Return(nil, nil).Maybe()

		updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-agent-fail", 10, false)

		Expect(err).NotTo(HaveOccurred())
		Expect(updatedArtist).NotTo(BeNil())
		Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
		ag.AssertExpectations(GinkgoT())
	})

	It("matches similar artists by ID first when agent provides IDs", func() {
		originalArtist := &model.Artist{
			ID:   "ar-id-match",
			Name: "ID Match Artist",
		}
		similarByID := model.Artist{ID: "ar-similar-by-id", Name: "Similar By ID", MbzArtistID: "mbid-similar"}
		mockArtistRepo.SetData(model.Artists{*originalArtist, similarByID})

		// Agent returns similar artist with ID (highest priority matching)
		rawSimilar := []agents.Artist{
			{ID: "ar-similar-by-id", Name: "Different Name", MBID: "different-mbid"},
		}

		ag.On("GetArtistMBID", ctx, "ar-id-match", "ID Match Artist").Return("", nil).Once()
		ag.On("GetArtistImages", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return(nil, nil).Maybe()
		ag.On("GetArtistBiography", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe()
		ag.On("GetArtistURL", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe()
		ag.On("GetSimilarArtists", ctx, "ar-id-match", "ID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()

		updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-id-match", 10, false)

		Expect(err).NotTo(HaveOccurred())
		Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
		// Should match by ID, not by name or MBID
		Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-id"))
		Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By ID"))
	})

	It("matches similar artists by MBID when ID is empty", func() {
		originalArtist := &model.Artist{
			ID:   "ar-mbid-match",
			Name: "MBID Match Artist",
		}
		similarByMBID := model.Artist{ID: "ar-similar-by-mbid", Name: "Similar By MBID", MbzArtistID: "mbid-similar"}
		mockArtistRepo.SetData(model.Artists{*originalArtist, similarByMBID})

		// Agent returns similar artist with only MBID (no ID)
		rawSimilar := []agents.Artist{
			{Name: "Different Name", MBID: "mbid-similar"},
		}

		ag.On("GetArtistMBID", ctx, "ar-mbid-match", "MBID Match Artist").Return("", nil).Once()
		ag.On("GetArtistImages", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return(nil, nil).Maybe()
		ag.On("GetArtistBiography", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe()
		ag.On("GetArtistURL", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe()
		ag.On("GetSimilarArtists", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()

		updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-mbid-match", 10, false)

		Expect(err).NotTo(HaveOccurred())
		Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
		// Should match by MBID since ID was empty
		Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-mbid"))
		Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By MBID"))
	})

	It("falls back to name matching when ID and MBID don't match", func() {
		originalArtist := &model.Artist{
			ID:   "ar-name-match",
			Name: "Name Match Artist",
		}
		similarByName := model.Artist{ID: "ar-similar-by-name", Name: "Similar By Name"}
		mockArtistRepo.SetData(model.Artists{*originalArtist, similarByName})

		// Agent returns similar artist with non-matching ID and MBID
		rawSimilar := []agents.Artist{
			{ID: "non-existent-id", Name: "Similar By Name", MBID: "non-existent-mbid"},
		}

		ag.On("GetArtistMBID", ctx, "ar-name-match", "Name Match Artist").Return("", nil).Once()
		ag.On("GetArtistImages", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return(nil, nil).Maybe()
		ag.On("GetArtistBiography", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe()
		ag.On("GetArtistURL", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe()
		ag.On("GetSimilarArtists", ctx, "ar-name-match", "Name Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()

		updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-name-match", 10, false)

		Expect(err).NotTo(HaveOccurred())
		Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
		// Should fall back to name matching since ID and MBID didn't match
		Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-name"))
		Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By Name"))
	})
})