navidrome/server/jellyfin/playlists_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

465 lines
17 KiB
Go

package jellyfin
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/filter"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// fakePlaylists is a local fake for core/playlists.Playlists. It embeds the interface so
// unimplemented methods aren't needed here; only the ones this test exercises are overridden.
type fakePlaylists struct {
playlists.Playlists
createdName string
createdIds []string
createErr error
getPls *model.Playlist
getErr error
tracksRepo *tests.MockPlaylistTrackRepo
getByIDPls *model.Playlist
getByIDErr error
addPlaylistID string
addIds []string
addErr error
removePlaylistID string
removeIds []string
removeErr error
setImagePlaylistID string
setImageBytes []byte
setImageExt string
setImageErr error
removeImagePlaylistID string
removeImageErr error
deletePlaylistID string
deleteErr error
}
func (f *fakePlaylists) Delete(_ context.Context, id string) error {
f.deletePlaylistID = id
return f.deleteErr
}
func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []string) (string, error) {
f.createdName = name
f.createdIds = ids
if f.createErr != nil {
return "", f.createErr
}
return "pl-new", nil
}
// Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real
// service's behavior for a missing or inaccessible playlist and letting getItem tests that don't
// care about playlists leave it unconfigured.
func (f *fakePlaylists) Get(_ context.Context, _ string) (*model.Playlist, error) {
if f.getByIDErr != nil {
return nil, f.getByIDErr
}
if f.getByIDPls == nil {
return nil, model.ErrNotFound
}
return f.getByIDPls, nil
}
func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) {
if f.getErr != nil {
return nil, f.getErr
}
if f.getPls == nil {
return nil, model.ErrNotFound // mirror the real repo: never (nil, nil)
}
return f.getPls, nil
}
// Tracks serves the same getPls fixture as GetWithTracks. tracksRepo is kept so tests can assert
// what was pushed down to the query.
func (f *fakePlaylists) Tracks(_ context.Context, _ string) (model.PlaylistTrackRepository, error) {
if f.getErr != nil {
return nil, f.getErr
}
if f.getPls == nil {
return nil, model.ErrNotFound
}
f.tracksRepo = &tests.MockPlaylistTrackRepo{}
f.tracksRepo.SetData(f.getPls.Tracks)
return f.tracksRepo, nil
}
func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) {
f.addPlaylistID = playlistID
f.addIds = ids
return len(ids), f.addErr
}
func (f *fakePlaylists) RemoveTracks(_ context.Context, playlistID string, trackIds []string) error {
f.removePlaylistID = playlistID
f.removeIds = trackIds
return f.removeErr
}
func (f *fakePlaylists) SetImage(_ context.Context, playlistID string, reader io.Reader, ext string) error {
f.setImagePlaylistID = playlistID
f.setImageExt = ext
if reader != nil {
f.setImageBytes, _ = io.ReadAll(reader)
}
return f.setImageErr
}
func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error {
f.removeImagePlaylistID = playlistID
return f.removeImageErr
}
var _ = Describe("Playlists", func() {
var api *Router
var fp *fakePlaylists
BeforeEach(func() {
fp = &fakePlaylists{}
api = &Router{ds: &tests.MockDataStore{}, playlists: fp}
})
Describe("createPlaylist", func() {
It("creates a playlist and returns its id", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["s1","s2"]}`)).
WithContext(context.Background())
invoke(api.createPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res map[string]string
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res["Id"]).To(Equal(dto.EncodeID("pl-new")))
Expect(fp.createdName).To(Equal("Mix"))
Expect(fp.createdIds).To(Equal([]string{"s1", "s2"}))
})
It("returns 400 on an invalid JSON body", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`not json`)).
WithContext(context.Background())
invoke(api.createPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 500 when the service fails", func() {
fp.createErr = errors.New("boom")
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix"}`)).
WithContext(context.Background())
invoke(api.createPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("getPlaylistItems", func() {
It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() {
fp.getPls = &model.Playlist{
ID: "pl1",
Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1", Title: "Song One"}},
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2", Title: "Song Two"}},
},
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/pl1/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
api.getPlaylistItems(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.TotalRecordCount).To(Equal(2))
Expect(res.Items).To(HaveLen(2))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1")))
Expect(res.Items[0].Type).To(Equal("Audio"))
Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1")))
Expect(res.Items[1].Id).To(Equal(dto.EncodeID("s2")))
Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2")))
})
It("pages with StartIndex/Limit, pushing them down to the query", func() {
fp.getPls = &model.Playlist{
ID: "pl1",
Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}},
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}},
{ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}},
},
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/pl1/Items?StartIndex=1&Limit=1", nil).
WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
invoke(api.getPlaylistItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.TotalRecordCount).To(Equal(3))
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2")))
Expect(fp.tracksRepo.Options.Offset).To(Equal(1))
Expect(fp.tracksRepo.Options.Max).To(Equal(1))
})
It("returns 404 for a non-owned or absent playlist", func() {
fp.getErr = model.ErrNotFound
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "missing")
api.getPlaylistItems(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("container id expansion", func() {
var ds *tests.MockDataStore
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
ds = &tests.MockDataStore{}
api = &Router{ds: ds, playlists: fp}
})
createWith := func(id string) {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+id+`"]}`)).
WithContext(ctx)
invoke(api.createPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
}
It("passes a bare song id through unchanged", func() {
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}})
createWith("s1")
Expect(fp.createdIds).To(Equal([]string{"s1"}))
})
It("expands an album id into its songs, filtered by album", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al1"}})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "s1", AlbumID: "al1"}, {ID: "s2", AlbumID: "al1"},
})
createWith("al1")
Expect(fp.createdIds).To(Equal([]string{"s1", "s2"}))
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum("al1").Filters))
})
It("expands an artist id into its songs", func() {
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1"}})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}, {ID: "s2"}})
createWith("ar1")
Expect(fp.createdIds).To(Equal([]string{"s1", "s2"}))
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID("ar1").Filters))
})
It("expands a playlist id into its tracks' media file ids", func() {
fp.getPls = &model.Playlist{ID: "pl9", Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s3"}, {ID: "2", MediaFileID: "s4"},
}}
createWith("pl9")
Expect(fp.createdIds).To(Equal([]string{"s3", "s4"}))
})
})
Describe("getPlaylist", func() {
It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() {
pls := &model.Playlist{
ID: "pl1",
Public: true,
Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}},
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}},
},
}
fp.getPls, fp.getByIDPls = pls, pls
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
invoke(api.getPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.PlaylistInfo
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.OpenAccess).To(BeTrue())
Expect(res.Shares).To(BeEmpty())
Expect(res.ItemIds).To(Equal([]string{dto.EncodeID("s1"), dto.EncodeID("s2")}))
})
It("returns 404 for a non-owned or absent playlist", func() {
fp.getErr = model.ErrNotFound
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "missing")
invoke(api.getPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("deleteItem", func() {
deleteReq := func(id string) *http.Request {
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(id), nil).WithContext(context.Background())
return withChiURLParam(r, "itemId", dto.EncodeID(id))
}
It("deletes the playlist and returns 204", func() {
w := httptest.NewRecorder()
api.deleteItem(w, deleteReq("pl1"))
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.deletePlaylistID).To(Equal("pl1"))
})
It("returns 403 when the user doesn't own the playlist", func() {
fp.deleteErr = model.ErrNotAuthorized
w := httptest.NewRecorder()
api.deleteItem(w, deleteReq("pl1"))
Expect(w.Code).To(Equal(http.StatusForbidden))
})
It("returns 404 for a missing playlist or non-playlist id", func() {
fp.deleteErr = model.ErrNotFound
w := httptest.NewRecorder()
api.deleteItem(w, deleteReq("al1"))
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 500 on an unexpected error", func() {
fp.deleteErr = errors.New("boom")
w := httptest.NewRecorder()
api.deleteItem(w, deleteReq("pl1"))
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
Describe("addToPlaylist", func() {
It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1,s2", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
invoke(api.addToPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.addPlaylistID).To(Equal("pl1"))
Expect(fp.addIds).To(Equal([]string{"s1", "s2"}))
})
It("accepts a PascalCase Ids param (case-folded by the middleware)", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists/pl1/Items?Ids=s1,s2", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
invoke(api.addToPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.addIds).To(Equal([]string{"s1", "s2"}))
})
It("returns 404 when the service rejects the request (not found/not owned)", func() {
fp.addErr = model.ErrNotAuthorized
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
invoke(api.addToPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("passes no ids (not a spurious empty string) when the ids param is absent", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Playlists/pl1/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
invoke(api.addToPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.addPlaylistID).To(Equal("pl1"))
Expect(fp.addIds).To(BeEmpty())
})
})
Describe("removeFromPlaylist", func() {
It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1,2", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
invoke(api.removeFromPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.removePlaylistID).To(Equal("pl1"))
Expect(fp.removeIds).To(Equal([]string{"1", "2"}))
})
It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?EntryIds=1,2", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
invoke(api.removeFromPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.removeIds).To(Equal([]string{"1", "2"}))
})
It("returns 404 when the service rejects the request (not found/not owned)", func() {
fp.removeErr = model.ErrNotFound
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
invoke(api.removeFromPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
invoke(api.removeFromPlaylist, w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.removePlaylistID).To(Equal("pl1"))
Expect(fp.removeIds).To(BeEmpty())
})
})
Describe("getPlaylistUsers", func() {
It("returns the current user with CanEdit true", func() {
w := httptest.NewRecorder()
ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"})
r := httptest.NewRequest("GET", "/Playlists/pl1/Users", nil).WithContext(ctx)
r = withChiURLParam(r, "playlistId", "pl1")
api.getPlaylistUsers(w, r)
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: dto.EncodeID("u1"), CanEdit: true}}))
})
})
Describe("getPlaylistUser", func() {
It("returns CanEdit true for the requested user", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/pl1/Users/u1", nil).WithContext(context.Background())
rctx := chi.NewRouteContext()
rctx.URLParams.Add("playlistId", "pl1")
rctx.URLParams.Add("userId", "u1")
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
api.getPlaylistUser(w, r)
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}))
})
})
})