navidrome/server/jellyfin/audiomuse_test.go
Deluan Quintão 09022b4bd2
feat(jellyfin): AudioMuse-AI compatible sonic endpoints (#5782)
* refactor(jellyfin): inject core/sonic into the Jellyfin Router

* feat(jellyfin): add AudioMuse /info endpoint

* feat(jellyfin): add AudioMuse /similar_tracks endpoint

* feat(jellyfin): gate AudioMuse endpoints on sonic provider

* feat(jellyfin): add AudioMuse /find_path endpoint

* fix(jellyfin): fix case-insensitive route collision across positions

canonicalRouteSegments keyed canonical case by lower-cased segment name
alone, globally. Two unrelated routes sharing a segment name with
different casing at different tree depths (e.g. "Info" in
/System/Info/Public vs "info" in /AudioMuseAI/info) silently overwrote
each other, 404-ing the loser even for exact-case requests. Replace the
flat map with a position-aware trie mirroring the routing tree.

* test(jellyfin): e2e tests for AudioMuse endpoints

* docs(jellyfin): document AudioMuse compatibility endpoints

* test(jellyfin): harden AudioMuse tests and doc note (final-review follow-ups)

- Comment-lock the []string{} (not nil) contract for /AudioMuseAI/info's
  AvailableEndpoints so it keeps serializing as [] rather than null, and
  add a raw-body assertion to the existing empty-list test to catch a
  regression a struct-only unmarshal can't detect.
- Cover the previously-untested engine-error branch in similar_tracks and
  find_path, both of which degrade to an empty result.
- Document that find_path's path/total_distance only reflect hops through
  libraries the caller can access in multi-library setups.

* refactor(jellyfin): dedup AudioMuse test request helper, presize dedup map

* refactor(sonic): expose sonic.Engine interface; drop typed-nil guard in jellyfin.New

The Jellyfin Router's sonic field was an interface but New() took the concrete
*sonic.Sonic, so a nil arg became a non-nil typed-nil and needed a guard — the
only injected dependency that did. Move the interface (sonic.Engine) beside its
implementation, take it in New() like every other service, and bind it in wire.

* refactor(jellyfin): case-insensitive routing via lowercased paths

Replace the position-aware route trie with a trivial middleware that lowercases
the request path, and register every route in lowercase. Simpler, and no segment
name can collide across positions. caseInsensitivePaths moves into middlewares.go
alongside normalizeQueryKeys. Relies on the invariant that no Jellyfin path segment
carries case-sensitive data (all ids are lowercase hex via dto.EncodeID).

* feat(jellyfin): add AudioMuse /health endpoint

A liveness probe matching the reference plugin: 200 with an empty body when a
SonicSimilarity provider is loaded, 404 otherwise. /AudioMuseAI/info now
advertises it (list alphabetized like the plugin's OrderBy).

Also trims the AudioMuse and case-insensitive-routing comments to their essential
rationale.

* fix(jellyfin): hex-encode user IDs so lowercased paths stay valid

Address PR review: user IDs were the one id the Jellyfin API emitted raw (base62,
uppercase-capable), so lowercasing request paths could alter a userId segment. Encode
them via dto.EncodeID like every other id, making the 'all boundary ids are lowercase
hex' invariant true — no routing special-casing needed. Also caps user-controlled n /
max_steps, fixes the songAgent test comment, and adds leading slashes to the README
endpoint list.
2026-07-15 20:44:56 -04:00

251 lines
8.7 KiB
Go

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))
})
})
// audioMuseGet drives a GET through normalizeQueryKeys as the given user, mirroring a real request.
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("mf1", "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf("mf2", "B", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID("seed")+"&n=5", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(fake.gotID).To(Equal("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("mf1"), Title: "T1",
}))
})
It("collapses to one track per artist when eliminate_duplicates defaults on", func() {
fake.similar = []sonic.SimilarMatch{
{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID("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("mf1", "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID("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("mf1", "A", "T1", 2), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID("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 404 when no sonic provider is loaded", func() {
fake.provider = false
w := call("item_id="+dto.EncodeID("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("mf1", "A", "T1", 1), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID("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("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("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("mf1", "A", "T1", 1)
withBPM.BPM = &bpm
fake.path = []sonic.SimilarMatch{
{MediaFile: withBPM, Similarity: 1.5},
{MediaFile: mf("mf2", "B", "T2", 1), Similarity: 2.0},
}
w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e")+"&max_steps=10", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(fake.gotStart).To(Equal("s"))
Expect(fake.gotEnd).To(Equal("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("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("s")+"&end_song_id="+dto.EncodeID("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("mf1", "A", "T1", 1), Similarity: 1.0}}
w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("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))
})
})