mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-30 15:07:56 +01:00
fix(jellyfin): support Playlist type, multi-type Items queries, and PlayCount/DatePlayed sort
Real Finamp requests break against three /Items query engine bugs: Playlist requests fell through to albums, multi-type IncludeItemTypes (e.g. Finamp's favorites screen) only returned the first requested type, and comma-separated SortBy lists (e.g. "DateCreated,SortName") were matched as one opaque string so they always fell through to the repo default. Also extends tests/mock_playlist_repo.go with SetData/GetAll so playlist listing is testable like the other mock repos.
This commit is contained in:
parent
ae78d779ca
commit
3a155e23e8
6 changed files with 312 additions and 70 deletions
|
|
@ -149,3 +149,8 @@ curl -s -X DELETE "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?EntryIds=$ENT
|
|||
- **ID handling is pass-through only.** Navidrome ids are used verbatim as Jellyfin item ids;
|
||||
there's no hex/GUID-reversible id scheme. If a client turns out to mangle ids in a way that
|
||||
requires one (e.g. expecting a GUID shape), that will need a follow-up change.
|
||||
- **Playlists never match `Filters=IsFavorite`.** `GET Items?IncludeItemTypes=Playlist` (alone
|
||||
or mixed with other types, e.g. Finamp's favorites screen sending
|
||||
`IncludeItemTypes=Audio,MusicAlbum,Playlist`) is supported, but `model.Playlist` has no
|
||||
starred/annotation concept, so a favorites query always returns zero playlists rather than
|
||||
erroring.
|
||||
|
|
|
|||
|
|
@ -122,3 +122,19 @@ func GenreToBaseItem(g model.Genre) BaseItemDto {
|
|||
BackdropImageTags: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. Unlike songs/albums/artists,
|
||||
// model.Playlist has no embedded Annotations (no starred/rating/play-count), so UserData is
|
||||
// left nil rather than synthesized.
|
||||
func PlaylistToBaseItem(p model.Playlist) BaseItemDto {
|
||||
return BaseItemDto{
|
||||
Name: p.Name,
|
||||
Id: p.ID,
|
||||
Type: "Playlist",
|
||||
IsFolder: true,
|
||||
MediaType: "Audio",
|
||||
ChildCount: new(p.SongCount),
|
||||
RunTimeTicks: TicksFromSeconds(p.Duration),
|
||||
BackdropImageTags: []string{},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,4 +79,16 @@ var _ = Describe("mappers", func() {
|
|||
Expect(item.Id).To(Equal("genre-1"))
|
||||
Expect(item.Name).To(Equal("Rock"))
|
||||
})
|
||||
|
||||
It("maps a playlist to a Playlist BaseItemDto", func() {
|
||||
p := model.Playlist{ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120}
|
||||
item := PlaylistToBaseItem(p)
|
||||
Expect(item.Type).To(Equal("Playlist"))
|
||||
Expect(item.IsFolder).To(BeTrue())
|
||||
Expect(item.Id).To(Equal("pl-1"))
|
||||
Expect(item.Name).To(Equal("Chill"))
|
||||
Expect(item.MediaType).To(Equal("Audio"))
|
||||
Expect(*item.ChildCount).To(Equal(7))
|
||||
Expect(item.RunTimeTicks).To(Equal(int64(1_200_000_000)))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,24 +29,53 @@ func (api *Router) getItems(w http.ResponseWriter, r *http.Request) {
|
|||
api.ok(w, r, res)
|
||||
}
|
||||
|
||||
// queryItems is the universal /Items dispatcher: it picks a target entity from
|
||||
// IncludeItemTypes (falling back to MusicAlbum) and delegates to the matching listXxx.
|
||||
// queryItems is the universal /Items dispatcher. It parses every recognized entity type out of
|
||||
// IncludeItemTypes (falling back to MusicAlbum when none are recognized), queries each type via
|
||||
// the matching listXxx, and — when more than one type was requested — merges the results into a
|
||||
// single paginated list, as real Finamp does for its favorites screen
|
||||
// (IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite).
|
||||
func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryResult, error) {
|
||||
p := req.Params(r)
|
||||
parentId := p.StringOr("ParentId", "")
|
||||
search := p.StringOr("SearchTerm", "")
|
||||
favOnly := strings.Contains(p.StringOr("Filters", ""), "IsFavorite")
|
||||
itemType := firstType(p.StringOr("IncludeItemTypes", ""))
|
||||
|
||||
opts := model.QueryOptions{Offset: p.IntOr("StartIndex", 0), Max: p.IntOr("Limit", 0)}
|
||||
applySort(&opts, itemType, p.StringOr("SortBy", ""), p.StringOr("SortOrder", ""))
|
||||
sortBy := p.StringOr("SortBy", "")
|
||||
sortOrder := p.StringOr("SortOrder", "")
|
||||
offset := p.IntOr("StartIndex", 0)
|
||||
limit := p.IntOr("Limit", 0)
|
||||
types := parseTypes(p.StringOr("IncludeItemTypes", ""))
|
||||
|
||||
scopeIDs, isLibraryParent := resolveLibraryScope(ctx, parentId)
|
||||
entityParent := parentId
|
||||
if isLibraryParent {
|
||||
// ParentId-as-entity-id (an artist for a MusicAlbum query, an album for an Audio query) only
|
||||
// makes sense when browsing a single type; a multi-type query has no single natural parent
|
||||
// entity type, so there ParentId only ever acts as library scoping.
|
||||
if isLibraryParent || len(types) > 1 {
|
||||
entityParent = ""
|
||||
}
|
||||
|
||||
if len(types) == 1 {
|
||||
opts := model.QueryOptions{Offset: offset, Max: limit}
|
||||
applySort(&opts, types[0], sortBy, sortOrder)
|
||||
return api.queryItemsOfType(ctx, types[0], opts, entityParent, scopeIDs, search, favOnly)
|
||||
}
|
||||
|
||||
var items []dto.BaseItemDto
|
||||
total := 0
|
||||
for _, itemType := range types {
|
||||
var opts model.QueryOptions
|
||||
applySort(&opts, itemType, sortBy, sortOrder)
|
||||
res, err := api.queryItemsOfType(ctx, itemType, opts, entityParent, scopeIDs, search, favOnly)
|
||||
if err != nil {
|
||||
return dto.QueryResult{}, err
|
||||
}
|
||||
items = append(items, res.Items...)
|
||||
total += res.TotalRecordCount
|
||||
}
|
||||
return result(paginate(items, offset, limit), total, offset), nil
|
||||
}
|
||||
|
||||
func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, entityParent string, scopeIDs []int, search string, favOnly bool) (dto.QueryResult, error) {
|
||||
switch itemType {
|
||||
case "Audio":
|
||||
return api.listSongs(ctx, opts, entityParent, scopeIDs, search, favOnly)
|
||||
|
|
@ -54,25 +83,46 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryRe
|
|||
return api.listArtists(ctx, opts, scopeIDs, search, favOnly)
|
||||
case "MusicGenre":
|
||||
return api.listGenres(ctx, opts)
|
||||
case "Playlist":
|
||||
return api.listPlaylists(ctx, opts, favOnly)
|
||||
default: // MusicAlbum
|
||||
return api.listAlbums(ctx, opts, entityParent, scopeIDs, search, favOnly)
|
||||
}
|
||||
}
|
||||
|
||||
// firstType picks the first recognized entry in the (possibly comma-separated)
|
||||
// IncludeItemTypes, defaulting to MusicAlbum otherwise. This makes ParentId=<artistId>
|
||||
// (no explicit type) browse into that artist's albums, matching the UserViews ->
|
||||
// artists -> albums -> songs hierarchy; callers browsing into an album are expected to
|
||||
// pass IncludeItemTypes=Audio explicitly, as Finamp and Jellyfin's own clients do.
|
||||
func firstType(types string) string {
|
||||
// parseTypes returns every recognized entry in the (possibly comma-separated) IncludeItemTypes,
|
||||
// in the order they appear, defaulting to []string{"MusicAlbum"} when nothing is recognized.
|
||||
// The MusicAlbum default makes ParentId=<artistId> (no explicit type) browse into that artist's
|
||||
// albums, matching the UserViews -> artists -> albums -> songs hierarchy; callers browsing into
|
||||
// an album are expected to pass IncludeItemTypes=Audio explicitly, as Finamp and Jellyfin's own
|
||||
// clients do.
|
||||
func parseTypes(types string) []string {
|
||||
var recognized []string
|
||||
for t := range strings.SplitSeq(types, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
switch t {
|
||||
case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre":
|
||||
return t
|
||||
case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre", "Playlist":
|
||||
recognized = append(recognized, t)
|
||||
}
|
||||
}
|
||||
return "MusicAlbum"
|
||||
if len(recognized) == 0 {
|
||||
return []string{"MusicAlbum"}
|
||||
}
|
||||
return recognized
|
||||
}
|
||||
|
||||
// paginate applies StartIndex/Limit to an already-fetched, in-memory item list. It's only used
|
||||
// for the multi-type merge path; single-type queries push Offset/Max down to the SQL query
|
||||
// instead, which is why this isn't just folded into queryItems.
|
||||
func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto {
|
||||
if offset >= len(items) {
|
||||
return []dto.BaseItemDto{}
|
||||
}
|
||||
items = items[offset:]
|
||||
if limit > 0 && limit < len(items) {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, parentId string, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) {
|
||||
|
|
@ -164,6 +214,25 @@ func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (dto
|
|||
return result(slice.Map(genres, dto.GenreToBaseItem), len(genres), opts.Offset), nil
|
||||
}
|
||||
|
||||
// listPlaylists lists playlists visible to the current user. Unlike albums/songs/artists,
|
||||
// playlists aren't scoped by library — visibility (public, or owned by the current user) is
|
||||
// enforced by persistence's playlistRepository itself, not by scopeIDs here. model.Playlist also
|
||||
// carries no annotations (no starred concept), so a favorites query can never match a playlist;
|
||||
// rather than send Filters=IsFavorite to a repo that doesn't understand it, short-circuit to an
|
||||
// empty result.
|
||||
func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, favOnly bool) (dto.QueryResult, error) {
|
||||
if favOnly {
|
||||
return result(nil, 0, opts.Offset), nil
|
||||
}
|
||||
repo := api.ds.Playlist(ctx)
|
||||
playlists, err := repo.GetAll(opts)
|
||||
if err != nil {
|
||||
return dto.QueryResult{}, err
|
||||
}
|
||||
total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
|
||||
return result(slice.Map(playlists, dto.PlaylistToBaseItem), int(total), opts.Offset), nil
|
||||
}
|
||||
|
||||
// getItem fetches a single entity by id, trying album, artist and song in turn. For albums
|
||||
// and songs (which each belong to exactly one library) it 404s if the current user lacks
|
||||
// access to that library, so an id can't be used to probe content outside the user's libraries.
|
||||
|
|
@ -217,64 +286,68 @@ func result(items []dto.BaseItemDto, total, start int) dto.QueryResult {
|
|||
return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start}
|
||||
}
|
||||
|
||||
// applySort translates Jellyfin's SortBy/SortOrder into a model.QueryOptions sort key valid
|
||||
// for the given item type. Each repository maps different logical fields to different (or
|
||||
// annotation-joined) real columns, so a key valid for one type can be an invalid column for
|
||||
// another (e.g. media_file has no "name" column, only "title"; artist has no "random" column).
|
||||
// An unrecognized SortBy is dropped rather than passed through raw, to avoid an invalid ORDER BY.
|
||||
// applySort translates Jellyfin's SortBy/SortOrder into a model.QueryOptions sort key valid for
|
||||
// the given item type (see sortColumnsByType). Real clients (Finamp included) send SortBy as a
|
||||
// comma-separated list of fallback keys, e.g. "DateCreated,SortName" or
|
||||
// "ParentIndexNumber,IndexNumber,SortName" — this uses the first recognized key and ignores the
|
||||
// rest. An unrecognized SortBy leaves opts.Sort untouched (the repo's own default), rather than
|
||||
// passing it through raw and risking an invalid ORDER BY.
|
||||
func applySort(opts *model.QueryOptions, itemType, sortBy, order string) {
|
||||
switch itemType {
|
||||
case "Audio":
|
||||
switch strings.ToLower(sortBy) {
|
||||
case "sortname", "name":
|
||||
opts.Sort = "title"
|
||||
case "album":
|
||||
opts.Sort = "album"
|
||||
case "albumartist":
|
||||
opts.Sort = "album_artist"
|
||||
case "datecreated":
|
||||
opts.Sort = "recently_added"
|
||||
case "playcount":
|
||||
opts.Sort = "play_count"
|
||||
case "dateplayed":
|
||||
opts.Sort = "play_date"
|
||||
case "random":
|
||||
opts.Sort = "random"
|
||||
}
|
||||
case "MusicArtist":
|
||||
switch strings.ToLower(sortBy) {
|
||||
case "sortname", "name":
|
||||
opts.Sort = "name"
|
||||
case "albumcount":
|
||||
opts.Sort = "album_count"
|
||||
case "songcount":
|
||||
opts.Sort = "song_count"
|
||||
case "datecreated":
|
||||
opts.Sort = "created_at"
|
||||
case "playcount":
|
||||
opts.Sort = "play_count"
|
||||
case "dateplayed":
|
||||
opts.Sort = "play_date"
|
||||
}
|
||||
default: // MusicAlbum
|
||||
switch strings.ToLower(sortBy) {
|
||||
case "sortname", "name", "album":
|
||||
opts.Sort = "name"
|
||||
case "albumartist":
|
||||
opts.Sort = "album_artist"
|
||||
case "datecreated":
|
||||
opts.Sort = "recently_added"
|
||||
case "random":
|
||||
opts.Sort = "random"
|
||||
case "playcount":
|
||||
opts.Sort = "play_count"
|
||||
case "dateplayed":
|
||||
opts.Sort = "play_date"
|
||||
case "premieredate", "productionyear":
|
||||
opts.Sort = "max_year"
|
||||
for key := range strings.SplitSeq(sortBy, ",") {
|
||||
if col, ok := sortColumn(itemType, strings.TrimSpace(key)); ok {
|
||||
opts.Sort = col
|
||||
break
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(order, "Descending") {
|
||||
opts.Order = "desc"
|
||||
}
|
||||
}
|
||||
|
||||
// sortColumnsByType is a lowercased-SortBy -> repo-sort-key table per item type. A map (rather
|
||||
// than a switch per type) keeps this a flat data lookup instead of a large branch, since each
|
||||
// repository maps different logical fields to different (or annotation-joined) real columns —
|
||||
// e.g. media_file has no "name" column, only "title"; artist has no "random" column.
|
||||
var sortColumnsByType = map[string]map[string]string{
|
||||
"Audio": {
|
||||
"sortname": "title", "name": "title",
|
||||
"album": "album",
|
||||
"artist": "artist",
|
||||
"albumartist": "album_artist",
|
||||
"datecreated": "recently_added",
|
||||
"playcount": "play_count",
|
||||
"dateplayed": "play_date",
|
||||
"communityrating": "rating",
|
||||
"random": "random",
|
||||
},
|
||||
"MusicArtist": {
|
||||
"sortname": "name", "name": "name",
|
||||
"albumcount": "album_count",
|
||||
"songcount": "song_count",
|
||||
"datecreated": "created_at",
|
||||
"playcount": "play_count",
|
||||
"dateplayed": "play_date",
|
||||
"communityrating": "rating",
|
||||
},
|
||||
"MusicAlbum": {
|
||||
"sortname": "name", "name": "name", "album": "name",
|
||||
"artist": "artist",
|
||||
"albumartist": "album_artist",
|
||||
"datecreated": "recently_added",
|
||||
"random": "random",
|
||||
"playcount": "play_count",
|
||||
"dateplayed": "play_date",
|
||||
"communityrating": "rating",
|
||||
"premieredate": "max_year", "productionyear": "max_year",
|
||||
},
|
||||
"MusicGenre": {
|
||||
"sortname": "name", "name": "name",
|
||||
},
|
||||
}
|
||||
|
||||
// sortColumn maps a single (non comma-list) Jellyfin SortBy key to the repo sort key for
|
||||
// itemType, reporting false when it isn't recognized for that type.
|
||||
func sortColumn(itemType, sortBy string) (string, bool) {
|
||||
col, ok := sortColumnsByType[itemType][strings.ToLower(sortBy)]
|
||||
return col, ok
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,69 @@ var _ = Describe("Items", func() {
|
|||
Expect(res.Items).NotTo(BeNil())
|
||||
})
|
||||
|
||||
It("lists playlists when IncludeItemTypes=Playlist", func() {
|
||||
ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "p1", Name: "My Mix", SongCount: 5}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser())
|
||||
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("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: "s1", Title: "Song"}})
|
||||
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser())
|
||||
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 a multi-type favorites query (Finamp's favorites screen), skipping playlists (no starred concept)", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}})
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
|
||||
ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "p1", Name: "My Mix"}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser())
|
||||
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"))
|
||||
sql, _, err := albumRepo.Options.Filters.ToSql()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(sql).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: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}})
|
||||
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser())
|
||||
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("applies a starred filter when Filters=IsFavorite", func() {
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
|
||||
|
|
@ -139,6 +202,58 @@ var _ = Describe("Items", func() {
|
|||
Expect(albumRepo.Options.Max).To(Equal(10))
|
||||
})
|
||||
|
||||
Describe("sorting", func() {
|
||||
It("maps SortBy=PlayCount to the play_count column", func() {
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser())
|
||||
api.getItems(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(albumRepo.Options.Sort).To(Equal("play_count"))
|
||||
})
|
||||
|
||||
It("maps SortBy=DatePlayed to the play_date column", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser())
|
||||
api.getItems(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Options.Sort).To(Equal("play_date"))
|
||||
})
|
||||
|
||||
It("uses the first recognized key in a comma-separated SortBy list", func() {
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser())
|
||||
api.getItems(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(albumRepo.Options.Sort).To(Equal("recently_added"))
|
||||
})
|
||||
|
||||
It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() {
|
||||
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser())
|
||||
api.getItems(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(mfRepo.Options.Sort).To(Equal("title"))
|
||||
})
|
||||
|
||||
It("leaves Sort at the repo default when no SortBy key is recognized", func() {
|
||||
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
||||
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser())
|
||||
api.getItems(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(albumRepo.Options.Sort).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ type MockPlaylistRepo struct {
|
|||
model.PlaylistRepository
|
||||
Data map[string]*model.Playlist // keyed by ID
|
||||
PathMap map[string]*model.Playlist // keyed by path
|
||||
All model.Playlists
|
||||
Options model.QueryOptions
|
||||
Last *model.Playlist
|
||||
Deleted []string
|
||||
Err bool
|
||||
|
|
@ -29,6 +31,25 @@ func (m *MockPlaylistRepo) SetError(err bool) {
|
|||
m.Err = err
|
||||
}
|
||||
|
||||
// SetData seeds both Get-by-id lookups (Data) and GetAll's result set (All).
|
||||
func (m *MockPlaylistRepo) SetData(playlists model.Playlists) {
|
||||
m.Data = make(map[string]*model.Playlist, len(playlists))
|
||||
m.All = playlists
|
||||
for i, p := range m.All {
|
||||
m.Data[p.ID] = &m.All[i]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
|
||||
if len(options) > 0 {
|
||||
m.Options = options[0]
|
||||
}
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
return m.All, nil
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue