mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* 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.
251 lines
8.7 KiB
Go
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))
|
|
})
|
|
})
|