package jellyfin
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("AudioMuse info", func() {
It("lists the sonic endpoints (excluding info) when a provider is present", func() {
api := &Router{sonic: &fakeSonicEngine{provider: true}}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil)
api.audioMuseInfo(w, r)
Expect(w.Code).To(Equal(200))
var body audioMuseInfoResponse
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body.Version).To(Equal(consts.Version))
Expect(body.AvailableEndpoints).To(ConsistOf(
"GET /AudioMuseAI/find_path",
"GET /AudioMuseAI/health",
"GET /AudioMuseAI/similar_tracks",
))
})
It("returns an empty endpoint list when no provider is loaded", func() {
api := &Router{}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil)
api.audioMuseInfo(w, r)
Expect(w.Code).To(Equal(200))
var body audioMuseInfoResponse
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body.AvailableEndpoints).To(BeEmpty())
Expect(w.Body.String()).To(ContainSubstring(`"AvailableEndpoints":[]`))
})
})
type fakeSonicEngine struct {
provider bool
similar []sonic.SimilarMatch
similarErr error
path []sonic.SimilarMatch
pathErr error
gotID string
gotStart string
gotEnd string
gotCount int
}
func (f *fakeSonicEngine) HasProvider() bool { return f.provider }
func (f *fakeSonicEngine) GetSonicSimilarTracks(_ context.Context, id string, count int) ([]sonic.SimilarMatch, error) {
f.gotID, f.gotCount = id, count
return f.similar, f.similarErr
}
func (f *fakeSonicEngine) FindSonicPath(_ context.Context, startID, endID string, count int) ([]sonic.SimilarMatch, error) {
f.gotStart, f.gotEnd, f.gotCount = startID, endID, count
return f.path, f.pathErr
}
func mf(id, artist, title string, lib int) model.MediaFile {
return model.MediaFile{ID: id, Artist: artist, Title: title, LibraryID: lib}
}
var _ = Describe("AudioMuse health", func() {
It("returns 200 with an empty body when a provider is loaded", func() {
api := &Router{sonic: &fakeSonicEngine{provider: true}}
w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(w.Body.Len()).To(Equal(0))
})
It("returns 404 when no provider is loaded", func() {
api := &Router{}
w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(404))
})
})
func audioMuseGet(handler http.HandlerFunc, path, query string, user model.User) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", path+"?"+query, nil)
r = r.WithContext(request.WithUser(r.Context(), user))
invoke(handler, w, r)
return w
}
var _ = Describe("AudioMuse similar_tracks", func() {
var fake *fakeSonicEngine
var api *Router
call := func(query string, user model.User) *httptest.ResponseRecorder {
return audioMuseGet(api.audioMuseSimilarTracks, "/AudioMuseAI/similar_tracks", query, user)
}
BeforeEach(func() {
fake = &fakeSonicEngine{provider: true}
api = &Router{sonic: fake}
})
It("maps matches, decodes the seed id, encodes item ids, copies distance", func() {
fake.similar = []sonic.SimilarMatch{
{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID(testID("seed"))+"&n=5", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(fake.gotID).To(Equal(testID("seed")))
Expect(fake.gotCount).To(Equal(5))
var body []audioMuseSimilarTrack
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body).To(HaveLen(2))
Expect(body[0]).To(Equal(audioMuseSimilarTrack{
Author: "A", Distance: 0.3, ItemID: dto.EncodeID(testID("mf1")), Title: "T1",
}))
})
It("collapses to one track per artist when eliminate_duplicates defaults on", func() {
fake.similar = []sonic.SimilarMatch{
{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true})
var body []audioMuseSimilarTrack
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body).To(HaveLen(1))
})
It("keeps same-artist tracks when eliminate_duplicates=false", func() {
fake.similar = []sonic.SimilarMatch{
{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID(testID("seed"))+"&eliminate_duplicates=false", model.User{IsAdmin: true})
var body []audioMuseSimilarTrack
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body).To(HaveLen(2))
})
It("filters out tracks in libraries the user cannot access", func() {
fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 2), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{Libraries: model.Libraries{{ID: 1}}})
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
})
It("returns an empty array without calling the engine when item_id is missing", func() {
w := call("n=5", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
Expect(fake.gotID).To(Equal(""))
})
It("returns an empty array without calling the engine when item_id is malformed", func() {
w := call("item_id=not-a-valid-id", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
Expect(fake.gotID).To(Equal(""))
})
It("returns 404 when no sonic provider is loaded", func() {
fake.provider = false
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(404))
})
It("returns an empty array when the engine errors", func() {
fake.similarErr = errors.New("boom")
fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
})
})
var _ = Describe("AudioMuse find_path", func() {
var fake *fakeSonicEngine
var api *Router
call := func(query string, user model.User) *httptest.ResponseRecorder {
return audioMuseGet(api.audioMuseFindPath, "/AudioMuseAI/find_path", query, user)
}
BeforeEach(func() {
fake = &fakeSonicEngine{provider: true}
api = &Router{sonic: fake}
})
It("returns 400 with the exact message when start_song_id is missing", func() {
w := call("end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(400))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required."))
})
It("returns 400 when end_song_id is missing", func() {
w := call("start_song_id="+dto.EncodeID(testID("s")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(400))
})
It("maps the path, decodes ids, sums total_distance, fills tempo from BPM", func() {
bpm := 120
withBPM := mf(testID("mf1"), "A", "T1", 1)
withBPM.BPM = &bpm
fake.path = []sonic.SimilarMatch{
{MediaFile: withBPM, Similarity: 1.5},
{MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 2.0},
}
w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e"))+"&max_steps=10", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(fake.gotStart).To(Equal(testID("s")))
Expect(fake.gotEnd).To(Equal(testID("e")))
Expect(fake.gotCount).To(Equal(10))
var body audioMusePathResponse
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body.Path).To(HaveLen(2))
Expect(body.TotalDistance).To(Equal(3.5))
Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID(testID("mf1"))))
Expect(*body.Path[0].Tempo).To(Equal(120.0))
Expect(body.Path[1].Tempo).To(BeNil())
})
It("returns 404 when no sonic provider is loaded", func() {
fake.provider = false
w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(404))
})
It("returns an empty path object when the engine errors", func() {
fake.pathErr = errors.New("boom")
fake.path = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 1.0}}
w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
var body audioMusePathResponse
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body.Path).To(BeEmpty())
Expect(body.TotalDistance).To(Equal(0.0))
})
It("400s without calling the engine when an id is malformed", func() {
w := call("start_song_id=not-a-valid-id&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(fake.gotStart).To(Equal(""))
Expect(fake.gotEnd).To(Equal(""))
})
})