🏡 index : quine-global/quintodrome.git

package artwork

import (
	"context"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"time"

	"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/navidrome/navidrome/utils/str"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

// fakeImageAgent is a built-in agent stub implementing both image retrievers; it
// records call counts so per-agent ordering and short-circuiting can be asserted.
type fakeImageAgent struct {
	name          string
	imgs          []agents.ExternalImage
	err           error
	artistCalls   int
	albumCalls    int
	gotArtistName string
	gotAlbumName  string
	// block, when set, holds every lookup until closed, standing in for a slow/rate-limited agent.
	block chan struct{}
	// mu guards the call counters: the worker resolves several items concurrently.
	mu sync.Mutex
}

func (f *fakeImageAgent) AgentName() string { return f.name }

func (f *fakeImageAgent) GetArtistImages(_ context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
	if f.block != nil {
		<-f.block
	}
	f.mu.Lock()
	f.artistCalls++
	f.gotArtistName = name
	f.mu.Unlock()
	return f.imgs, f.err
}

func (f *fakeImageAgent) GetAlbumImages(_ context.Context, name, _, _ string) ([]agents.ExternalImage, error) {
	f.albumCalls++
	f.gotAlbumName = name
	return f.imgs, f.err
}

// imageAgents registers the fakes as built-in agents and enables them in order. The fakes
// ignore the DataStore, so reusing the process-wide GetAgents singleton across tests is safe.
func imageAgents(fakes ...*fakeImageAgent) *agents.Agents {
	names := make([]string, 0, len(fakes))
	for _, f := range fakes {
		fake := f
		agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake })
		names = append(names, fake.name)
	}
	conf.Server.Agents = strings.Join(names, ",")
	return agents.GetAgents(&tests.MockDataStore{}, nil)
}

var _ = Describe("agent images", func() {
	var (
		ctx context.Context
		srv *httptest.Server
	)

	BeforeEach(func() {
		DeferCleanup(configtest.SetupConfig())
		ctx = context.Background()
		srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
			_, _ = w.Write([]byte("image-bytes"))
		}))
		DeferCleanup(srv.Close)
	})

	img := func(path string, size int) agents.ExternalImage {
		return agents.ExternalImage{URL: srv.URL + path, Size: size}
	}

	Describe("bestImageURL", func() {
		It("picks the largest-Size URL and skips empty ones", func() {
			u := bestImageURL([]agents.ExternalImage{
				{URL: "http://x/small", Size: 10},
				{URL: "", Size: 9999},
				{URL: "http://x/big", Size: 100},
			})
			Expect(u).ToNot(BeNil())
			Expect(u.String()).To(Equal("http://x/big"))
		})

		It("skips a malformed largest URL and falls back to a valid smaller one", func() {
			u := bestImageURL([]agents.ExternalImage{
				{URL: "http://x/valid", Size: 10},
				{URL: "http://x/%zz", Size: 100}, // invalid percent-escape, largest
			})
			Expect(u).ToNot(BeNil())
			Expect(u.String()).To(Equal("http://x/valid"))
		})

		It("returns nil when there is no non-empty URL", func() {
			Expect(bestImageURL(nil)).To(BeNil())
			Expect(bestImageURL([]agents.ExternalImage{{URL: "", Size: 5}})).To(BeNil())
		})

		// Plugins hand these over as free-form strings, and url.Parse accepts them all. An
		// unfetchable candidate that wins here ends the agent's turn before its valid images run.
		DescribeTable("skips a candidate that cannot be fetched",
			func(badURL string) {
				u := bestImageURL([]agents.ExternalImage{
					{URL: badURL, Size: 100}, // largest, and first
					{URL: "https://cdn.example.com/ok.jpg", Size: 10},
				})
				Expect(u).ToNot(BeNil())
				Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg"))
			},
			Entry("a relative path", "images/big.jpg"),
			Entry("a root-relative path", "/images/big.jpg"),
			Entry("a scheme we cannot fetch", "ftp://host/big.jpg"),
			Entry("a scheme-relative URL", "//host/big.jpg"),
			Entry("a URL with no host", "http:///big.jpg"),
		)

		// Size is often 0 for every candidate, and only a strictly larger one replaces the first,
		// so an unfetchable entry in first position would otherwise stick.
		It("skips an unfetchable first candidate when every Size is zero", func() {
			u := bestImageURL([]agents.ExternalImage{
				{URL: "images/rel.jpg"},
				{URL: "https://cdn.example.com/ok.jpg"},
			})
			Expect(u).ToNot(BeNil())
			Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg"))
		})

		It("returns nil when no candidate is fetchable", func() {
			Expect(bestImageURL([]agents.ExternalImage{
				{URL: "images/a.jpg", Size: 10},
				{URL: "ftp://host/b.jpg", Size: 20},
			})).To(BeNil())
		})
	})

	Describe("fetchArtistImage", func() {
		It("returns the first agent's image and its name", func() {
			a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
			ag := imageAgents(a)

			r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"})
			Expect(r).ToNot(BeNil())
			defer r.Close()
			Expect(name).To(Equal("agentA"))
			Expect(err).ToNot(HaveOccurred())
		})

		It("skips the external lookup for synthetic artists", func() {
			a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
			ag := imageAgents(a)

			for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} {
				r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"})
				Expect(r).To(BeNil())
				Expect(name).To(BeEmpty())
				Expect(err).ToNot(HaveOccurred())
			}
			Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents")
		})

		It("records a skipped external candidate when no agent provides artist images", func() {
			ag := imageAgents()
			t := &ChainTrace{}

			r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"})
			Expect(r).To(BeNil())
			Expect(err).ToNot(HaveOccurred())
			Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
				Detail: "no enabled agent provides artist images"}}),
				"a configured external token must never be silently absent from the chain")
		})

		It("records a skipped external candidate for synthetic artists", func() {
			a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
			ag := imageAgents(a)
			t := &ChainTrace{}

			_, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate,
				model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"})
			Expect(t.Steps()).To(HaveLen(1))
			Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped))
			Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic"))
		})

		It("clears typographic characters from the query name unless preserving unicode", func() {
			conf.Server.DevPreserveUnicodeInExternalCalls = false
			a := &fakeImageAgent{name: "agentA"}
			ag := imageAgents(a)

			_, _, _ = fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "AC’DC"})
			Expect(a.gotArtistName).To(Equal(str.Clear("AC’DC")))
		})

		It("falls through to a later agent, and its success beats the earlier error", func() {
			a := &fakeImageAgent{name: "agentA", err: errBreakerOpen}
			b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}}
			ag := imageAgents(a, b)

			r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
			Expect(r).ToNot(BeNil())
			defer r.Close()
			Expect(name).To(Equal("agentB"))
			Expect(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error")
			Expect(a.artistCalls).To(Equal(1))
			Expect(b.artistCalls).To(Equal(1))
		})

		It("reports a clean miss when every agent finds nothing", func() {
			a := &fakeImageAgent{name: "agentA"} // no images, no error -> not found
			b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound}
			ag := imageAgents(a, b)

			r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
			Expect(r).To(BeNil())
			Expect(name).To(BeEmpty())
			Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure")
		})

		It("reports an error when one agent fails transiently and the rest find nothing", func() {
			a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound}
			b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded}
			ag := imageAgents(a, b)

			r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
			Expect(r).To(BeNil())
			Expect(err).To(HaveOccurred())
		})

		// The worker reschedules on this delay, so it is only honored if the agent loop
		// returns it. Two throttled agents: the longest wait is the one that must survive.
		It("returns the longest retry delay the providers asked for", func() {
			a := &fakeImageAgent{name: "agentA", err: &agents.RetryLaterError{RetryIn: 10 * time.Second}}
			b := &fakeImageAgent{name: "agentB", err: &agents.RetryLaterError{RetryIn: 5 * time.Second}}
			ag := imageAgents(a, b)

			r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
			Expect(r).To(BeNil())
			retry, ok := errors.AsType[*agents.RetryLaterError](err)
			Expect(ok).To(BeTrue())
			Expect(retry.RetryIn).To(Equal(10 * time.Second))
		})

		It("returns no delay when the provider did not ask for one", func() {
			ag := imageAgents(&fakeImageAgent{name: "agentA", err: errors.New("boom")})

			_, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"})
			Expect(err).To(HaveOccurred())
			_, ok := errors.AsType[*agents.RetryLaterError](err)
			Expect(ok).To(BeFalse(), "a plain failure must not look like a throttle")
		})
	})

	Describe("fetchAlbumImage", func() {
		It("returns the winning agent's image and name", func() {
			a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
			ag := imageAgents(a)

			r, name, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"})
			Expect(r).ToNot(BeNil())
			defer r.Close()
			Expect(name).To(Equal("agentA"))
			Expect(err).ToNot(HaveOccurred())
			Expect(a.albumCalls).To(Equal(1))
		})

		It("records a skipped external candidate when no agent provides album images", func() {
			ag := imageAgents()
			t := &ChainTrace{}

			r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"})
			Expect(r).To(BeNil())
			Expect(err).ToNot(HaveOccurred())
			Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
				Detail: "no enabled agent provides album images"}}),
				"a configured external token must never be silently absent from the chain")
		})

		It("reports an error when the only agent fails transiently", func() {
			a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
			ag := imageAgents(a)

			r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"})
			Expect(r).To(BeNil())
			Expect(err).To(HaveOccurred())
		})
	})

	Describe("gate naming", func() {
		It("invokes the gate once per agent, keyed by agent name", func() {
			a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
			b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 1)}}
			ag := imageAgents(a, b)

			var gatedNames []string
			gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
				gatedNames = append(gatedNames, name)
				return f()
			}

			r, _, _ := fetchArtistImage(ctx, ag, gate, model.Artist{ID: "ar1"})
			Expect(r).ToNot(BeNil())
			defer r.Close()
			Expect(gatedNames).To(Equal([]string{"agentA", "agentB"}))
		})
	})
})