mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* fix(jellyfin): honor Filters=IsFavorite on /Artists and /Artists/AlbumArtists listArtistsByRole hand-built its itemsQuery and never set favOnly, so the favorites filter was silently dropped on both artist routes while /Items honored it. Finamp's home screen asks for favorite artists once per load and was served the entire artist list instead: 10,298 artists, 6.15 MB, 2.7s on a real library, and the wrong data on screen. Extract the favOnly parsing that parseItemsQuery already did into parseFavOnly and use it in both places. listArtists now adds the starred predicate to notMissing rather than replacing it, matching listAlbums and listSongs, so a favorite artist whose files are gone stays excluded. * fix(jellyfin): map SortBy=Runtime to duration for albums and songs sortColumnsByType had no runtime/runtimeticks key for any type, so Finamp's "Duration" sort silently misbehaved in two different ways. Albums: Finamp sends a bare SortBy=Runtime. Nothing matched, opts.Sort stayed empty, and applyOptions skips OrderBy entirely when Sort is empty — so the query ran with no ORDER BY at all and Ascending and Descending returned identical lists. Songs: Finamp sends SortBy=Runtime,AlbumArtist,Album,SortName. applySort takes the first *recognized* key, so Runtime was skipped and the list came back sorted by album artist while looking correct. Both repos already accept a duration sort (mediafile_repository maps it explicitly; album_repository falls through to the column name), so no migration is needed. Sorting 97k songs by duration costs a temp B-tree (~114ms on a prod-sized copy) — the same cost the Subsonic and UI duration sorts already pay, and correct where the previous behaviour was merely fast. * fix(jellyfin): apply the played/unplayed filters and MaxHeight image bound Filters was matched with a substring test for IsFavorite, so every other token Jellyfin defines was silently dropped and the response kept rows it should have excluded. Finamp sends Filters=IsUnplayed in normal use. Replace the bool with a parsed itemFilters carrying nullable favorite and played flags, so isFavorite=false and isPlayed=false are real filters rather than indistinguishable from an absent param. Standalone params are read first and the Filters list overrides them, the precedence real Jellyfin has. IsFavoriteOrLikes now maps to favorites deliberately instead of by substring accident; Likes, Dislikes, IsFolder, IsNotFolder and IsResumable have no Navidrome equivalent and are dropped rather than half-applied. The negative cases match NULL as well, since annotations are LEFT JOINed and an untouched item has no row. getItemImage read only maxwidth, so a client sending just MaxHeight got the full-size original: measured against a real cover, maxHeight=100 returned 82,570 bytes where maxWidth=100 returned 3,316. Use the tighter of the two bounds. * refactor(jellyfin): share the plain-param parser between /Items and /Artists listArtistsByRole hand-listed the itemsQuery fields it happened to need, which is exactly how the favorites filter went missing: the literal has been amended in four of the five commits that touched it. Extract listParams for the fields that come straight from query params so both paths read one parser, and the next supported param reaches every list path instead of only /Items. Also from the cleanup pass: collapse imageSize to a single clamped comparison and read its bounds through req.Params like the rest of the package, which drops the strconv import; build the artist and playlist filter lists with the flat append shape the album and song paths already use, instead of re-wrapping opts.Filters into a nested And per predicate; drop a nil guard in listPlaylists that no caller can reach, since both paths into queryItemsOfType build QueryOptions without Filters. applySort now logs when no SortBy key resolves at all — a miss inside a fallback list is normal, but none matching means a silently ignored sort, the failure mode that hid the Runtime bug. Its doc comment records why the remaining keys cannot simply be joined. Folds three duplicated test bodies into the tables that already parameterize them, and covers the artist-parent album branch, which reaches notMissing through filter.AlbumsByArtistID rather than the default branch. * docs(jellyfin): correct how applySort describes Jellyfin's SortBy semantics The comment claimed SortBy is a comma-separated fallback list. It is not: RequestHelpers.GetOrderBy (10.10) builds one (ItemSortBy, SortOrder) pair per key, so Jellyfin orders by every key in turn. Navidrome applies only the first recognized one, which is a real divergence — secondary keys never break ties — not the intended reading of the parameter. The assertion that the keys cannot be joined was also wrong. buildSortOrder does split its input on commas; what it maps is the whole string, so joining raw Jellyfin key names misses the mappings. Mapping each key first and joining the results would work, which makes multi-key sorting a real option rather than a blocked one. Documenting the current behaviour as a known divergence until then. * fix(jellyfin): order by every recognized SortBy key, not just the first Jellyfin orders by each SortBy key in turn, so "DatePlayed,SortName" means break ties by name. Navidrome applied only the first recognized key and dropped the rest, which is 28% of the sort traffic on a real server (23 of 82 requests in 12h carry 2-5 keys). Most were harmless because the primary key dominates, but PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName came back unordered within a year. The keys cannot simply be joined: sortMapping keyed on the whole Sort string, so a joined value missed every mapping and fell through to raw column names. Make it resolve a comma list per part, but only when every part is a known key — the four existing callers that pass raw column lists (core/matcher, core/lyrics, core/maintenance, subsonic/browsing) all carry a part that is not a mapping key, several with their own direction, so they keep falling through exactly as before. Verified each one. applySort now collects every recognized key, skipping duplicates so ParentIndexNumber,IndexNumber does not repeat a column. random stays alone: the repo matches it by exact string equality, so joining it would both break that path and emit a bare 'random' column into the ORDER BY. Verified against a prod-sized copy: every multi-key combination seen in real traffic returns 200, and a secondary key now changes the order within a tied year for songs. Albums are unchanged there, because their max_year mapping already ended in ", name". * fix(persistence): resolve sort mappings exactly once Making sortMapping resolve a comma list per part broke an invariant it had been relying on: idempotence. sanitizeSort mapped the sort key up front and applyOptions then ran buildSortOrder over the result, so sortMapping was already being handed its own output. That was harmless only while a mapped value could never look like a key list. media_file's rated_at maps to "rating, rated_at", and both parts are keys, so the second pass expanded it to "rating, rating, rated_at". Found by round-tripping every mapping in all four repositories; it was the only collision, and the duplicate sort key was benign in SQL, but any future mapping of that shape would silently change meaning. sanitizeSort now validates without resolving, leaving buildSortOrder as the single mapping point. The generated SQL is unchanged — the whole suite passes apart from the two specs that asserted the old return value, which are updated and joined by a round-trip guard covering exactly the rated_at shape. Also use the paren-aware splitFunc that buildSortOrder already uses, so an expression carrying commas inside its parentheses cannot be split apart. * refactor(jellyfin,persistence): flatten the sort resolution paths Cleanup pass over the branch, no behavior change. sortMapping loses the len(parts)>1 guard, which existed only to pick between two identical toSnakeCase exits; the single-key case now falls through the same loop. lookupSortMapping hands back the snake_case form it had to derive so the fallback stops recomputing it — toSnakeCase is two regexps, and on a miss it was running twice per call. sanitizeSort now asks lookupSortMapping instead of probing the map itself, so "is this a known sort key" has one answer; the two had already drifted, since sanitizeSort tried one casing where the resolver tries three. applySort folds the nested random branch into the skip condition and the two trailing length tests into one switch. setSortMappings documents the invariant the comma-list rule depends on, where someone adding a mapping will read it. The README line describing SortBy still said only the first key applied, which the commit before last made false. Tests: the twelve near-identical sorting specs become one DescribeTable of (itemType, SortBy, want) triples, 124 lines to 36, and the applyOptions round-trip assertion collapses to the buildSortOrder call its sibling uses. * fix(jellyfin): keep annotation filters out of search, resolve sorts per part Two findings from the Codex review on #5981. The played/unplayed filters turned working requests into 500s when combined with SearchTerm. Search runs a two-phase FTS query whose first phase selects rowids with no annotation join, so a starred or play_count predicate there is "no such column", not a filter. Measured against master: MusicAlbum with SearchTerm and Filters=IsUnplayed went 200 -> 500, likewise IsPlayed and the Audio equivalents. listAlbums and listSongs now skip those predicates on the search path, matching what listArtists already did. That also clears the same 500 master already had for Filters=IsFavorite with SearchTerm. sortMapping resolved a comma list only while every part was a known key, so a list mixing a plain column with a mapped key kept neither: MusicAlbum SortBy=Runtime,SortName arrives as "duration, name", and duration is a plain album column, so name stayed raw instead of expanding to order_album_name. Albums whose name differs from its sort form — 1,366 of 6,987 on a real library — then ordered by the wrong secondary key, and PreferSortTags was ignored. Each part is now resolved on its own, which is what setSortMappings already documents for a single field. Verified every in-tree caller that passes a raw column list still produces its original ORDER BY. Codex also asked for the artist search path to apply the same filters. It would 500 for the reason above, and wrapping the library scope in a compound filter makes requestedLibraryIDs stop recognizing it, silently widening the search past the requested ParentId. * fix(jellyfin): honor the first SortOrder value for a multi-key sort applySort compared the whole SortOrder string with "Descending", so a per-key list like SortOrder=Descending,Ascending failed the match and every key, including the primary, sorted ascending — the exact opposite of the request. Take the first comma-separated value, which Jellyfin also uses for any key past the end of the SortOrder list. True per-key directions can't be expressed through the single opts.Sort string and are left out; no observed client sends a SortOrder list.
1172 lines
57 KiB
Go
1172 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(context.Background()).(*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(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile(context.Background()).(*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("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(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile(context.Background()).(*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(context.Background()).(*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(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
ds.Album(context.Background()).(*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(context.Background()).(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
|
|
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
playlistRepo := ds.Playlist(context.Background()).(*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(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}})
|
|
ds.Album(context.Background()).(*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(context.Background()).(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}})
|
|
albumRepo := ds.Album(context.Background()).(*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(context.Background()).(*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(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile(context.Background()).(*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(context.Background()).(*tests.MockAlbumRepo).Options
|
|
} else {
|
|
opts = ds.MediaFile(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
|
|
ds.Album(context.Background()).(*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(context.Background()).(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
ds.MediaFile(context.Background()).(*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(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
|
|
ds.Album(context.Background()).(*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(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
|
|
ds.Album(context.Background()).(*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(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
|
|
ds.Album(context.Background()).(*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(context.Background()).(*tests.MockMediaFileRepo).SetData(songs)
|
|
ds.Album(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
|
|
ds.MediaFile(context.Background()).(*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(context.Background()).(*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(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
|
|
ds.MediaFile(context.Background()).(*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(context.Background()).(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
|
|
mfRepo := ds.MediaFile(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
|
|
ds.MediaFile(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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"}}
|
|
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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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(context.Background()).(*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"}))
|
|
})
|
|
})
|
|
|
|
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())
|
|
})
|
|
})
|
|
})
|