🏡 index : ironmagma/quintodrome.git

package e2e

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"

	"github.com/Masterminds/squirrel"
	"github.com/navidrome/navidrome/core"
	"github.com/navidrome/navidrome/core/agents"
	"github.com/navidrome/navidrome/core/lyrics"
	"github.com/navidrome/navidrome/core/matcher"
	"github.com/navidrome/navidrome/core/metrics"
	"github.com/navidrome/navidrome/core/playback"
	"github.com/navidrome/navidrome/core/playlists"
	"github.com/navidrome/navidrome/core/scrobbler"
	"github.com/navidrome/navidrome/core/sonic"
	"github.com/navidrome/navidrome/core/stream"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/server/events"
	"github.com/navidrome/navidrome/server/subsonic"
	"github.com/navidrome/navidrome/server/subsonic/responses"

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

// buildSonicRouter creates a subsonic.Router with a real sonic.Sonic service
// backed by the given provider and the shared e2e DataStore.
func buildSonicRouter(provider sonic.Provider) *subsonic.Router {
	loader := &mockSonicPluginLoader{provider: provider}
	m := matcher.New(ds)
	sonicSvc := sonic.New(ds, loader, m)
	decider := stream.NewTranscodeDecider(ds, noopFFmpeg{})
	return subsonic.New(
		ds,
		noopArtwork{},
		&spyStreamer{},
		noopArchiver{},
		core.NewPlayers(ds),
		noopProvider{},
		nil, // scanner
		events.NoopBroker(),
		playlists.NewPlaylists(ds, core.NewImageUploadService()),
		scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
		core.NewShare(ds),
		playback.PlaybackServer(nil),
		metrics.NewNoopInstance(),
		lyrics.NewLyrics(nil),
		decider,
		sonicSvc,
	)
}

// doSonicReq makes a request through a sonic-enabled router and returns the parsed response.
func doSonicReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *responses.Subsonic {
	w := httptest.NewRecorder()
	r := buildReq(adminUser, endpoint, params...)
	sonicRouter.ServeHTTP(w, r)
	return parseJSONResponse(w)
}

// doSonicRawReq makes a request through a sonic-enabled router and returns the raw recorder.
func doSonicRawReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *httptest.ResponseRecorder {
	w := httptest.NewRecorder()
	r := buildReq(adminUser, endpoint, params...)
	sonicRouter.ServeHTTP(w, r)
	return w
}

var _ = Describe("Sonic Similarity Endpoints", func() {
	BeforeEach(func() {
		setupTestDB()
	})

	Context("without sonic similarity plugin", func() {
		Describe("getSonicSimilarTracks", func() {
			It("returns 404 when no sonic similarity plugin is available", func() {
				w := doRawReq("getSonicSimilarTracks", "id", "any-song-id")
				Expect(w.Code).To(Equal(http.StatusNotFound))
			})
		})

		Describe("findSonicPath", func() {
			It("returns 404 when no sonic similarity plugin is available", func() {
				w := doRawReq("findSonicPath", "startSongId", "any-song-id", "endSongId", "another-song-id")
				Expect(w.Code).To(Equal(http.StatusNotFound))
			})
		})
	})

	Context("with sonic similarity plugin", func() {
		var (
			sonicRouter  *subsonic.Router
			comeTogether model.MediaFile
			something    model.MediaFile
		)

		BeforeEach(func() {
			songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
				Filters: squirrel.Eq{"title": "Come Together"},
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(songs).ToNot(BeEmpty())
			comeTogether = songs[0]

			songs, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
				Filters: squirrel.Eq{"title": "Something"},
			})
			Expect(err).ToNot(HaveOccurred())
			Expect(songs).ToNot(BeEmpty())
			something = songs[0]

			provider := &mockSonicProvider{
				similarIDs: []string{something.ID, comeTogether.ID},
				pathIDs:    []string{comeTogether.ID, something.ID},
			}
			sonicRouter = buildSonicRouter(provider)
		})

		Describe("getSonicSimilarTracks", func() {
			It("returns similar tracks with similarity scores", func() {
				resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID)

				Expect(resp.Status).To(Equal(responses.StatusOK))
				matches := *resp.SonicMatches
				Expect(matches).To(HaveLen(2))
				Expect(matches[0].Entry.Title).To(Equal("Something"))
				Expect(matches[0].Similarity).To(Equal(1.0))
				Expect(matches[1].Entry.Title).To(Equal("Come Together"))
				Expect(matches[1].Similarity).To(Equal(0.9))
			})

			It("respects the count parameter", func() {
				resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID, "count", "1")

				Expect(resp.Status).To(Equal(responses.StatusOK))
				Expect(*resp.SonicMatches).To(HaveLen(1))
			})

			It("returns an error for a missing id parameter", func() {
				resp := doSonicReq(sonicRouter, "getSonicSimilarTracks")

				Expect(resp.Status).To(Equal(responses.StatusFailed))
				Expect(resp.Error).ToNot(BeNil())
			})

			It("returns an error for a non-existent song ID", func() {
				resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", "non-existent-id")

				Expect(resp.Status).To(Equal(responses.StatusFailed))
				Expect(resp.Error).ToNot(BeNil())
			})

			It("returns correct JSON structure", func() {
				w := doSonicRawReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID)
				Expect(w.Code).To(Equal(http.StatusOK))

				var wrapper responses.JsonWrapper
				Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed())
				matches := *wrapper.Subsonic.SonicMatches
				Expect(matches).To(HaveLen(2))
				Expect(matches[0].Similarity).To(BeNumerically(">", 0))
				Expect(matches[0].Entry.Id).ToNot(BeEmpty())
			})
		})

		Describe("findSonicPath", func() {
			It("returns a path between two tracks with similarity scores", func() {
				resp := doSonicReq(sonicRouter, "findSonicPath",
					"startSongId", comeTogether.ID,
					"endSongId", something.ID,
				)

				Expect(resp.Status).To(Equal(responses.StatusOK))
				matches := *resp.SonicMatches
				Expect(matches).To(HaveLen(2))
				Expect(matches[0].Entry.Title).To(Equal("Come Together"))
				Expect(matches[0].Similarity).To(Equal(1.0))
				Expect(matches[1].Entry.Title).To(Equal("Something"))
				Expect(matches[1].Similarity).To(Equal(0.95))
			})

			It("returns an error for a missing startSongId parameter", func() {
				resp := doSonicReq(sonicRouter, "findSonicPath", "endSongId", something.ID)

				Expect(resp.Status).To(Equal(responses.StatusFailed))
				Expect(resp.Error).ToNot(BeNil())
			})

			It("returns an error for a missing endSongId parameter", func() {
				resp := doSonicReq(sonicRouter, "findSonicPath", "startSongId", comeTogether.ID)

				Expect(resp.Status).To(Equal(responses.StatusFailed))
				Expect(resp.Error).ToNot(BeNil())
			})

			It("returns an error for a non-existent start song ID", func() {
				resp := doSonicReq(sonicRouter, "findSonicPath",
					"startSongId", "non-existent-id",
					"endSongId", something.ID,
				)

				Expect(resp.Status).To(Equal(responses.StatusFailed))
				Expect(resp.Error).ToNot(BeNil())
			})

			It("returns an error for a non-existent end song ID", func() {
				resp := doSonicReq(sonicRouter, "findSonicPath",
					"startSongId", comeTogether.ID,
					"endSongId", "non-existent-id",
				)

				Expect(resp.Status).To(Equal(responses.StatusFailed))
				Expect(resp.Error).ToNot(BeNil())
			})
		})
	})
})

// mockSonicProvider returns results using IDs from the real test library,
// so that the matcher can resolve them back to actual MediaFiles.
type mockSonicProvider struct {
	similarIDs []string
	pathIDs    []string
}

func (m *mockSonicProvider) GetSonicSimilarTracks(_ context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
	var results []sonic.SimilarResult
	for i, id := range m.similarIDs {
		if i >= count {
			break
		}
		results = append(results, sonic.SimilarResult{
			Song:       agents.Song{ID: id},
			Similarity: 1.0 - float64(i)*0.1,
		})
	}
	return results, nil
}

func (m *mockSonicProvider) FindSonicPath(_ context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) {
	var results []sonic.SimilarResult
	for i, id := range m.pathIDs {
		if i >= count {
			break
		}
		results = append(results, sonic.SimilarResult{
			Song:       agents.Song{ID: id},
			Similarity: 1.0 - float64(i)*0.05,
		})
	}
	return results, nil
}

type mockSonicPluginLoader struct {
	provider sonic.Provider
}

func (m *mockSonicPluginLoader) PluginNames(capability string) []string {
	if capability == "SonicSimilarity" && m.provider != nil {
		return []string{"mock-sonic"}
	}
	return nil
}

func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonic.Provider, bool) {
	if m.provider != nil {
		return m.provider, true
	}
	return nil, false
}