mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 11:57:12 +02:00
* refactor(persistence): adopt generic deluan/rest repository API Pin deluan/rest to the refactor branch. REST-facing repository methods take a context and return typed values. Drop DataStore.Resource and ResourceRepository; the native API names typed repositories directly through a per-request adapter that later commits remove. * refactor(persistence): base repository helpers take a context * refactor(persistence): LibraryRepository takes a context per call * refactor(persistence): PropertyRepository takes a context per call * refactor(persistence): UserPropsRepository takes a context per call * refactor(persistence): TranscodingRepository takes a context per call * refactor(persistence): ShareRepository takes a context per call * refactor(persistence): PlayerRepository takes a context per call * refactor(persistence): RadioRepository takes a context per call * refactor(persistence): PlayQueueRepository takes a context per call * refactor(persistence): Tag and Genre repositories take a context per call * refactor(persistence): PluginRepository takes a context per call * refactor(persistence): Scrobble repositories take a context per call * refactor(persistence): FolderRepository takes a context per call * refactor(persistence): Artwork repositories take a context per call * refactor(persistence): UserRepository takes a context per call * refactor(persistence): ArtistRepository takes a context per call ReadAll no longer rewrites the shared sort mappings for the role filter; it works on a per-call copy. * test(persistence): assert artist role sort sanitization in ReadAll * refactor(persistence): AlbumRepository takes a context per call * test(persistence): pass the test context to album repository helpers * refactor(persistence): MediaFileRepository takes a context per call * refactor(persistence): Playlist repositories take a context per call * refactor(persistence): build all repositories once per store * refactor(core): REST repository wrappers are built once * refactor(persistence): repositories are stateless Remove the context field from the base repository and the per-request REST adapter. Enable the containedctx linter so no repository can hold a request context again. * chore(lint): skip containedctx in test files * refactor: share simplifications from the stateless repositories sweep Add deleteOwnedAll on sqlRepository and use it in player/share Delete to remove the duplicated bulk-delete loop; have Share.Repository() return model.ShareRepository so subsonic sharing.go drops its repeated type assertions. * chore(core): assert REST wrappers implement Persistable * chore: reformat imports * perf(persistence): build repositories on first use Each transaction store used to construct all 21 repositories up front, paying for filter and sort mapping setup the block never touched. Fields are now sync.OnceValue thunks, so a store only builds what it uses. * fix(persistence): clean plugin references per deleted user A bulk user delete that fails on a later id had already removed the earlier rows but skipped their plugin cleanup. Cleanup now runs right after each successful delete. * fix(core): unload disabled plugins even when a user delete fails A bulk delete can fail on a later id after earlier users were removed and their plugins auto-disabled. The wrapper returned before unloading, leaving those plugins running until the next successful delete or a restart. * chore(deps): pin deluan/rest to v1.0.1 Replaces the pseudo-version of the refactor branch with the tagged release. REST error messages now name the bare type (Artist, not model.Artist). * test: use the spec context instead of context.Background() Replace the context.Background()/context.TODO() calls this branch added to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so repository calls are bound to the running spec's lifetime. * test: declare the spec context once per Describe Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
1203 lines
57 KiB
Go
1203 lines
57 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// withChiURLParam simulates chi's routing having captured a path parameter, since these
|
|
// tests call handlers directly instead of going through the full router.
|
|
func withChiURLParam(r *http.Request, key, value string) *http.Request {
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add(key, value)
|
|
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
var _ = Describe("Items", func() {
|
|
var api *Router
|
|
var ds *tests.MockDataStore
|
|
var fp *fakePlaylists
|
|
// alice has access to library 1 only; used by tests that don't care about scoping.
|
|
ctxUser := func() context.Context {
|
|
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
|
}
|
|
ctxUserWithLibraries := func(libs model.Libraries) context.Context {
|
|
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs})
|
|
}
|
|
// admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership.
|
|
ctxAdmin := func() context.Context {
|
|
return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil})
|
|
}
|
|
BeforeEach(func() {
|
|
ds = &tests.MockDataStore{}
|
|
fp = &fakePlaylists{}
|
|
api = &Router{ds: ds, playlists: fp}
|
|
})
|
|
|
|
Describe("getItems", func() {
|
|
It("lists albums when IncludeItemTypes=MusicAlbum", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(2))
|
|
Expect(res.Items[0].Type).To(Equal("MusicAlbum"))
|
|
Expect(res.TotalRecordCount).To(Equal(2))
|
|
})
|
|
|
|
It("lists an album's songs when ParentId is an album and type is Audio", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Type).To(Equal("Audio"))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
|
|
})
|
|
|
|
It("ignores IncludeItemTypes names that aren't Jellyfin item kinds", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=music", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
|
|
})
|
|
|
|
It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() {
|
|
fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
|
|
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
|
|
}}
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&IncludeItemTypes=Audio", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(2))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
|
|
Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1")))
|
|
Expect(res.TotalRecordCount).To(Equal(2))
|
|
})
|
|
|
|
It("pages a playlist parent's tracks in the query, not in memory", func() {
|
|
fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
|
|
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
|
|
{ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}},
|
|
}}
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&StartIndex=1&Limit=1", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, 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(testID("s2"))))
|
|
Expect(fp.tracksRepo.Options.Offset).To(Equal(1))
|
|
Expect(fp.tracksRepo.Options.Max).To(Equal(1))
|
|
})
|
|
|
|
It("falls through to the type dispatch when ParentId is not a playlist", func() {
|
|
fp.getErr = model.ErrNotFound
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
|
|
})
|
|
|
|
It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() {
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetError(true)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=true", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
|
|
// Recursive=false asks for direct children only. Finamp's sync probes a library this way
|
|
// looking for tracks outside any album; answering with every track streams the whole library.
|
|
Describe("Recursive=false", func() {
|
|
BeforeEach(func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}})
|
|
})
|
|
|
|
It("returns no songs for a library parent, as tracks are never its direct children", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=false", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(BeEmpty())
|
|
Expect(res.TotalRecordCount).To(BeZero())
|
|
})
|
|
|
|
It("drops only Audio from a multi-type library query", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Type).To(Equal("MusicAlbum"))
|
|
})
|
|
|
|
It("still lists albums for a library parent, as they are its direct children", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
})
|
|
|
|
It("still lists an album's tracks, as they are its direct children", func() {
|
|
fp.getErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio&Recursive=false", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
|
|
})
|
|
|
|
It("keeps returning every song when no parent scopes the query", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=false", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
})
|
|
|
|
// Jellyfin's own default: ItemsController binds `bool? recursive` and reads it as
|
|
// `recursive ?? false`, so an omitted Recursive is a non-recursive request.
|
|
It("treats an omitted Recursive as false, like Jellyfin", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", AlbumArtistID: testID("ar1")}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("ar1"))+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
sql, _, err := albumRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("album_artists"))
|
|
})
|
|
|
|
It("lists artists when IncludeItemTypes=MusicArtist", func() {
|
|
ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Type).To(Equal("MusicArtist"))
|
|
})
|
|
|
|
It("lists genres when IncludeItemTypes=MusicGenre", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicGenre", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).NotTo(BeNil())
|
|
})
|
|
|
|
It("lists playlists when IncludeItemTypes=Playlist", func() {
|
|
ds.Playlist().(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", SongCount: 5}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Type).To(Equal("Playlist"))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("p1"))))
|
|
Expect(res.TotalRecordCount).To(Equal(1))
|
|
})
|
|
|
|
It("merges results from every requested type in IncludeItemTypes", func() {
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(2))
|
|
types := []string{res.Items[0].Type, res.Items[1].Type}
|
|
Expect(types).To(ConsistOf("Audio", "MusicAlbum"))
|
|
Expect(res.TotalRecordCount).To(Equal(2))
|
|
})
|
|
|
|
It("merges favorite songs, albums, and playlists", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo)
|
|
playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", Annotations: model.Annotations{Starred: true}}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(3))
|
|
types := []string{res.Items[0].Type, res.Items[1].Type}
|
|
types = append(types, res.Items[2].Type)
|
|
Expect(types).To(ConsistOf("Audio", "MusicAlbum", "Playlist"))
|
|
sql, _, err := albumRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("starred"))
|
|
playlistSQL, _, err := playlistRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(playlistSQL).To(ContainSubstring("starred"))
|
|
})
|
|
|
|
It("applies StartIndex/Limit to the merged multi-type result set", func() {
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}})
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(2))
|
|
Expect(res.TotalRecordCount).To(Equal(4))
|
|
Expect(res.StartIndex).To(Equal(1))
|
|
})
|
|
|
|
It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}})
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
// The merged window is [1, 3): each type needs at most its first 3 rows, not the table.
|
|
Expect(mfRepo.Options.Max).To(Equal(3))
|
|
Expect(albumRepo.Options.Max).To(Equal(3))
|
|
})
|
|
|
|
DescribeTable("translates the Filters list and its standalone equivalents",
|
|
func(query string, wantSQL, notWantSQL []string) {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&"+query, nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, _, err := albumRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
for _, want := range wantSQL {
|
|
Expect(sql).To(ContainSubstring(want))
|
|
}
|
|
for _, not := range notWantSQL {
|
|
Expect(sql).NotTo(ContainSubstring(not))
|
|
}
|
|
},
|
|
Entry("IsFavorite", "Filters=IsFavorite", []string{"starred"}, nil),
|
|
Entry("IsFavorite,IsUnplayed combined", "Filters=IsFavorite,IsUnplayed",
|
|
[]string{"starred", "play_count"}, nil),
|
|
Entry("IsUnplayed", "Filters=IsUnplayed", []string{"play_count"}, []string{"starred"}),
|
|
Entry("IsPlayed", "Filters=IsPlayed", []string{"play_count"}, []string{"starred"}),
|
|
Entry("IsFavoriteOrLikes is treated as favorites", "Filters=IsFavoriteOrLikes", []string{"starred"}, nil),
|
|
Entry("isPlayed=false", "isPlayed=false", []string{"play_count"}, nil),
|
|
Entry("isFavorite=false still filters", "isFavorite=false", []string{"starred"}, nil),
|
|
// Jellyfin builds the query from the standalone params, then applies Filters over the top.
|
|
Entry("Filters wins over the standalone param", "isFavorite=false&Filters=IsFavorite",
|
|
[]string{"starred = "}, nil),
|
|
// No Navidrome equivalent: these must be dropped, not half-applied.
|
|
Entry("Likes is ignored", "Filters=Likes", nil, []string{"starred", "play_count"}),
|
|
Entry("IsResumable is ignored", "Filters=IsResumable", nil, []string{"starred", "play_count"}),
|
|
// The artist-parent branch gets notMissing from filter.AlbumsByArtistID, not the default
|
|
// branch, so favorites must not be the only predicate left on it.
|
|
Entry("keeps missing excluded under an artist parent",
|
|
"Filters=IsFavorite&ArtistIds="+dto.EncodeID(testID("ar1")),
|
|
[]string{"starred", "missing"}, nil),
|
|
)
|
|
|
|
// Search runs a two-phase FTS query whose first phase has no annotation join, so an
|
|
// annotation predicate there is "no such column: starred" -> 500.
|
|
DescribeTable("does not push annotation filters into a search",
|
|
func(itemType, filters string) {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET",
|
|
"/Items?IncludeItemTypes="+itemType+"&SearchTerm=one&Filters="+filters, nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var opts model.QueryOptions
|
|
if itemType == "MusicAlbum" {
|
|
opts = ds.Album().(*tests.MockAlbumRepo).Options
|
|
} else {
|
|
opts = ds.MediaFile().(*tests.MockMediaFileRepo).Options
|
|
}
|
|
if opts.Filters == nil {
|
|
return
|
|
}
|
|
sql, _, err := opts.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).NotTo(ContainSubstring("starred"))
|
|
Expect(sql).NotTo(ContainSubstring("play_count"))
|
|
},
|
|
Entry("albums, IsFavorite", "MusicAlbum", "IsFavorite"),
|
|
Entry("albums, IsUnplayed", "MusicAlbum", "IsUnplayed"),
|
|
Entry("albums, IsPlayed", "MusicAlbum", "IsPlayed"),
|
|
Entry("songs, IsFavorite", "Audio", "IsFavorite"),
|
|
Entry("songs, IsUnplayed", "Audio", "IsUnplayed"),
|
|
)
|
|
|
|
It("forwards SearchTerm to the repo's Search method", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
})
|
|
|
|
It("caps a search the client left unbounded", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(albumRepo.Options.Max).To(Equal(defaultSearchLimit + 1))
|
|
})
|
|
|
|
It("honors an explicit search Limit up to the ceiling", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(albumRepo.Options.Max).To(Equal(501))
|
|
})
|
|
|
|
It("clamps a search Limit that would materialize the library", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1))
|
|
})
|
|
|
|
It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(2))
|
|
Expect(albumRepo.SearchQuery).To(BeEmpty())
|
|
})
|
|
|
|
It("reports a multi-type search total past the page, so clients keep paging", func() {
|
|
songs := make(model.MediaFiles, defaultSearchLimit*2)
|
|
for i := range songs {
|
|
songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
|
|
}
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(10))
|
|
Expect(res.TotalRecordCount).To(BeNumerically(">", 10))
|
|
})
|
|
|
|
It("bounds the multi-type search window however large StartIndex is", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
// Without the bound this asks each type for ~500001 rows.
|
|
Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1))
|
|
})
|
|
|
|
It("stops a multi-type search at the ceiling rather than serving another type's rows", func() {
|
|
// Bounding the per-type window is what keeps StartIndex from driving it without limit, and
|
|
// past that window the merged order is no longer the true one.
|
|
songs := make(model.MediaFiles, maxSearchLimit+1)
|
|
for i := range songs {
|
|
songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
|
|
}
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET",
|
|
fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit),
|
|
nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(BeEmpty())
|
|
Expect(res.TotalRecordCount).To(Equal(maxSearchLimit))
|
|
})
|
|
|
|
It("serves the last page below the ceiling in full", func() {
|
|
songs := make(model.MediaFiles, maxSearchLimit+1)
|
|
for i := range songs {
|
|
songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
|
|
}
|
|
// The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold.
|
|
slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) })
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET",
|
|
fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1),
|
|
nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
// Clipped to the window; the interleaved album takes one slot, shifting this song in by one.
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-2].ID)))
|
|
})
|
|
|
|
It("bounds an unbounded multi-type search to the default in total, not per type", func() {
|
|
songs := make(model.MediaFiles, defaultSearchLimit*2)
|
|
for i := range songs {
|
|
songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
|
|
}
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil).
|
|
WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(defaultSearchLimit))
|
|
})
|
|
|
|
It("pages an unbounded multi-type search past the default without dropping matches", func() {
|
|
songs := make(model.MediaFiles, defaultSearchLimit*2)
|
|
for i := range songs {
|
|
songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
|
|
}
|
|
// The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold.
|
|
slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) })
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET",
|
|
fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50),
|
|
nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).ToNot(BeEmpty())
|
|
// The interleaved album takes one slot ahead of it, shifting this song in by one.
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+49].ID)))
|
|
})
|
|
|
|
It("reports a search total beyond the fetched page instead of the page length", func() {
|
|
ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{
|
|
{ID: testID("r1"), Name: "Alpha"}, {ID: testID("r2"), Name: "Beta"}, {ID: testID("r3"), Name: "Gamma"},
|
|
})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.TotalRecordCount).To(Equal(3))
|
|
})
|
|
|
|
It("forwards StartIndex/Limit as Offset/Max", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(albumRepo.Options.Offset).To(Equal(5))
|
|
Expect(albumRepo.Options.Max).To(Equal(10))
|
|
})
|
|
|
|
Describe("Ids batch-fetch", func() {
|
|
// Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=<id>; without
|
|
// this, queryItems ignored Ids and returned the default type-dispatched list instead.
|
|
It("returns exactly the requested item when Ids has a single id", func() {
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
|
|
Expect(res.Items[0].Name).To(Equal("Song"))
|
|
Expect(res.TotalRecordCount).To(Equal(1))
|
|
})
|
|
|
|
It("returns items of different types for a lowercase ids param with multiple ids", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(2))
|
|
ids := []string{res.Items[0].Id, res.Items[1].Id}
|
|
Expect(ids).To(ConsistOf(dto.EncodeID(testID("a1")), dto.EncodeID(testID("s1"))))
|
|
types := []string{res.Items[0].Type, res.Items[1].Type}
|
|
Expect(types).To(ConsistOf("MusicAlbum", "Audio"))
|
|
Expect(res.TotalRecordCount).To(Equal(2))
|
|
})
|
|
|
|
It("resolves song ids with one batched IN query, not a Get per id", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}, {ID: testID("s2"), Title: "Song2", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(2))
|
|
sql, args, err := mfRepo.Options.Filters.ToSql()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("media_file.id IN"))
|
|
Expect(args).To(ConsistOf(testID("s1"), testID("s2")))
|
|
})
|
|
|
|
It("omits an id in a library the user can't access, without erroring the whole batch", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) // alice only has access to library 1
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("a1"))))
|
|
Expect(res.TotalRecordCount).To(Equal(1))
|
|
})
|
|
})
|
|
|
|
Describe("sorting", func() {
|
|
DescribeTable("translates SortBy into the repo's sort keys",
|
|
func(itemType, sortBy, want string) {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes="+itemType+"&SortBy="+sortBy, nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
got := mfRepo.Options.Sort
|
|
if itemType == "MusicAlbum" {
|
|
got = albumRepo.Options.Sort
|
|
}
|
|
Expect(got).To(Equal(want))
|
|
},
|
|
Entry("PlayCount", "MusicAlbum", "PlayCount", "play_count"),
|
|
Entry("DatePlayed", "Audio", "DatePlayed", "play_date"),
|
|
Entry("Runtime on albums", "MusicAlbum", "Runtime", "duration"),
|
|
Entry("RunTimeTicks alias", "MusicAlbum", "RunTimeTicks", "duration"),
|
|
// Finamp leads its track sort with Runtime: unless that resolves, the first recognized
|
|
// key is AlbumArtist and the list looks sorted while being sorted by the wrong thing.
|
|
Entry("Finamp's Runtime-led track sort", "Audio", "Runtime,AlbumArtist,Album,SortName",
|
|
"duration, album_artist, album, title"),
|
|
Entry("every recognized key, in order", "MusicAlbum", "DateCreated,SortName", "recently_added, name"),
|
|
Entry("a key repeating a column is dropped", "Audio",
|
|
"PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName", "year, album, title"),
|
|
// random is matched by exact string equality in the repo, so it can never share a sort.
|
|
Entry("Random stays alone", "MusicAlbum", "Random,SortName", "random"),
|
|
Entry("unrecognized keys are skipped", "Audio", "Runtime,Nonsense,SortName", "duration, title"),
|
|
Entry("only the last key recognized", "Audio", "Unknown1,Unknown2,SortName", "title"),
|
|
Entry("Finamp's album view is disc+track", "Audio", "ParentIndexNumber,IndexNumber,SortName", "album, title"),
|
|
Entry("nothing recognized leaves the repo default", "MusicAlbum", "SeriesSortName", ""),
|
|
)
|
|
|
|
// Jellyfin allows a per-key SortOrder list; we cannot express that through one Order, so
|
|
// we honor the first value for all keys, matching Jellyfin's fallback for extra keys.
|
|
DescribeTable("reads the first SortOrder value for the whole sort",
|
|
func(sortOrder, want string) {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET",
|
|
"/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime,SortName&SortOrder="+sortOrder, nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(albumRepo.Options.Order).To(Equal(want))
|
|
},
|
|
Entry("ascending", "Ascending", ""),
|
|
Entry("descending", "Descending", "desc"),
|
|
Entry("descending leading a list", "Descending,Ascending", "desc"),
|
|
Entry("ascending leading a list", "Ascending,Descending", ""),
|
|
)
|
|
})
|
|
|
|
Describe("library scoping", func() {
|
|
It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
libs := model.Libraries{{ID: 1}, {ID: 2}}
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, args, err := albumRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("library_id"))
|
|
Expect(args).To(ContainElements(1, 2))
|
|
})
|
|
|
|
It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
w := httptest.NewRecorder()
|
|
libs := model.Libraries{{ID: 1}, {ID: 2}}
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs))
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, args, err := mfRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("library_id"))
|
|
Expect(args).To(ContainElements(1, 2))
|
|
})
|
|
|
|
It("scopes a MusicArtist listing to the user's accessible libraries", func() {
|
|
artistRepo := ds.Artist().(*tests.MockArtistRepo)
|
|
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
|
|
w := httptest.NewRecorder()
|
|
libs := model.Libraries{{ID: 1}, {ID: 2}}
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs))
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, args, err := artistRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("library_artist.library_id"))
|
|
Expect(args).To(ContainElements(1, 2))
|
|
})
|
|
|
|
It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
libs := model.Libraries{{ID: 1}, {ID: 2}}
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(2)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, args, err := albumRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).NotTo(ContainSubstring("album_artists")) // not treated as an artist-parent filter
|
|
Expect(sql).To(ContainSubstring("library_id"))
|
|
Expect(args).To(ContainElement(2))
|
|
})
|
|
|
|
It("does not let ParentId=<inaccessible library id> scope results to that library", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
libs := model.Libraries{{ID: 1}} // no access to library 99
|
|
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(99)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, args, err := albumRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
// Falls back to treating "99" as an (empty-matching) artist-parent id...
|
|
Expect(sql).To(ContainSubstring("album_artists"))
|
|
// ...while still scoping to the user's own accessible libraries.
|
|
Expect(sql).To(ContainSubstring("library_id"))
|
|
Expect(args).To(ContainElement(1))
|
|
Expect(args).NotTo(ContainElement(99))
|
|
})
|
|
|
|
It("does not restrict a default MusicAlbum listing for an admin user", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}, {ID: testID("a2"), Name: "Two", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
// accessibleLibraryIDs is empty for an admin (Libraries is nil), so
|
|
// ApplyLibraryFilter([]) is a no-op: no library_id restriction is added.
|
|
if albumRepo.Options.Filters == nil {
|
|
return
|
|
}
|
|
sql, _, err := albumRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).NotTo(ContainSubstring("library_id"))
|
|
})
|
|
})
|
|
|
|
// A malformed id must 404, not silently drop the filter; a well-formed but unknown one must
|
|
// still reach the entity filter, not the unfiltered default.
|
|
Describe("stale and malformed id filtering", func() {
|
|
It("404s a malformed ParentId instead of listing every song", func() {
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("404s a malformed AlbumArtistIds instead of listing every album", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds=not-a-valid-id", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("404s a malformed ArtistIds instead of listing every song", func() {
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ArtistIds=not-a-valid-id", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds="+dto.EncodeID(testID("no-such-artist")), nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, _, err := albumRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("album_artists"))
|
|
})
|
|
|
|
It("still applies the album filter (rather than dropping it) for a well-formed but unknown ParentId", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId="+dto.EncodeID(testID("no-such-album")), nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, args, err := mfRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("album_id"))
|
|
Expect(args).To(ContainElement(testID("no-such-album")))
|
|
})
|
|
})
|
|
|
|
Describe("mixed IncludeItemTypes merge", func() {
|
|
BeforeEach(func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "S1"}, {ID: testID("s2"), Title: "S2"}})
|
|
})
|
|
|
|
It("returns a mix of both types, not all of one", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(4))
|
|
Expect(res.TotalRecordCount).To(Equal(4))
|
|
types := map[string]int{}
|
|
for _, it := range res.Items {
|
|
types[it.Type]++
|
|
}
|
|
Expect(types["Audio"]).To(Equal(2))
|
|
Expect(types["MusicAlbum"]).To(Equal(2))
|
|
})
|
|
|
|
It("interleaves types round-robin (Audio first, per IncludeItemTypes order)", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
got := []string{res.Items[0].Type, res.Items[1].Type, res.Items[2].Type, res.Items[3].Type}
|
|
Expect(got).To(Equal([]string{"Audio", "MusicAlbum", "Audio", "MusicAlbum"}))
|
|
})
|
|
|
|
It("honors Limit across the merged set", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=1", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.TotalRecordCount).To(Equal(4))
|
|
})
|
|
|
|
It("serves a full random page from offset 0 regardless of StartIndex", func() {
|
|
// A deep StartIndex on a random merge must not materialize offset+limit rows; since random
|
|
// reshuffles per request, offset 0 is an equivalent fresh draw. Old behavior returned empty.
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SortBy=Random&Recursive=true&StartIndex=1000&Limit=4", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.Items).To(HaveLen(4))
|
|
})
|
|
|
|
It("propagates a per-type query error", func() {
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetError(true)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser())
|
|
invoke(api.getItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("getItem", func() {
|
|
It("returns an album by id", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var item dto.BaseItemDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
|
|
Expect(item.Id).To(Equal(dto.EncodeID(testID("a1"))))
|
|
Expect(item.Type).To(Equal("MusicAlbum"))
|
|
})
|
|
|
|
It("returns 404 when the id doesn't match any entity", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing")))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 404 for an album in a library the user can't access", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 404 for a song in a library the user can't access", func() {
|
|
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) // only has access to library 1
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns an album to an admin even when it's outside their (empty) Libraries", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxAdmin()) // admin, Libraries: nil
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var item dto.BaseItemDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
|
|
Expect(item.Id).To(Equal(dto.EncodeID(testID("a1"))))
|
|
})
|
|
|
|
// Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the
|
|
// library node before it can load the home screen or any library tab.
|
|
It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() {
|
|
w := httptest.NewRecorder()
|
|
libs := model.Libraries{{ID: 1, Name: "Music Library"}}
|
|
ds.Library().(*tests.MockLibraryRepo).SetData(libs)
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs))
|
|
r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var item dto.BaseItemDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
|
|
Expect(item.Id).To(Equal(dto.EncodeLibraryID(1)))
|
|
Expect(item.Name).To(Equal("Music Library"))
|
|
Expect(item.Type).To(Equal("CollectionFolder"))
|
|
Expect(item.CollectionType).To(Equal("music"))
|
|
Expect(item.IsFolder).To(BeTrue())
|
|
})
|
|
|
|
It("does not resolve a library-view id the user has no access to", func() {
|
|
w := httptest.NewRecorder()
|
|
libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs))
|
|
r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
// Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it
|
|
// 404s with "Could not fetch BaseItemDto <playlist> from server."
|
|
It("resolves a playlist id via the playlists service", func() {
|
|
fp.getByIDPls = &model.Playlist{ID: testID("p1"), Name: "My Mix", SongCount: 5}
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var item dto.BaseItemDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
|
|
Expect(item.Id).To(Equal(dto.EncodeID(testID("p1"))))
|
|
Expect(item.Name).To(Equal("My Mix"))
|
|
Expect(item.Type).To(Equal("Playlist"))
|
|
})
|
|
|
|
It("returns 404 for a non-owned or absent playlist id", func() {
|
|
fp.getByIDErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
// Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID).
|
|
It("resolves a genre id as a MusicGenre item", func() {
|
|
Expect(ds.Genre().(*tests.MockedGenreRepo).Put(&model.Genre{ID: testID("g1"), Name: "Rock"})).To(Succeed())
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1")), nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1")))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var item dto.BaseItemDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
|
|
Expect(item.Id).To(Equal(dto.EncodeID(testID("g1"))))
|
|
Expect(item.Name).To(Equal("Rock"))
|
|
Expect(item.Type).To(Equal("MusicGenre"))
|
|
})
|
|
|
|
It("resolves a library-view id for an admin even though their Libraries slice is empty", func() {
|
|
ds.Library().(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxAdmin())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1))
|
|
invoke(api.getItem, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var item dto.BaseItemDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
|
|
Expect(item.Id).To(Equal(dto.EncodeLibraryID(1)))
|
|
Expect(item.Name).To(Equal("Music Library"))
|
|
Expect(item.Type).To(Equal("CollectionFolder"))
|
|
})
|
|
})
|
|
|
|
Describe("getLatest", func() {
|
|
It("returns a bare array of the newest albums", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser())
|
|
invoke(api.getLatest, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var items []dto.BaseItemDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed())
|
|
Expect(items).To(HaveLen(1))
|
|
Expect(items[0].Id).To(Equal(dto.EncodeID(testID("a1"))))
|
|
})
|
|
|
|
It("scopes to the user's accessible libraries", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
libs := model.Libraries{{ID: 1}, {ID: 2}}
|
|
r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs))
|
|
invoke(api.getLatest, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
sql, args, err := albumRepo.Options.Filters.ToSql()
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("library_id"))
|
|
Expect(args).To(ContainElements(1, 2))
|
|
})
|
|
})
|
|
|
|
Describe("applySort random for all merge types", func() {
|
|
DescribeTable("maps Random -> random",
|
|
func(itemType string) {
|
|
var opts model.QueryOptions
|
|
applySort(&opts, itemType, "Random", "")
|
|
Expect(opts.Sort).To(Equal("random"))
|
|
},
|
|
Entry("Audio", "Audio"),
|
|
Entry("MusicAlbum", "MusicAlbum"),
|
|
Entry("MusicArtist", "MusicArtist"),
|
|
Entry("MusicGenre", "MusicGenre"),
|
|
Entry("Playlist", "Playlist"),
|
|
)
|
|
})
|
|
|
|
Describe("interleave", func() {
|
|
It("round-robins one item per list in turn", func() {
|
|
lists := [][]dto.BaseItemDto{
|
|
{{Id: "a0"}, {Id: "a1"}, {Id: "a2"}},
|
|
{{Id: "b0"}, {Id: "b1"}},
|
|
}
|
|
got := interleave(lists)
|
|
ids := make([]string, len(got))
|
|
for i, it := range got {
|
|
ids[i] = it.Id
|
|
}
|
|
Expect(ids).To(Equal([]string{"a0", "b0", "a1", "b1", "a2"}))
|
|
})
|
|
|
|
It("returns empty for no lists", func() {
|
|
Expect(interleave(nil)).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("parseTypes", func() {
|
|
It("dedupes repeated types, preserving first-seen order", func() {
|
|
Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"}))
|
|
})
|
|
|
|
It("matches type names case-insensitively, like Jellyfin's enum binding", func() {
|
|
Expect(parseTypes("musicalbum, AUDIO")).To(Equal([]string{"MusicAlbum", "Audio"}))
|
|
})
|
|
|
|
It("returns no types for real Jellyfin kinds Navidrome has none of", func() {
|
|
Expect(parseTypes("Boxset")).To(BeEmpty())
|
|
Expect(parseTypes("BoxSet,Movie")).To(BeEmpty())
|
|
})
|
|
|
|
It("drops names that aren't Jellyfin item kinds, case-insensitively", func() {
|
|
Expect(knownItemKinds("music")).To(BeEmpty())
|
|
Expect(knownItemKinds("music, audio,MUSICVIDEO")).To(Equal("audio,MUSICVIDEO"))
|
|
})
|
|
|
|
It("defaults to albums only when IncludeItemTypes is absent", func() {
|
|
Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"}))
|
|
Expect(parseTypes("Nonsense")).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("decodeFilterParam", func() {
|
|
It("reports ok for an absent param, decoding to \"\"", func() {
|
|
id, ok := decodeFilterParam("")
|
|
Expect(id).To(BeEmpty())
|
|
Expect(ok).To(BeTrue())
|
|
})
|
|
|
|
It("reports ok for a well-formed id, whether or not it exists", func() {
|
|
id, ok := decodeFilterParam(dto.EncodeID(testID("a1")))
|
|
Expect(id).To(Equal(testID("a1")))
|
|
Expect(ok).To(BeTrue())
|
|
})
|
|
|
|
It("reports not ok for a non-empty param that fails to decode", func() {
|
|
id, ok := decodeFilterParam("not-a-valid-id")
|
|
Expect(id).To(BeEmpty())
|
|
Expect(ok).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("decodedQueryIDs", func() {
|
|
It("decodes every entry when all are well-formed", func() {
|
|
r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("a2")), nil)
|
|
ids, ok := decodedQueryIDs(r, "ids")
|
|
Expect(ok).To(BeTrue())
|
|
Expect(ids).To(Equal([]string{testID("a1"), testID("a2")}))
|
|
})
|
|
|
|
It("reports not ok and an empty list, not a partially-decoded one, for a mix of valid and malformed entries", func() {
|
|
r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+",not-a-valid-id", nil)
|
|
ids, ok := decodedQueryIDs(r, "ids")
|
|
Expect(ok).To(BeFalse())
|
|
Expect(ids).To(BeEmpty())
|
|
})
|
|
|
|
It("reports ok for an absent param, decoding to an empty list", func() {
|
|
r := httptest.NewRequest("GET", "/Items", nil)
|
|
ids, ok := decodedQueryIDs(r, "ids")
|
|
Expect(ok).To(BeTrue())
|
|
Expect(ids).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|