navidrome/server/jellyfin/e2e/browsing_test.go
Deluan Quintão 16567f147b
fix(jellyfin): match Jellyfin on login SessionInfo, item types and universal streams (#6161)
* fix(jellyfin): send SessionInfo on login so JellyBox gets past sign-in

JellyBox parses AuthenticateByName's SessionInfo as a required object and
fails silently when it is missing, leaving the user on the login screen.
Real Jellyfin always sends it (SessionManager.AuthenticateNewSessionInternal,
10.10.7 and master), so the login response now carries a full SessionInfo
built from the user and the MediaBrowser auth header. It includes every field
JellyBox (Id, PlayState) and Finamp (UserId, LastActivityDate, the activity
and control bools, PlayState's CanSeek/IsPaused/IsMuted) require once the
object is present. The session Id is derived from client and device id, so
repeated logins from one install share it.

* fix(jellyfin): ignore IncludeItemTypes names that aren't Jellyfin kinds

JellyBox opens an album with ParentId=<album>&IncludeItemTypes=music. Music
is not a BaseItemKind, and Jellyfin's comma-delimited binder drops values it
cannot parse, so real Jellyfin treats the request as having no type filter and
lists the album's tracks. Navidrome returned an empty list, so every album
opened empty. Entries that aren't BaseItemKind names are now dropped before
type resolution, so an all-unknown list behaves like an absent one. Real kinds
Navidrome doesn't serve, such as Boxset, still return nothing.

* fix(jellyfin): treat universal Container as the direct-play list

On /Audio/{id}/universal, Container lists the "container|codec" entries the
client can direct play, and TranscodingContainer/AudioCodec name the target
when it can't (UniversalAudioController builds DirectPlayProfiles from it).
Navidrome passed the whole list to the decider as one target format, which
matched nothing and fell back to DefaultDownsamplingFormat, so JellyBox got
every MP3 transcoded to Opus. /universal now has its own handler: a source
matching an entry keeps its format (still downsampled under a bitrate cap),
anything else is transcoded to TranscodingContainer, then AudioCodec. The
/stream routes keep treating Container as the target format.

* refactor(jellyfin): let the stream decider resolve universal requests

streamUniversal matched the Container list itself with plain string equality
and then asked the legacy resolver for the source format. That skipped the
decider's container and codec aliases (mp4 vs m4a, ogg vs opus), and a
direct-playable source over the bitrate cap was transcoded to its own format
instead of the client's TranscodingContainer.

The shared part of ResolveRequest (server-side player override, player
MaxBitRate cap, decision to Request mapping) moves to a resolve helper, and a
new ResolveClientRequest exposes it for callers that build their own
ClientInfo. streamUniversal now turns Container into DirectPlayProfiles and
TranscodingContainer/AudioCodec into a transcoding profile, so the decision
uses the same rules as the Subsonic getTranscodeDecision path. streamFile and
the /stream routes share a serveStream helper, NewSessionInfo reads the clock
itself, and duplicate comments and tests are trimmed.
2026-09-17 23:48:39 -04:00

701 lines
30 KiB
Go

package e2e
import (
"encoding/json"
"net/http"
"slices"
"sort"
"time"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func names(items []dto.BaseItemDto) []string {
out := make([]string, len(items))
for i, it := range items {
out[i] = it.Name
}
return out
}
var _ = Describe("Browsing", func() {
BeforeEach(func() { setupTestDB() })
Describe("GET /UserViews", func() {
It("returns the user's libraries as CollectionFolders", func() {
q := queryResult(get("/UserViews"))
Expect(q.TotalRecordCount).To(Equal(1))
Expect(q.Items[0].Name).To(Equal("Music Library"))
Expect(q.Items[0].Type).To(Equal("CollectionFolder"))
Expect(q.Items[0].CollectionType).To(Equal("music"))
})
})
Describe("GET /Items by type", func() {
It("lists all albums", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(5))
Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles"))
})
It("lists all songs with Audio type and an AlbumId", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(7))
for _, it := range q.Items {
Expect(it.Type).To(Equal("Audio"))
Expect(it.MediaType).To(Equal("Audio"))
Expect(it.LocationType).To(Equal("FileSystem"))
Expect(it.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) // real Jellyfin always sets it, no-dash GUID form
Expect(it.AlbumId).ToNot(BeEmpty())
}
})
// Real Jellyfin omits MediaSources from a plain list response, returning it only when the
// client asks via Fields=MediaSources (Finamp's download dialog does).
It("omits MediaSources unless Fields=MediaSources is requested", func() {
plain := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true"))
for _, it := range plain.Items {
Expect(it.MediaSources).To(BeEmpty())
}
withSources := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=MediaSources"))
for _, it := range withSources.Items {
Expect(it.MediaSources).To(HaveLen(1))
}
})
// Clients (Finamp, Feishin) send Fields as repeated params rather than one comma-separated
// value; real Jellyfin accepts both, so a later Fields=MediaSources must still take effect.
It("honors MediaSources when Fields is sent as repeated params", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=Genres&Fields=MediaSources"))
Expect(q.Items).ToNot(BeEmpty())
for _, it := range q.Items {
Expect(it.MediaSources).To(HaveLen(1))
}
})
It("lists all album artists", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(4))
Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist"))
})
// Finamp's A-Z jump scans SortName client-side, so it must follow the response order.
It("returns artists' SortName matching the server sort order when Fields=SortName", func() {
plain := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName"))
for _, it := range plain.Items {
Expect(it.SortName).To(BeEmpty())
}
q := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName&Fields=SortName"))
Expect(q.Items).ToNot(BeEmpty())
sortNames := make([]string, 0, len(q.Items))
for _, it := range q.Items {
Expect(it.SortName).ToNot(BeEmpty())
sortNames = append(sortNames, it.SortName)
}
Expect(slices.IsSorted(sortNames)).To(BeTrue(), "SortName values must follow the response order: %v", sortNames)
// "The Beatles" must be filed under B, exposing the article-stripped key to clients.
Expect(sortNames).To(ContainElement("beatles"))
})
It("lists all genres", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(3))
Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop"))
})
It("returns no playlists when none exist", func() {
q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(0))
Expect(q.Items).To(BeEmpty())
})
// Manet syncs collections as Boxset, and took albums coming back instead as a sync failure.
DescribeTable("returns nothing for a type it does not serve",
func(itemType string) {
q := queryResult(get("/Items?IncludeItemTypes=" + itemType + "&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(0))
Expect(q.Items).To(BeEmpty())
},
Entry("a Jellyfin kind Navidrome has none of", "Boxset"),
)
It("treats a name Jellyfin does not know as an absent IncludeItemTypes", func() {
q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(queryResult(get("/Items?Recursive=true")).TotalRecordCount))
Expect(q.TotalRecordCount).To(BeNumerically(">", 0))
})
// A strict client (Manet) fails its whole sync on the first item missing any of these.
DescribeTable("sends the keys Jellyfin puts on every item",
func(itemType string, fields string, nonNull ...string) {
var body struct{ Items []map[string]json.RawMessage }
res := get("/Items?IncludeItemTypes=" + itemType + "&Fields=" + fields + "&Recursive=true")
Expect(json.Unmarshal(res.Body.Bytes(), &body)).To(Succeed())
Expect(body.Items).ToNot(BeEmpty())
for _, it := range body.Items {
Expect(it).To(HaveKey("ChannelId"), "ChannelId is null, but always present")
for _, k := range nonNull {
Expect(it).To(HaveKey(k))
Expect(string(it[k])).ToNot(Equal("null"), k)
}
}
},
Entry("songs", "Audio", "Genres,Tags", "ImageTags", "HasLyrics", "Genres", "GenreItems", "Tags"),
Entry("albums", "MusicAlbum", "Genres", "ImageTags", "Genres", "GenreItems"),
Entry("artists", "MusicArtist", "Genres", "ImageTags", "Genres", "GenreItems"),
)
It("sends MediaType Unknown on items without one, as Jellyfin always emits it", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true"))
Expect(q.Items).ToNot(BeEmpty())
for _, it := range q.Items {
Expect(it.MediaType).To(Equal("Unknown"))
}
Expect(queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")).Items[0].MediaType).To(Equal("Audio"))
})
})
Describe("ParentId browsing", func() {
It("browses an artist's albums", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + enc(artistID("The Beatles"))))
Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!"))
})
It("browses an album's tracks in track order by default", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road"))))
Expect(q.TotalRecordCount).To(Equal(2))
// Track order (Something=1, Come Together=2) differs from alphabetical title order,
// proving the sort is by track number, not name.
Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"}))
Expect(*q.Items[0].IndexNumber).To(Equal(1))
Expect(*q.Items[1].IndexNumber).To(Equal(2))
})
// "Latest Releases": if PremiereDate isn't recognized, applySort falls through to album-name order.
It("sorts an artist's tracks by release year for SortBy=PremiereDate (Latest Releases)", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumArtistIds=" + enc(artistID("The Beatles")) +
"&SortBy=PremiereDate%2CAlbum%2CParentIndexNumber%2CIndexNumber%2CSortName&SortOrder=Descending"))
got := names(q.Items)
Expect(got).To(HaveLen(3))
Expect(got[:2]).To(ConsistOf("Come Together", "Something"))
Expect(got[2]).To(Equal("Help!"))
})
It("respects Finamp's explicit ParentIndexNumber/IndexNumber SortBy on an album", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&SortBy=ParentIndexNumber,IndexNumber,SortName"))
Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"}))
})
})
// Finamp's download sync asks a library for the tracks outside any album this way; answering
// with every track would stream the whole library.
Describe("Recursive=false", func() {
lib1 := dto.EncodeLibraryID(1)
It("returns no songs for a library parent", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false"))
Expect(q.Items).To(BeEmpty())
Expect(q.TotalRecordCount).To(BeZero())
})
It("still lists the library's albums", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + lib1 + "&Recursive=false"))
Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles"))
})
It("still lists an album's tracks", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&Recursive=false"))
Expect(names(q.Items)).To(ConsistOf("Come Together", "Something"))
})
})
// Finamp's artist screen sends ParentId=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds
// for the actual artist filter, not ParentId=<artistId>.
Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() {
lib1 := dto.EncodeLibraryID(1)
It("filters albums by AlbumArtistIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles"))))
Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!"))
})
It("filters songs by ArtistIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(artistID("The Beatles"))))
Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!"))
})
It("filters albums by a single-album artist", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&AlbumArtistIds=" + enc(artistID("Led Zeppelin"))))
Expect(names(q.Items)).To(ConsistOf("IV"))
})
It("filters songs by a single-track artist", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("Miles Davis"))))
Expect(names(q.Items)).To(ConsistOf("So What"))
})
// contributingArtistIds is Jellify's "Featured On" section: albums the artist only appears
// on, which must exclude their own discography (albums where they are the album artist).
It("lists Featured On albums (contributingArtistIds) a performer only guests on", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("Featured Guest"))))
Expect(names(q.Items)).To(ConsistOf("Singles"))
})
It("excludes an album artist's own discography from Featured On (contributingArtistIds)", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("The Beatles"))))
Expect(names(q.Items)).ToNot(ContainElement("Abbey Road"))
Expect(names(q.Items)).ToNot(ContainElement("Help!"))
})
})
// A malformed id must 404, not silently drop the filter and widen the query to the whole
// library; a well-formed but unknown one must still fail closed to zero results.
Describe("stale and malformed id filtering", func() {
lib1 := dto.EncodeLibraryID(1)
It("404s a malformed ParentId instead of listing every song", func() {
w := get("/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero songs, not the whole library, for a well-formed but unknown ParentId", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(testID("no-such-album"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed AlbumArtistIds instead of listing every album", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero albums, not every album, for a well-formed but unknown AlbumArtistIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(testID("no-such-artist"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed ArtistIds instead of listing every song", func() {
w := get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero songs, not every song, for a well-formed but unknown ArtistIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(testID("no-such-artist"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed Ids entry instead of batch-fetching nothing", func() {
w := get("/Items?Ids=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s when only one of several Ids entries is malformed", func() {
w := get("/Items?Ids=" + enc(songID("Something")) + ",not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero items, not an error, for a well-formed but unknown Ids entry", func() {
q := queryResult(get("/Items?Ids=" + enc(testID("no-such-item"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed GenreIds entry instead of listing every album", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s when only one of several GenreIds entries is malformed", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + ",not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s a malformed AlbumIds entry instead of listing every song", func() {
w := get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s a malformed StudioIds entry instead of listing every album", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero albums, not every album, for a well-formed but unknown StudioIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + enc(testID("no-such-studio"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
})
// Feishin fetches an album's tracks with AlbumIds=<albumId>&IncludeItemTypes=Audio&Recursive=true.
Describe("album filtering (AlbumIds)", func() {
It("filters songs by AlbumIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road"))))
Expect(names(q.Items)).To(ConsistOf("Come Together", "Something"))
Expect(q.TotalRecordCount).To(Equal(2))
})
It("matches any of multiple comma-separated AlbumIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")) + "," + enc(albumID("IV"))))
Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Stairway To Heaven"))
Expect(q.TotalRecordCount).To(Equal(3))
})
It("returns nothing for an unknown album id", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(testID("no-such-album"))))
Expect(q.Items).To(BeEmpty())
Expect(q.TotalRecordCount).To(Equal(0))
})
})
Describe("year filtering (Years=)", func() {
It("filters items by Years=", func() {
albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Years=1959"))
Expect(names(albums.Items)).To(ConsistOf("Kind of Blue"))
songs := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Years=1959"))
for _, it := range songs.Items {
Expect(it.ProductionYear).ToNot(BeNil())
Expect(*it.ProductionYear).To(Equal(1959))
}
Expect(songs.Items).ToNot(BeEmpty())
})
})
Describe("studio filtering (StudioIds=)", func() {
It("filters items by StudioIds=", func() {
studios := queryResult(get("/Studios"))
var columbiaID string
for _, it := range studios.Items {
if it.Name == "Columbia" {
columbiaID = it.Id
}
}
Expect(columbiaID).ToNot(BeEmpty())
albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + columbiaID))
Expect(names(albums.Items)).To(ConsistOf("Kind of Blue"))
})
It("returns filter lists scoped to a ParentId library", func() {
var filters dto.QueryFiltersLegacy
parseInto(get("/Items/Filters?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=true"), &filters)
Expect(filters.Years).To(ContainElements(1959, 1965))
studios := queryResult(get("/Studios?ParentId=" + dto.EncodeLibraryID(1)))
Expect(names(studios.Items)).To(ContainElement("Columbia"))
})
})
// Finamp's genre screen sends ParentId=<libraryId> (scoping) plus GenreIds=<genreId>.
Describe("genre filtering (GenreIds)", func() {
lib1 := dto.EncodeLibraryID(1)
It("filters albums by GenreIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz"))))
Expect(names(q.Items)).To(ConsistOf("Kind of Blue"))
Expect(q.TotalRecordCount).To(Equal(1))
})
It("filters songs by GenreIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Rock"))))
Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!", "Stairway To Heaven"))
Expect(q.TotalRecordCount).To(Equal(4))
})
It("matches any of multiple comma-separated GenreIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop"))))
Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles"))
})
It("matches any of multiple repeated GenreIds params (@jellyfin/sdk spelling)", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "&GenreIds=" + enc(genreID("Pop"))))
Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles"))
})
It("returns nothing for an unknown genre id", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(testID("no-such-genre"))))
Expect(q.Items).To(BeEmpty())
Expect(q.TotalRecordCount).To(Equal(0))
})
It("filters album artists by GenreIds on /Artists/AlbumArtists", func() {
q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz"))))
Expect(names(q.Items)).To(ConsistOf("Miles Davis"))
Expect(q.TotalRecordCount).To(Equal(1))
})
It("matches album artists of any of multiple GenreIds", func() {
q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop"))))
Expect(names(q.Items)).To(ConsistOf("Miles Davis", "Solo Artist"))
})
It("filters album artists by GenreIds via /Items?IncludeItemTypes=MusicArtist", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true&GenreIds=" + enc(genreID("Rock"))))
Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin"))
})
It("returns no artists for an unknown genre id", func() {
q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(testID("no-such-genre"))))
Expect(q.Items).To(BeEmpty())
})
})
// Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase
// (parentId, includeItemTypes, albumArtistIds), where Finamp sends PascalCase. Real Jellyfin
// binds them case-insensitively; these guard that our dispatcher does too, and that browsing an
// album with only parentId (no IncludeItemTypes, as Jellify does) returns its tracks.
Describe("camelCase query params (Jellify / JS SDK)", func() {
lib1 := dto.EncodeLibraryID(1)
It("filters albums by camelCase albumArtistIds", func() {
q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles"))))
Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!"))
})
It("filters songs by camelCase artistIds", func() {
q := queryResult(get("/Items?includeItemTypes=Audio&recursive=true&parentId=" + lib1 + "&artistIds=" + enc(artistID("The Beatles"))))
Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!"))
})
It("browses an album's tracks with only camelCase parentId (no IncludeItemTypes)", func() {
q := queryResult(get("/Items?parentId=" + enc(albumID("Abbey Road")) + "&sortBy=ParentIndexNumber&sortBy=IndexNumber&sortBy=SortName"))
Expect(q.TotalRecordCount).To(Equal(2))
Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"}))
})
It("browses an artist's albums with only camelCase parentId (no IncludeItemTypes)", func() {
q := queryResult(get("/Items?parentId=" + enc(artistID("The Beatles"))))
Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!"))
})
})
Describe("search, batch and pagination", func() {
It("searches albums by term", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey"))
Expect(names(q.Items)).To(ContainElement("Abbey Road"))
})
It("batch-fetches specific items by Ids", func() {
ids := enc(albumID("Abbey Road")) + "," + enc(albumID("IV"))
q := queryResult(get("/Items?ids=" + ids))
Expect(q.TotalRecordCount).To(Equal(2))
Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV"))
})
It("applies Limit while reporting the full TotalRecordCount", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2"))
Expect(q.Items).To(HaveLen(2))
Expect(q.TotalRecordCount).To(Equal(5))
})
It("pages distinct items via StartIndex", func() {
p1 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=0"))
p2 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=2"))
Expect(p1.Items).To(HaveLen(2))
Expect(p2.Items).To(HaveLen(2))
Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items))))
})
It("merges multiple types into one paginated result", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(12)) // 5 albums + 7 songs
})
// Chaining the per-type cursors must preserve the merged order.
It("streams an unbounded multi-type merge, honoring StartIndex", func() {
all := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true"))
Expect(all.Items).To(HaveLen(12))
skipped := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true&StartIndex=2"))
Expect(skipped.Items).To(HaveLen(10))
Expect(skipped.TotalRecordCount).To(Equal(12))
Expect(skipped.StartIndex).To(Equal(2))
Expect(names(skipped.Items)).To(Equal(names(all.Items)[2:]))
})
// Paging must ride on the cursor query's LIMIT/OFFSET, not be applied after materializing.
It("pages songs via StartIndex/Limit while reporting the full total", func() {
all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName"))
Expect(all.TotalRecordCount).To(Equal(7))
Expect(all.Items).To(HaveLen(7))
p1 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=0"))
p2 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=3"))
Expect(p1.Items).To(HaveLen(3))
Expect(p2.Items).To(HaveLen(3))
Expect(p1.TotalRecordCount).To(Equal(7))
// The two pages are distinct and match the head of the unpaged, identically-sorted list.
Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items))))
Expect(append(names(p1.Items), names(p2.Items)...)).To(Equal(names(all.Items)[:6]))
})
})
Describe("GET /Items/{id}", func() {
It("resolves an album", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(albumID("Kind of Blue"))), &item)
Expect(item.Name).To(Equal("Kind of Blue"))
Expect(item.Type).To(Equal("MusicAlbum"))
})
It("resolves a song", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("So What"))), &item)
Expect(item.Type).To(Equal("Audio"))
})
It("includes a parseable DateCreated (Date Added) on a song", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("So What"))), &item)
Expect(item.DateCreated).ToNot(BeEmpty())
_, err := time.Parse(time.RFC3339, item.DateCreated)
Expect(err).ToNot(HaveOccurred())
})
It("includes structured ArtistItems and AlbumArtists on a song (now-playing artist)", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("So What"))), &item)
Expect(item.ArtistItems).ToNot(BeEmpty())
Expect(item.ArtistItems[0].Name).To(Equal("Miles Davis"))
Expect(item.ArtistItems[0].Id).ToNot(BeEmpty())
Expect(item.AlbumArtists).ToNot(BeEmpty())
Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis"))
})
It("exposes NormalizationGain and AlbumNormalizationGain from ReplayGain tags", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &item)
Expect(item.NormalizationGain).ToNot(BeNil())
Expect(*item.NormalizationGain).To(BeNumerically("~", -3.5, 0.001))
Expect(item.AlbumNormalizationGain).ToNot(BeNil())
Expect(*item.AlbumNormalizationGain).To(BeNumerically("~", -4.25, 0.001))
})
It("omits normalization gains for files without ReplayGain tags", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("So What"))), &item)
Expect(item.NormalizationGain).To(BeNil())
Expect(item.AlbumNormalizationGain).To(BeNil())
})
It("resolves an artist", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item)
Expect(item.Type).To(Equal("MusicArtist"))
})
It("returns 404 for an unknown id", func() {
Expect(get("/Items/" + enc(testID("does-not-exist"))).Code).To(Equal(http.StatusNotFound))
})
})
Describe("GET /Users/{userId}/Items/Latest", func() {
It("returns recent albums as a bare array, respecting Limit", func() {
var items []dto.BaseItemDto
parseInto(get("/Users/admin-1/Items/Latest?Limit=3"), &items)
Expect(items).To(HaveLen(3))
for _, it := range items {
Expect(it.Type).To(Equal("MusicAlbum"))
}
})
})
Describe("GET /Items/Latest", func() {
// Jellyfin marks the /Users/{userId} form obsolete and hides it from the OpenAPI spec, so
// SDK-generated clients (Jellify) only ever call this one.
It("serves the same response as the legacy /Users/{userId} route", func() {
Expect(get("/Items/Latest?Limit=3").Body.String()).
To(Equal(get("/Users/admin-1/Items/Latest?Limit=3").Body.String()))
})
It("scopes to ParentId when it names a library", func() {
var items []dto.BaseItemDto
parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(1)), &items)
Expect(names(items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles"))
})
It("scopes to ParentId when it names an artist", func() {
var items []dto.BaseItemDto
parseInto(get("/Items/Latest?ParentId="+enc(artistID("The Beatles"))), &items)
Expect(names(items)).To(ConsistOf("Abbey Road", "Help!"))
})
It("returns nothing for a library the user cannot access", func() {
var items []dto.BaseItemDto
parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(99)), &items)
Expect(items).To(BeEmpty())
})
It("returns nothing for an id that is neither a library nor an artist", func() {
var items []dto.BaseItemDto
parseInto(get("/Items/Latest?ParentId="+enc(testID("does-not-exist"))), &items)
Expect(items).To(BeEmpty())
})
It("404s a malformed ParentId, like every other filtered endpoint", func() {
Expect(get("/Items/Latest?ParentId=not-a-valid-id").Code).To(Equal(http.StatusNotFound))
})
})
Describe("GET /Artists and /Genres", func() {
It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() {
names := names(queryResult(get("/Artists/AlbumArtists")).Items)
Expect(names).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist"))
Expect(names).ToNot(ContainElement("Featured Guest"))
})
It("lists performing artists on /Artists (includes a track's guest artist)", func() {
names := names(queryResult(get("/Artists")).Items)
Expect(names).To(ContainElement("Featured Guest"))
Expect(names).To(ContainElement("Solo Artist"))
})
It("returns different lists for album artists and performing artists", func() {
aa := names(queryResult(get("/Artists/AlbumArtists")).Items)
ar := names(queryResult(get("/Artists")).Items)
Expect(aa).ToNot(Equal(ar))
})
It("lists genres", func() {
q := queryResult(get("/Genres"))
Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop"))
})
It("pages genres with StartIndex/Limit and still reports the full total", func() {
q := queryResult(get("/Genres?StartIndex=1&Limit=1"))
Expect(q.Items).To(HaveLen(1))
Expect(q.TotalRecordCount).To(Equal(3))
})
It("returns record labels as Studio items", func() {
q := queryResult(get("/Studios"))
names := make([]string, 0, len(q.Items))
for _, it := range q.Items {
Expect(it.Type).To(Equal("Studio"))
names = append(names, it.Name)
}
Expect(names).To(ContainElement("Columbia"))
})
})
Describe("GET /Items/Filters", func() {
It("returns legacy query filters with genres, years, and empty tags/ratings", func() {
var filters dto.QueryFiltersLegacy
parseInto(get("/Items/Filters?IncludeItemTypes=Audio&Recursive=true"), &filters)
Expect(filters.Genres).To(ContainElements("Rock", "Jazz"))
Expect(filters.Years).To(ContainElements(1959, 1965, 1969, 1971))
// Verify ascending sort by checking it equals itself sorted.
sorted := make([]int, len(filters.Years))
copy(sorted, filters.Years)
sort.Ints(sorted)
Expect(filters.Years).To(Equal(sorted))
Expect(filters.Tags).To(BeEmpty())
Expect(filters.OfficialRatings).To(BeEmpty())
})
})
})