🏡 index : quine-global/quintodrome.git

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())
		})
	})
})