mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* test(jellyfin): use canonical ids in dto fixtures
Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape.
* test(jellyfin): use canonical ids in handler fixtures
Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape. Playlist entry positions
stay decimal, matching the integer playlist_tracks.id column.
* test(jellyfin): use canonical ids in e2e fixtures
Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from
id.NewHash keeps the labels readable while exercising the real id shape.
* test(jellyfin): use canonical ids in audiomuse fixtures
audiomuse_test.go passes ids as bare function args (mf(id, ...), call(query, user)) rather than
via ID: struct-literal fields, so the original grep-built file list missed it. Same conversion as
the rest of the fixtures: fake labels through id.NewHash via testID.
* test(jellyfin): convert remaining nonexistent-id sentinels in e2e tests
Reviewer swept for enc("literal") sites the brief's dto.EncodeID grep missed. These "does not
exist" fixtures must stay well-formed GUIDs under the strict codec, or the test degrades from
"resolves to nothing" to "empty path segment".
* refactor(jellyfin): emit real 128-bit GUIDs as item ids
Navidrome ids are now a canonical 22-char base62 encoding of exactly 128 bits, so they map
losslessly onto Jellyfin GUIDs. Previously the API hex-encoded the id string itself, producing
44 hex chars where Jellyfin uses 32.
Integer library ids, the synthetic playlists folder, and playlist entry positions (a
playlist_tracks.id, an integer column) aren't 128-bit values, so they get a reserved GUID space
tagged by kind. DecodeID is now strict: malformed input returns an empty string instead of
passing through unchanged.
BREAKING: Jellyfin clients see entirely new item ids.
* fix(jellyfin): 404 malformed playlist ids instead of silently creating
updatePlaylist decoded a malformed playlistId to "", the same sentinel core/playlists.Create
uses to mean "make a new playlist" — the overload createPlaylist deliberately relies on. A
malformed id now 404s before reaching Create.
Also tightens id-codec test fixtures: several tests set chi params to a raw canonical id, which
now decodes to "" and only passed because the fakes ignore the id argument; and a batch of
not-found sentinels now use well-formed-but-nonexistent GUIDs so they exercise the intended path
instead of the malformed-id path. READMEs "lossless" claim softened to note the reserved space.
* refactor(jellyfin): drop the id truncation workaround
Finamp's saved-queue packing keeps the first 16 bytes of each item id. That was lossy only
because our ids were 44 hex chars; now they are 32, so the packing round-trips exactly and the
server-side prefix recovery is dead code.
Removes an indexed range scan per restored queue and the ambiguous-prefix path that could
resolve to the wrong item.
* fix(jellyfin): emit ServerId and PlaySessionId in Jellyfin's id format
Jellyfin serializes GUIDs without dashes; ServerId was emitting the dashed UUID form. A
ServerId persisted before this change is normalized on read rather than rewritten.
PlaySessionId was emitting a raw internal id instead of the encoded form.
BREAKING: the ServerId change makes clients treat the server as new, so users re-login once.
* fix(jellyfin): 404 on undecodable id filters instead of widening the query
DecodeID collapsed an absent param and an undecodable one into the empty string, and downstream
an empty id means no filter. A client sending a stale pre-upgrade id therefore had its filter
silently dropped: ParentId, ArtistIds and AlbumArtistIds each returned the whole library instead
of a scoped result. Every existing client hits this on first launch after the id format changes.
Scalar id params now distinguish the two cases and report not-found. List-valued params already
failed closed. EncodeID logs a diagnostic when a non-empty id is not canonical, which should not
happen post-migration and would otherwise ship an unaddressable item silently.
* test(jellyfin): drop comments that restate the spec names
* refactor(jellyfin): decode reserved GUIDs from bytes, not hex strings
DecodeID already had the 16 decoded bytes, then re-derived the kind tag and payload by slicing the
hex string and parsing it a second time. Reading them off the byte slice matches how the format is
specified and removes the duplicate parse.
Bounding the payload inside encodeReserved gives both encoders the 32-char guarantee, which only
EncodePlaylistEntryID enforced before.
Playlist entries now decode through DecodePlaylistEntryID, which rejects other kinds. The tag was
being encoded and then discarded, so a song id passed as an EntryId reached RemoveTracks as a
playlist_tracks position.
Drops the per-field log.Warn from EncodeID: it sat in a leaf codec without a ctx and would emit
once per item per request on exactly the bad-data population it was meant to surface.
* refactor(jellyfin): make DecodeID report whether the id was decodable
DecodeID returned the empty string for both an absent param and an undecodable one, and
downstream an empty id means no filter. That conflation is what let a stale id widen /Items to
the whole library; it had been patched at two call sites, leaving three different policies for an
undecodable id in one package and ~16 handlers correct only because a repo Get("") happens to fail.
Returning (string, bool) makes the ambiguity unrepresentable, and the compiler forces each of the
~22 sites to decide. URL params share one itemIDParam helper that 404s; id lists go through
DecodeIDs, which is all-or-nothing because dropping bad entries would empty a list and make its
len() > 0 filter gate vanish — the original bug by another route.
A well-formed but unknown id is still 200 with zero results; only malformed ids 404. Malformed
ids now also 404 on the image and similar/instant-mix routes, which previously answered with a
placeholder or an empty list.
623 lines
27 KiB
Go
623 lines
27 KiB
Go
package e2e
|
|
|
|
import (
|
|
"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())
|
|
})
|
|
|
|
It("defaults to albums when IncludeItemTypes is unrecognized", func() {
|
|
q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true"))
|
|
Expect(q.TotalRecordCount).To(Equal(5))
|
|
})
|
|
})
|
|
|
|
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 /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())
|
|
})
|
|
})
|
|
})
|