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"
)
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,
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,
)
}
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)
}
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())
})
})
})
})
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
}