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.
This commit is contained in:
Deluan Quintão 2026-07-15 20:44:56 -04:00 committed by GitHub
parent adeaa93e7e
commit 09022b4bd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 780 additions and 242 deletions

View File

@ -136,7 +136,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
imageUploadService := core.NewImageUploadService()
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic)
return router
}
@ -245,7 +246,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -51,6 +51,7 @@ var allProviders = wire.NewSet(
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(sonic.Engine), new(*sonic.Sonic)),
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),

View File

@ -46,6 +46,15 @@ func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher
}
}
// Engine is the sonic-similarity surface the API layers depend on; *Sonic satisfies it.
type Engine interface {
HasProvider() bool
GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error)
FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error)
}
var _ Engine = (*Sonic)(nil)
func (s *Sonic) HasProvider() bool {
return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0
}

View File

@ -215,6 +215,29 @@ The stream endpoints reuse the same transcode-decision pipeline as the Subsonic
Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are
advertised and served but packed-audio players won't decode them.
## AudioMuse-AI compatible endpoints
Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin).
Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no
external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity`
plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension.
- `GET /AudioMuseAI/info` — returns `{"Version": <navidrome version>, "AvailableEndpoints": [...]}` (200).
`AvailableEndpoints` lists the endpoints below only when a provider is loaded; otherwise it is empty.
- `GET /AudioMuseAI/health` — liveness probe: 200 with an empty body when a provider is loaded, else 404.
- `GET /AudioMuseAI/similar_tracks?item_id=<id>&n=10&eliminate_duplicates=true` — 404 when no provider is
loaded; otherwise a JSON array of `{author, distance, item_id, title}` (200; `[]` when there is no match
or no `item_id`). `eliminate_duplicates` (default true) limits results to one track per artist.
- `GET /AudioMuseAI/find_path?start_song_id=<id>&end_song_id=<id>&max_steps=25` — 404 when no provider is
loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": <float>}` (200), or 400
with `start_song_id and end_song_id are required.` when either id is missing.
`item_id`/`start_song_id`/`end_song_id` are the hex-encoded ids Navidrome hands Jellyfin clients.
`tempo` comes from the track's BPM when known; the richer AudioMuse per-track features
(`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library
setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries
the caller can access, since hops through inaccessible libraries are filtered out of the result.
## curl walkthrough
This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the

View File

@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/core/external"
"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/log"
"github.com/navidrome/navidrome/model"
@ -32,6 +33,7 @@ type Router struct {
scrobbler scrobbler.PlayTracker
playlists playlists.Playlists
provider external.Provider
sonic sonic.Engine
similarFlight singleflight.Group
serverIDMu sync.Mutex
serverIDVal string
@ -39,10 +41,12 @@ type Router struct {
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer,
transcodeDecider stream.TranscodeDecider, players core.Players,
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider) *Router {
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider,
sonicSvc sonic.Engine) *Router {
r := &Router{
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
players: players, scrobbler: scrobbler, playlists: playlists, provider: provider,
sonic: sonicSvc,
}
r.Handler = r.routes()
return r
@ -55,20 +59,22 @@ func (api *Router) routes() http.Handler {
// handler and the api_key check see folded keys.
inner.Use(normalizeQueryKeys)
// Routes are lowercase; caseInsensitivePaths lowercases the request path. Keep new routes lowercase.
// Public (no auth): handshake + login.
inner.Get("/System/Info/Public", api.getPublicSystemInfo)
inner.Get("/System/Ping", api.ping)
inner.Post("/System/Ping", api.ping)
inner.Get("/QuickConnect/Enabled", api.quickConnectEnabled)
inner.Get("/system/info/public", api.getPublicSystemInfo)
inner.Get("/system/ping", api.ping)
inner.Post("/system/ping", api.ping)
inner.Get("/quickconnect/enabled", api.quickConnectEnabled)
// Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated
// brute-force surface, so it must share the same per-IP throttle when one is configured.
if conf.Server.AuthRequestLimit > 0 {
limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
inner.With(limiter).Post("/Users/AuthenticateByName", api.authenticateByName)
inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName)
} else {
inner.Post("/Users/AuthenticateByName", api.authenticateByName)
inner.Post("/users/authenticatebyname", api.authenticateByName)
}
inner.Get("/Users/Public", api.getPublicUsers)
inner.Get("/users/public", api.getPublicUsers)
// Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling.
// Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy,
@ -76,8 +82,8 @@ func (api *Router) routes() http.Handler {
inner.Group(func(r chi.Router) {
r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
conf.Server.DevArtworkThrottleBacklogTimeout))
r.Get("/Items/{itemId}/Images/{type}", api.getItemImage)
r.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage)
r.Get("/items/{itemId}/images/{type}", api.getItemImage)
r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage)
})
inner.Group(func(r chi.Router) {
@ -86,10 +92,10 @@ func (api *Router) routes() http.Handler {
// Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a
// player) even before the first playback report.
r.Use(api.withPlayer)
r.Get("/UserViews", api.getUserViews)
r.Get("/Users/{userId}/Views", api.getUserViews)
r.Get("/Users/Me", api.getCurrentUser)
r.Get("/Users/{userId}", api.getCurrentUser)
r.Get("/userviews", api.getUserViews)
r.Get("/users/{userId}/views", api.getUserViews)
r.Get("/users/me", api.getCurrentUser)
r.Get("/users/{userId}", api.getCurrentUser)
// Cursor-backed collections: each streams straight from the DB, holding a connection for the
// whole client-paced response, so enough slow clients would take the entire pool and stall the
@ -97,70 +103,75 @@ func (api *Router) routes() http.Handler {
// requests queue rather than fail.
r.Group(func(r chi.Router) {
r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams))
r.Get("/Items", api.getItems)
r.Get("/Users/{userId}/Items", api.getItems)
r.Get("/Users/{userId}/Items/Latest", api.getLatest)
r.Get("/Artists", api.getArtists)
r.Get("/Artists/AlbumArtists", api.getAlbumArtists)
r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems)
r.Get("/items", api.getItems)
r.Get("/users/{userId}/items", api.getItems)
r.Get("/users/{userId}/items/latest", api.getLatest)
r.Get("/artists", api.getArtists)
r.Get("/artists/albumartists", api.getAlbumArtists)
r.Get("/playlists/{playlistId}/items", api.getPlaylistItems)
})
r.Get("/Items/{itemId}", api.getItem)
r.Get("/Users/{userId}/Items/{itemId}", api.getItem)
r.Delete("/Items/{itemId}", api.deleteItem)
r.Get("/items/{itemId}", api.getItem)
r.Get("/users/{userId}/items/{itemId}", api.getItem)
r.Delete("/items/{itemId}", api.deleteItem)
// /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the
// /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses.
r.Post("/UserFavoriteItems/{itemId}", api.markFavorite)
r.Delete("/UserFavoriteItems/{itemId}", api.unmarkFavorite)
r.Post("/Users/{userId}/FavoriteItems/{itemId}", api.markFavorite)
r.Delete("/Users/{userId}/FavoriteItems/{itemId}", api.unmarkFavorite)
r.Post("/Users/{userId}/Items/{itemId}/Rating", api.setRating)
r.Delete("/Users/{userId}/Items/{itemId}/Rating", api.removeRating)
r.Post("/userfavoriteitems/{itemId}", api.markFavorite)
r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite)
r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite)
r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite)
r.Post("/users/{userId}/items/{itemId}/rating", api.setRating)
r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating)
// Per-item play/favorite/rating state. Jellify uses the /UserItems form;
// /Users/{userId}/Items is the legacy spelling.
r.Get("/UserItems/{itemId}/UserData", api.getUserItemData)
r.Get("/Users/{userId}/Items/{itemId}/UserData", api.getUserItemData)
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
r.Get("/Artists/{itemId}/Similar", api.getSimilarArtists)
r.Get("/Items/{itemId}/Similar", api.getSimilarItems)
r.Get("/Items/{itemId}/InstantMix", api.getInstantMix)
r.Get("/Genres", api.getGenres)
r.Get("/MusicGenres", api.getGenres)
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
r.Get("/items/{itemId}/similar", api.getSimilarItems)
r.Get("/items/{itemId}/instantmix", api.getInstantMix)
r.Get("/genres", api.getGenres)
r.Get("/musicgenres", api.getGenres)
r.Post("/Playlists", api.createPlaylist)
r.Get("/Playlists/{playlistId}", api.getPlaylist)
r.Post("/Playlists/{playlistId}", api.updatePlaylist)
r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist)
r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist)
r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers)
r.Get("/Playlists/{playlistId}/Users/{userId}", api.getPlaylistUser)
r.Post("/playlists", api.createPlaylist)
r.Get("/playlists/{playlistId}", api.getPlaylist)
r.Post("/playlists/{playlistId}", api.updatePlaylist)
r.Post("/playlists/{playlistId}/items", api.addToPlaylist)
r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist)
r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers)
r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser)
// Cover upload/delete: only playlists are writable (see postItemImage); the GET routes
// above stay public.
r.Post("/Items/{itemId}/Images/{type}", api.postItemImage)
r.Delete("/Items/{itemId}/Images/{type}", api.deleteItemImage)
r.Post("/items/{itemId}/images/{type}", api.postItemImage)
r.Delete("/items/{itemId}/images/{type}", api.deleteItemImage)
r.Get("/Audio/{itemId}/stream", api.streamAudio)
r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio)
r.Get("/Audio/{itemId}/universal", api.streamAudio)
r.Get("/Audio/{itemId}/main.m3u8", api.streamHls)
r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo)
r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo)
r.Get("/audio/{itemId}/stream", api.streamAudio)
r.Get("/audio/{itemId}/stream.{container}", api.streamAudio)
r.Get("/audio/{itemId}/universal", api.streamAudio)
r.Get("/audio/{itemId}/main.m3u8", api.streamHls)
r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
// Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of
// /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file.
r.Get("/Items/{itemId}/File", api.streamFile)
r.Get("/Items/{itemId}/Download", api.streamFile)
r.Get("/items/{itemId}/file", api.streamFile)
r.Get("/items/{itemId}/download", api.streamFile)
r.Post("/Sessions/Playing", api.reportPlaybackStart)
r.Post("/Sessions/Playing/Progress", api.reportPlaybackProgress)
r.Post("/Sessions/Playing/Stopped", api.reportPlaybackStopped)
r.Post("/Sessions/Capabilities", api.postCapabilities)
r.Post("/Sessions/Capabilities/Full", api.postCapabilities)
r.Post("/sessions/playing", api.reportPlaybackStart)
r.Post("/sessions/playing/progress", api.reportPlaybackProgress)
r.Post("/sessions/playing/stopped", api.reportPlaybackStopped)
r.Post("/sessions/capabilities", api.postCapabilities)
r.Post("/sessions/capabilities/full", api.postCapabilities)
// Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect.
r.Get("/socket", api.handleSocket)
r.Get("/audiomuseai/info", api.audioMuseInfo)
r.Get("/audiomuseai/health", api.audioMuseHealth)
r.Get("/audiomuseai/similar_tracks", api.audioMuseSimilarTracks)
r.Get("/audiomuseai/find_path", api.audioMuseFindPath)
})
// Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected

View File

@ -18,7 +18,7 @@ import (
var _ = Describe("Router", func() {
It("serves the public handshake through the mounted handler", func() {
ds := &tests.MockDataStore{}
api := New(ds, nil, nil, nil, nil, nil, nil, nil)
api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
@ -26,7 +26,7 @@ var _ = Describe("Router", func() {
})
It("returns 404 JSON for unknown routes", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Nonexistent/Route", nil)
api.ServeHTTP(w, r)
@ -36,7 +36,7 @@ var _ = Describe("Router", func() {
})
It("returns 404 JSON for a known path with an unsupported method", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("PATCH", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
@ -53,7 +53,7 @@ var _ = Describe("Router", func() {
Expect(err).ToNot(HaveOccurred())
fp := &fakePlayers{}
api := New(ds, nil, nil, nil, fp, nil, nil, nil)
api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Users/Me", nil)
@ -70,7 +70,7 @@ var _ = Describe("Router", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 2
conf.Server.AuthWindowLength = time.Minute
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil)
login := func() int {
w := httptest.NewRecorder()

View File

@ -0,0 +1,161 @@
package jellyfin
import (
"net/http"
"strings"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/req"
)
// audioMuseEndpoints is what /AudioMuseAI/info advertises; it omits info itself, like the plugin,
// and is sorted the same way (the plugin builds it with OrderBy).
var audioMuseEndpoints = []string{
"GET /AudioMuseAI/find_path",
"GET /AudioMuseAI/health",
"GET /AudioMuseAI/similar_tracks",
}
type audioMuseInfoResponse struct {
Version string `json:"Version"`
AvailableEndpoints []string `json:"AvailableEndpoints"`
}
func (api *Router) audioMuseInfo(w http.ResponseWriter, r *http.Request) {
endpoints := []string{} // non-nil so an empty list serializes as [], not null
if api.sonic != nil && api.sonic.HasProvider() {
endpoints = audioMuseEndpoints
}
api.ok(w, r, audioMuseInfoResponse{
Version: consts.Version,
AvailableEndpoints: endpoints,
})
}
// audioMuseHealth is a liveness probe: 200 with an empty body when a sonic provider is loaded, else
// 404 — mirroring the reference plugin, which returns 200 when its backend is reachable.
func (api *Router) audioMuseHealth(w http.ResponseWriter, r *http.Request) {
if api.sonic == nil || !api.sonic.HasProvider() {
api.notFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
}
type audioMuseSimilarTrack struct {
Author string `json:"author"`
Distance float64 `json:"distance"`
ItemID string `json:"item_id"`
Title string `json:"title"`
}
func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 404 without a provider, like the Subsonic sonicSimilarity handlers.
if api.sonic == nil || !api.sonic.HasProvider() {
api.notFound(w, r)
return
}
p := req.Params(r)
tracks := []audioMuseSimilarTrack{}
itemID := p.StringOr("item_id", "")
if itemID == "" {
api.ok(w, r, tracks)
return
}
id := api.resolveItemID(ctx, dto.DecodeID(itemID))
n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit
eliminateDuplicates := p.BoolOr("eliminate_duplicates", true)
matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, n)
if err != nil {
api.ok(w, r, tracks)
return
}
u, _ := request.UserFrom(ctx)
seenArtists := make(map[string]bool, len(matches))
for _, m := range matches {
mf := m.MediaFile
if !u.HasLibraryAccess(mf.LibraryID) {
continue
}
if eliminateDuplicates {
key := strings.ToLower(mf.Artist)
if seenArtists[key] {
continue
}
seenArtists[key] = true
}
tracks = append(tracks, audioMuseSimilarTrack{
Author: mf.Artist,
Distance: m.Similarity,
ItemID: dto.EncodeID(mf.ID),
Title: mf.Title,
})
}
api.ok(w, r, tracks)
}
type audioMusePathTrack struct {
Author string `json:"author"`
ItemID string `json:"item_id"`
Title string `json:"title"`
Tempo *float64 `json:"tempo,omitempty"`
}
type audioMusePathResponse struct {
Path []audioMusePathTrack `json:"path"`
TotalDistance float64 `json:"total_distance"`
}
func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if api.sonic == nil || !api.sonic.HasProvider() {
api.notFound(w, r)
return
}
p := req.Params(r)
startID := p.StringOr("start_song_id", "")
endID := p.StringOr("end_song_id", "")
if startID == "" || endID == "" {
http.Error(w, "start_song_id and end_song_id are required.", http.StatusBadRequest)
return
}
resp := audioMusePathResponse{Path: []audioMusePathTrack{}}
maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count
matches, err := api.sonic.FindSonicPath(ctx,
api.resolveItemID(ctx, dto.DecodeID(startID)),
api.resolveItemID(ctx, dto.DecodeID(endID)),
maxSteps)
if err != nil {
api.ok(w, r, resp)
return
}
u, _ := request.UserFrom(ctx)
for _, m := range matches {
mf := m.MediaFile
if !u.HasLibraryAccess(mf.LibraryID) {
continue
}
track := audioMusePathTrack{
Author: mf.Artist,
ItemID: dto.EncodeID(mf.ID),
Title: mf.Title,
}
if mf.BPM != nil {
tempo := float64(*mf.BPM)
track.Tempo = &tempo
}
resp.Path = append(resp.Path, track)
resp.TotalDistance += m.Similarity
}
api.ok(w, r, resp)
}

View File

@ -0,0 +1,250 @@
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))
})
})

View File

@ -56,7 +56,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
func userToDto(u *model.User, serverName, serverID string) *dto.UserDto {
return &dto.UserDto{
Name: u.UserName,
Id: u.ID,
Id: dto.EncodeID(u.ID), // hex like every other id, so lowercased paths stay valid
ServerId: serverID,
ServerName: serverName,
HasPassword: true,

View File

@ -1,68 +0,0 @@
package jellyfin
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
// caseInsensitivePaths normalizes each request path's literal segments to the case they were
// registered with before delegating to r, since Jellyfin clients route case-insensitively but
// chi matches case-sensitively. Param placeholders (e.g. "{itemId}") aren't literals, so id
// segments pass through untouched.
func caseInsensitivePaths(r chi.Router) http.Handler {
canon := canonicalRouteSegments(r)
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
normalizeRequestPath(req, canon)
r.ServeHTTP(w, req)
})
}
// canonicalRouteSegments walks every registered route and records, for each literal (non-param)
// "/"-separated segment, the case it was registered with, keyed by its lower-cased form (e.g.
// "audio" -> "Audio").
func canonicalRouteSegments(router chi.Router) map[string]string {
canon := map[string]string{}
_ = chi.Walk(router, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
for seg := range strings.SplitSeq(route, "/") {
if seg == "" || strings.Contains(seg, "{") {
continue
}
canon[strings.ToLower(seg)] = seg
}
return nil
})
return canon
}
// normalizeRequestPath rewrites literal path segments to the case routes were registered with.
// It must run before chi's matching. When the router is mounted under a parent, chi has already
// stripped the mount prefix and matches against RouteContext.RoutePath rather than r.URL.Path, so
// that's what must be normalized here.
func normalizeRequestPath(r *http.Request, canon map[string]string) {
if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" {
rctx.RoutePath = normalizeCase(rctx.RoutePath, canon)
return
}
r.URL.Path = normalizeCase(r.URL.Path, canon)
}
// normalizeCase rewrites each "/"-separated literal segment of path to the case it was
// registered with in canon. Segments with no match (e.g. case-sensitive ids) are left untouched.
// A segment like "STREAM.mp3" comes from a mixed literal+param route (e.g. "stream.{container}"),
// whose literal prefix ("stream") is registered separately: normalize that prefix and lower-case
// the extension so chi's case-sensitive match still hits.
func normalizeCase(path string, canon map[string]string) string {
segs := strings.Split(path, "/")
for i, seg := range segs {
if canonical, ok := canon[strings.ToLower(seg)]; ok {
segs[i] = canonical
} else if prefix, suffix, found := strings.Cut(seg, "."); found {
if canonical, ok := canon[strings.ToLower(prefix)]; ok {
segs[i] = canonical + "." + strings.ToLower(suffix)
}
}
}
return strings.Join(segs, "/")
}

View File

@ -1,90 +0,0 @@
package jellyfin
import (
"net/http"
"net/http/httptest"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("caseInsensitivePaths", func() {
var handler http.Handler
var gotID string
var gotContainer string
BeforeEach(func() {
gotID = ""
gotContainer = ""
r := chi.NewRouter()
r.Get("/Foo/{id}/Bar", func(w http.ResponseWriter, req *http.Request) {
gotID = chi.URLParam(req, "id")
w.WriteHeader(http.StatusOK)
})
// A mixed literal+param segment (like Jellyfin's /Audio/{id}/stream.{container}): the "stream"
// literal prefix is registered separately via the bare /Foo/{id}/stream route below.
r.Get("/Foo/{id}/stream", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
})
r.Get("/Foo/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) {
gotContainer = chi.URLParam(req, "container")
w.WriteHeader(http.StatusOK)
})
handler = caseInsensitivePaths(r)
})
It("normalizes the literal prefix of a mixed literal.param segment", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/foo/ID/STREAM.mp3", nil)
handler.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(gotContainer).To(Equal("mp3"))
})
It("matches a lower-cased request path against mixed-case registered literals", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/foo/ID/bar", nil)
handler.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("preserves the id segment's original casing", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/foo/ID/bar", nil)
handler.ServeHTTP(w, r)
Expect(gotID).To(Equal("ID"))
})
It("leaves a real mixed-case id untouched while still matching literals", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/foo/cjsFeXbNOaaSjASu3DM93g/bar", nil)
handler.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(gotID).To(Equal("cjsFeXbNOaaSjASu3DM93g"))
})
})
var _ = Describe("normalizeCase", func() {
It("rewrites known literal segments to their canonical case", func() {
canon := map[string]string{
"audio": "Audio",
"stream": "stream",
}
got := normalizeCase("/audio/XyZ123NotARoute/STREAM", canon)
Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream"))
})
It("normalizes the literal prefix of a mixed literal.extension segment", func() {
canon := map[string]string{"audio": "Audio", "stream": "stream"}
got := normalizeCase("/audio/XyZ123NotARoute/STREAM.MP3", canon)
Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream.mp3"))
})
It("leaves a dotted segment untouched when its prefix isn't a known literal", func() {
canon := map[string]string{"audio": "Audio"}
got := normalizeCase("/audio/some.file.id", canon)
Expect(got).To(Equal("/Audio/some.file.id"))
})
})

View File

@ -0,0 +1,113 @@
package e2e
import (
"net/http"
"strings"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("AudioMuse endpoints", func() {
BeforeEach(func() { setupTestDB() })
Describe("GET /AudioMuseAI/info", func() {
It("returns version and available endpoints", func() {
var body struct {
Version string `json:"Version"`
AvailableEndpoints []string `json:"AvailableEndpoints"`
}
parseInto(get("/AudioMuseAI/info"), &body)
Expect(body.Version).To(Equal(consts.Version))
Expect(body.AvailableEndpoints).To(ConsistOf(
"GET /AudioMuseAI/find_path",
"GET /AudioMuseAI/health",
"GET /AudioMuseAI/similar_tracks",
))
})
It("requires authentication", func() {
Expect(rawReq("GET", "/AudioMuseAI/info", "").Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("GET /AudioMuseAI/health", func() {
It("returns 200 with an empty body when a provider is loaded", func() {
w := get("/AudioMuseAI/health")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Body.Len()).To(Equal(0))
})
It("requires authentication", func() {
Expect(rawReq("GET", "/AudioMuseAI/health", "").Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("GET /AudioMuseAI/similar_tracks", func() {
It("maps provider results to seeded tracks, encoding item ids", func() {
sonicProviderFake.similar = []sonic.SimilarResult{
{Song: songAgent("Something"), Similarity: 0.3},
{Song: songAgent("So What"), Similarity: 0.5},
}
var body []struct {
Author string `json:"author"`
Distance float64 `json:"distance"`
ItemID string `json:"item_id"`
Title string `json:"title"`
}
parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Come Together"))+"&n=10"), &body)
Expect(body).To(HaveLen(2))
Expect([]string{body[0].Title, body[1].Title}).To(ConsistOf("Something", "So What"))
Expect(dto.DecodeID(body[0].ItemID)).To(Equal(songID(body[0].Title)))
})
It("collapses to one track per artist by default", func() {
sonicProviderFake.similar = []sonic.SimilarResult{
{Song: songAgent("Something"), Similarity: 0.3},
{Song: songAgent("Come Together"), Similarity: 0.5},
}
var body []map[string]any
parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Help!"))), &body)
Expect(body).To(HaveLen(1)) // both similar tracks are by The Beatles
})
It("returns an empty array (not null) when there are no results", func() {
sonicProviderFake.similar = nil
w := get("/AudioMuseAI/similar_tracks?item_id=" + enc(songID("Come Together")))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
})
It("requires authentication", func() {
Expect(rawReq("GET", "/AudioMuseAI/similar_tracks?item_id=x", "").Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("GET /AudioMuseAI/find_path", func() {
It("returns 400 with the exact message when a required id is missing", func() {
w := get("/AudioMuseAI/find_path?start_song_id=" + enc(songID("Something")))
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required."))
})
It("returns the path and summed total_distance", func() {
sonicProviderFake.path = []sonic.SimilarResult{
{Song: songAgent("Come Together"), Similarity: 1.5},
{Song: songAgent("So What"), Similarity: 2.0},
}
var body struct {
Path []struct {
ItemID string `json:"item_id"`
Title string `json:"title"`
} `json:"path"`
TotalDistance float64 `json:"total_distance"`
}
parseInto(get("/AudioMuseAI/find_path?start_song_id="+enc(songID("Something"))+"&end_song_id="+enc(songID("So What"))+"&max_steps=10"), &body)
Expect(body.Path).To(HaveLen(2))
Expect(body.TotalDistance).To(Equal(3.5))
})
})
})

View File

@ -27,7 +27,7 @@ var _ = Describe("Authentication", func() {
Expect(res.AccessToken).ToNot(BeEmpty())
Expect(res.User).ToNot(BeNil())
Expect(res.User.Name).To(Equal("admin"))
Expect(res.User.Id).To(Equal("admin-1"))
Expect(res.User.Id).To(Equal(enc("admin-1")))
Expect(res.User.Policy.IsAdministrator).To(BeTrue())
Expect(res.ServerId).ToNot(BeEmpty())
@ -84,7 +84,7 @@ var _ = Describe("Authentication", func() {
users := publicUsers()
Expect(users).To(HaveLen(1))
Expect(users[0].Name).To(Equal("regular"))
Expect(users[0].Id).To(Equal("regular-1"))
Expect(users[0].Id).To(Equal(enc("regular-1")))
Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login
})
})
@ -94,7 +94,7 @@ var _ = Describe("Authentication", func() {
var u dto.UserDto
parseInto(getAs(regularUser, "/Users/Me"), &u)
Expect(u.Name).To(Equal("regular"))
Expect(u.Id).To(Equal("regular-1"))
Expect(u.Id).To(Equal(enc("regular-1")))
})
It("returns the caller from GET /Users/{userId}", func() {

View File

@ -38,11 +38,14 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/db"
@ -77,14 +80,15 @@ var (
// Shared test state
var (
ctx context.Context
ds *tests.MockDataStore
router http.Handler
streamerSpy *harness.SpyStreamer
artworkSpy *spyArtwork
providerFake *fakeExternalProvider
goldenDB *harness.DB
dataFolder string
ctx context.Context
ds *tests.MockDataStore
router http.Handler
streamerSpy *harness.SpyStreamer
artworkSpy *spyArtwork
providerFake *fakeExternalProvider
sonicProviderFake *fakeSonicProvider
goldenDB *harness.DB
dataFolder string
adminUser = model.User{
ID: "admin-1",
@ -308,6 +312,8 @@ func setupTestDB() {
streamerSpy = &harness.SpyStreamer{}
artworkSpy = &spyArtwork{}
providerFake = &fakeExternalProvider{}
sonicProviderFake = &fakeSonicProvider{}
sonicSvc := sonic.New(ds, &fakeSonicLoader{provider: sonicProviderFake}, matcher.New(ds))
decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{})
router = jellyfin.New(
ds,
@ -318,6 +324,7 @@ func setupTestDB() {
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil),
playlists.NewPlaylists(ds, core.NewImageUploadService()),
providerFake,
sonicSvc,
)
}
@ -338,6 +345,50 @@ func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model
return f.similarSongs, nil
}
// fakeSonicLoader always advertises a SonicSimilarity provider so the AudioMuse endpoints are
// active in e2e; the provider it hands back returns test-configured results.
type fakeSonicLoader struct{ provider sonic.Provider }
func (f *fakeSonicLoader) PluginNames(capability string) []string {
if capability == "SonicSimilarity" {
return []string{"fake"}
}
return nil
}
func (f *fakeSonicLoader) LoadSonicSimilarity(string) (sonic.Provider, bool) {
return f.provider, true
}
// fakeSonicProvider is a configurable stand-in for a sonic-similarity plugin. Tests set the
// agents.Song results; the real matcher resolves them back to seeded library tracks.
type fakeSonicProvider struct {
similar []sonic.SimilarResult
path []sonic.SimilarResult
}
func (f *fakeSonicProvider) GetSonicSimilarTracks(context.Context, *model.MediaFile, int) ([]sonic.SimilarResult, error) {
return f.similar, nil
}
func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *model.MediaFile, int) ([]sonic.SimilarResult, error) {
return f.path, nil
}
// songAgent looks a seeded track up by title (titles are unique in the seed) and builds an
// agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile.
func songAgent(title string) agents.Song {
mfs, err := ds.MediaFile(ctx).GetAll()
Expect(err).ToNot(HaveOccurred())
for _, mf := range mfs {
if mf.Title == title {
return agents.Song{Name: mf.Title, Artists: []agents.Artist{{Name: mf.Artist}}}
}
}
Fail("song not found: " + title)
return agents.Song{}
}
// --- Spy/noop dependencies (shared ones live in tests/harness) ---
// spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the

View File

@ -103,7 +103,7 @@ var _ = Describe("Playlists", func() {
var perms []dto.PlaylistUserPermissions
parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms)
Expect(perms).To(HaveLen(1))
Expect(perms[0].UserId).To(Equal("admin-1"))
Expect(perms[0].UserId).To(Equal(enc("admin-1")))
Expect(perms[0].CanEdit).To(BeTrue())
})
})

View File

@ -7,6 +7,7 @@ import (
"regexp"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
@ -29,6 +30,22 @@ func throttleStreams(limit int) func(http.Handler) http.Handler {
return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout)
}
// caseInsensitivePaths lowercases the request path so chi (case-sensitive) matches the
// lowercase-registered routes; Jellyfin clients route case-insensitively. It lowercases id/param
// segments too, which is safe because every id the API emits — user ids included — is lowercase hex
// (dto.EncodeID).
func caseInsensitivePaths(r chi.Router) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// Mounted under a parent, chi matches RouteContext.RoutePath, not r.URL.Path.
if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" {
rctx.RoutePath = strings.ToLower(rctx.RoutePath)
} else {
req.URL.Path = strings.ToLower(req.URL.Path)
}
r.ServeHTTP(w, req)
})
}
// normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params
// case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase,
// Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one

View File

@ -8,6 +8,7 @@ import (
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -321,3 +322,61 @@ var _ = Describe("throttleStreams", func() {
Expect(serve(0, 4)).To(BeNumerically(">", int32(1)))
})
})
var _ = Describe("caseInsensitivePaths", func() {
var handler http.Handler
var gotID, gotContainer string
BeforeEach(func() {
gotID, gotContainer = "", ""
r := chi.NewRouter()
// Routes are registered lowercase, mirroring the real router.
r.Get("/foo/{id}/bar", func(w http.ResponseWriter, req *http.Request) {
gotID = chi.URLParam(req, "id")
w.WriteHeader(http.StatusOK)
})
r.Get("/audio/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) {
gotContainer = chi.URLParam(req, "container")
w.WriteHeader(http.StatusOK)
})
// A second route reusing the "bar" segment name at a different position.
r.Get("/bar/{id}", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler = caseInsensitivePaths(r)
})
serve := func(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
return w
}
It("routes a mixed-case request to its lowercase-registered route", func() {
Expect(serve("/FOO/abc/BAR").Code).To(Equal(http.StatusOK))
})
It("routes both routes that share a segment name, regardless of casing", func() {
Expect(serve("/Foo/abc/Bar").Code).To(Equal(http.StatusOK))
Expect(serve("/BAR/abc").Code).To(Equal(http.StatusOK))
})
It("lowercases the mixed literal.extension segment so the route and container match", func() {
w := serve("/Audio/abc/STREAM.MP3")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(gotContainer).To(Equal("mp3"))
})
It("lowercases id/param segments (safe: Jellyfin ids are lowercase hex)", func() {
serve("/foo/DEADBEEF/bar")
Expect(gotID).To(Equal("deadbeef"))
})
It("normalizes the RoutePath branch when mounted under a parent", func() {
parent := chi.NewRouter()
parent.Mount("/jellyfin", handler)
w := httptest.NewRecorder()
parent.ServeHTTP(w, httptest.NewRequest("GET", "/jellyfin/FOO/abc/BAR", nil))
Expect(w.Code).To(Equal(http.StatusOK))
})
})

View File

@ -279,7 +279,7 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) {
// enforced by AddTracks/RemoveTracks.
func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) {
u, _ := request.UserFrom(r.Context())
api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: u.ID, CanEdit: true}})
api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: true}})
}
func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) {

View File

@ -442,7 +442,7 @@ var _ = Describe("Playlists", func() {
Expect(w.Code).To(Equal(http.StatusOK))
var res []dto.PlaylistUserPermissions
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: "u1", CanEdit: true}}))
Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID("u1"), CanEdit: true}}))
})
})

View File

@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() {
var api *Router
BeforeEach(func() {
api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil)
api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("serves a fully lowercase path directly", func() {

View File

@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() {
Expect(err).ToNot(HaveOccurred())
token = t
api = New(ds, nil, nil, nil, nil, nil, nil, nil)
api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("upgrades when authenticated via the api_key query parameter", func() {

View File

@ -52,7 +52,7 @@ func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) {
}
users = append(users, dto.UserDto{
Name: usr.UserName,
Id: usr.ID,
Id: dto.EncodeID(usr.ID),
ServerId: serverID,
HasPassword: true,
})

View File

@ -106,7 +106,7 @@ var _ = Describe("Users", func() {
users := publicUsers()
Expect(users).To(HaveLen(2))
Expect(users[0].Name).To(Equal("bob"))
Expect(users[0].Id).To(Equal("u2"))
Expect(users[0].Id).To(Equal(dto.EncodeID("u2")))
Expect(users[1].Name).To(Equal("alice"))
// The public list must not expose Policy/Configuration to unauthenticated callers.
Expect(users[0].Policy).To(BeNil())