🏡 index : quine-global/quintodrome.git

package jellyfin

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"

	"github.com/go-chi/chi/v5"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/server/jellyfin/dto"
	"github.com/navidrome/navidrome/tests"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

// withChiURLParam simulates chi's routing having captured path parameters (key, value pairs), since
// these tests call handlers directly instead of going through the full router.
func withChiURLParam(r *http.Request, kv ...string) *http.Request {
	rctx := chi.NewRouteContext()
	for i := 0; i+1 < len(kv); i += 2 {
		rctx.URLParams.Add(kv[i], kv[i+1])
	}
	return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}

var _ = Describe("Items", func() {
	var api *Router
	var ds *tests.MockDataStore
	var fp *fakePlaylists
	// alice has access to library 1 only; used by tests that don't care about scoping.
	ctxUser := func() context.Context {
		return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
	}
	ctxUserWithLibraries := func(libs model.Libraries) context.Context {
		return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs})
	}
	// admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership.
	ctxAdmin := func() context.Context {
		return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil})
	}
	BeforeEach(func() {
		ds = &tests.MockDataStore{}
		fp = &fakePlaylists{}
		api = &Router{ds: ds, playlists: fp}
	})

	Describe("getItems", func() {
		It("lists albums when IncludeItemTypes=MusicAlbum", func() {
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(2))
			Expect(res.Items[0].Type).To(Equal("MusicAlbum"))
			Expect(res.TotalRecordCount).To(Equal(2))
		})

		It("lists an album's songs when ParentId is an album and type is Audio", func() {
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(1))
			Expect(res.Items[0].Type).To(Equal("Audio"))
			Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
		})

		It("ignores IncludeItemTypes names that aren't Jellyfin item kinds", func() {
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=music", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(1))
			Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
		})

		It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() {
			fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{
				{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
				{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
			}}
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&IncludeItemTypes=Audio", nil).
				WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(2))
			Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
			Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1")))
			Expect(res.TotalRecordCount).To(Equal(2))
		})

		It("pages a playlist parent's tracks in the query, not in memory", func() {
			fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{
				{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
				{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
				{ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}},
			}}
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&StartIndex=1&Limit=1", nil).
				WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.TotalRecordCount).To(Equal(3))
			Expect(res.Items).To(HaveLen(1))
			Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2"))))
			Expect(fp.tracksRepo.Options.Offset).To(Equal(1))
			Expect(fp.tracksRepo.Options.Max).To(Equal(1))
		})

		It("falls through to the type dispatch when ParentId is not a playlist", func() {
			fp.getErr = model.ErrNotFound
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).
				WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(1))
			Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
		})

		It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() {
			ds.MediaFile().(*tests.MockMediaFileRepo).SetError(true)
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=true", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusInternalServerError))
		})

		// Recursive=false asks for direct children only. Finamp's sync probes a library this way
		// looking for tracks outside any album; answering with every track streams the whole library.
		Describe("Recursive=false", func() {
			BeforeEach(func() {
				ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
				ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}})
			})

			It("returns no songs for a library parent, as tracks are never its direct children", func() {
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=false", nil).
					WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(BeEmpty())
				Expect(res.TotalRecordCount).To(BeZero())
			})

			It("drops only Audio from a multi-type library query", func() {
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil).
					WithContext(ctxUser())
				invoke(api.getItems, w, r)
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(1))
				Expect(res.Items[0].Type).To(Equal("MusicAlbum"))
			})

			It("still lists albums for a library parent, as they are its direct children", func() {
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil).
					WithContext(ctxUser())
				invoke(api.getItems, w, r)
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(1))
			})

			It("still lists an album's tracks, as they are its direct children", func() {
				fp.getErr = model.ErrNotFound
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio&Recursive=false", nil).
					WithContext(ctxUser())
				invoke(api.getItems, w, r)
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(1))
				Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
			})

			It("keeps returning every song when no parent scopes the query", func() {
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=false", nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(1))
			})

			// Jellyfin's own default: ItemsController binds `bool? recursive` and reads it as
			// `recursive ?? false`, so an omitted Recursive is a non-recursive request.
			It("treats an omitted Recursive as false, like Jellyfin", func() {
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio", nil).
					WithContext(ctxUser())
				invoke(api.getItems, w, r)
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(BeEmpty())
			})
		})

		It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() {
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", AlbumArtistID: testID("ar1")}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("ar1"))+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(1))
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			sql, _, err := albumRepo.Options.Filters.ToSql()
			Expect(err).NotTo(HaveOccurred())
			Expect(sql).To(ContainSubstring("album_artists"))
		})

		It("lists artists when IncludeItemTypes=MusicArtist", func() {
			ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(1))
			Expect(res.Items[0].Type).To(Equal("MusicArtist"))
		})

		It("lists genres when IncludeItemTypes=MusicGenre", func() {
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicGenre", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).NotTo(BeNil())
		})

		It("lists playlists when IncludeItemTypes=Playlist", func() {
			ds.Playlist().(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", SongCount: 5}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(1))
			Expect(res.Items[0].Type).To(Equal("Playlist"))
			Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("p1"))))
			Expect(res.TotalRecordCount).To(Equal(1))
		})

		It("merges results from every requested type in IncludeItemTypes", func() {
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(2))
			types := []string{res.Items[0].Type, res.Items[1].Type}
			Expect(types).To(ConsistOf("Audio", "MusicAlbum"))
			Expect(res.TotalRecordCount).To(Equal(2))
		})

		It("merges favorite songs, albums, and playlists", func() {
			mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
			mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo)
			playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", Annotations: model.Annotations{Starred: true}}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(3))
			types := []string{res.Items[0].Type, res.Items[1].Type}
			types = append(types, res.Items[2].Type)
			Expect(types).To(ConsistOf("Audio", "MusicAlbum", "Playlist"))
			sql, _, err := albumRepo.Options.Filters.ToSql()
			Expect(err).NotTo(HaveOccurred())
			Expect(sql).To(ContainSubstring("starred"))
			playlistSQL, _, err := playlistRepo.Options.Filters.ToSql()
			Expect(err).NotTo(HaveOccurred())
			Expect(playlistSQL).To(ContainSubstring("starred"))
		})

		It("applies StartIndex/Limit to the merged multi-type result set", func() {
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}})
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(2))
			Expect(res.TotalRecordCount).To(Equal(4))
			Expect(res.StartIndex).To(Equal(1))
		})

		It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() {
			mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
			mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}})
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			// The merged window is [1, 3): each type needs at most its first 3 rows, not the table.
			Expect(mfRepo.Options.Max).To(Equal(3))
			Expect(albumRepo.Options.Max).To(Equal(3))
		})

		DescribeTable("translates the Filters list and its standalone equivalents",
			func(query string, wantSQL, notWantSQL []string) {
				albumRepo := ds.Album().(*tests.MockAlbumRepo)
				albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&"+query, nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				sql, _, err := albumRepo.Options.Filters.ToSql()
				Expect(err).NotTo(HaveOccurred())
				for _, want := range wantSQL {
					Expect(sql).To(ContainSubstring(want))
				}
				for _, not := range notWantSQL {
					Expect(sql).NotTo(ContainSubstring(not))
				}
			},
			Entry("IsFavorite", "Filters=IsFavorite", []string{"starred"}, nil),
			Entry("IsFavorite,IsUnplayed combined", "Filters=IsFavorite,IsUnplayed",
				[]string{"starred", "play_count"}, nil),
			Entry("IsUnplayed", "Filters=IsUnplayed", []string{"play_count"}, []string{"starred"}),
			Entry("IsPlayed", "Filters=IsPlayed", []string{"play_count"}, []string{"starred"}),
			Entry("IsFavoriteOrLikes is treated as favorites", "Filters=IsFavoriteOrLikes", []string{"starred"}, nil),
			Entry("isPlayed=false", "isPlayed=false", []string{"play_count"}, nil),
			Entry("isFavorite=false still filters", "isFavorite=false", []string{"starred"}, nil),
			// Jellyfin builds the query from the standalone params, then applies Filters over the top.
			Entry("Filters wins over the standalone param", "isFavorite=false&Filters=IsFavorite",
				[]string{"starred = "}, nil),
			// No Navidrome equivalent: these must be dropped, not half-applied.
			Entry("Likes is ignored", "Filters=Likes", nil, []string{"starred", "play_count"}),
			Entry("IsResumable is ignored", "Filters=IsResumable", nil, []string{"starred", "play_count"}),
			// The artist-parent branch gets notMissing from filter.AlbumsByArtistID, not the default
			// branch, so favorites must not be the only predicate left on it.
			Entry("keeps missing excluded under an artist parent",
				"Filters=IsFavorite&ArtistIds="+dto.EncodeID(testID("ar1")),
				[]string{"starred", "missing"}, nil),
		)

		// Search runs a two-phase FTS query whose first phase has no annotation join, so an
		// annotation predicate there is "no such column: starred" -> 500.
		DescribeTable("does not push annotation filters into a search",
			func(itemType, filters string) {
				ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
				ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET",
					"/Items?IncludeItemTypes="+itemType+"&SearchTerm=one&Filters="+filters, nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				var opts model.QueryOptions
				if itemType == "MusicAlbum" {
					opts = ds.Album().(*tests.MockAlbumRepo).Options
				} else {
					opts = ds.MediaFile().(*tests.MockMediaFileRepo).Options
				}
				if opts.Filters == nil {
					return
				}
				sql, _, err := opts.Filters.ToSql()
				Expect(err).NotTo(HaveOccurred())
				Expect(sql).NotTo(ContainSubstring("starred"))
				Expect(sql).NotTo(ContainSubstring("play_count"))
			},
			Entry("albums, IsFavorite", "MusicAlbum", "IsFavorite"),
			Entry("albums, IsUnplayed", "MusicAlbum", "IsUnplayed"),
			Entry("albums, IsPlayed", "MusicAlbum", "IsPlayed"),
			Entry("songs, IsFavorite", "Audio", "IsFavorite"),
			Entry("songs, IsUnplayed", "Audio", "IsUnplayed"),
		)

		It("forwards SearchTerm to the repo's Search method", func() {
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(1))
		})

		It("caps a search the client left unbounded", func() {
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			Expect(albumRepo.Options.Max).To(Equal(defaultSearchLimit + 1))
		})

		It("honors an explicit search Limit up to the ceiling", func() {
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil).
				WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			Expect(albumRepo.Options.Max).To(Equal(501))
		})

		It("clamps a search Limit that would materialize the library", func() {
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil).
				WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1))
		})

		It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() {
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil).
				WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(2))
			Expect(albumRepo.SearchQuery).To(BeEmpty())
		})

		It("reports a multi-type search total past the page, so clients keep paging", func() {
			songs := make(model.MediaFiles, defaultSearchLimit*2)
			for i := range songs {
				songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
			}
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil).
				WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(10))
			Expect(res.TotalRecordCount).To(BeNumerically(">", 10))
		})

		It("bounds the multi-type search window however large StartIndex is", func() {
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil).
				WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			// Without the bound this asks each type for ~500001 rows.
			Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1))
		})

		It("stops a multi-type search at the ceiling rather than serving another type's rows", func() {
			// Bounding the per-type window is what keeps StartIndex from driving it without limit, and
			// past that window the merged order is no longer the true one.
			songs := make(model.MediaFiles, maxSearchLimit+1)
			for i := range songs {
				songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
			}
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET",
				fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit),
				nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(BeEmpty())
			Expect(res.TotalRecordCount).To(Equal(maxSearchLimit))
		})

		It("serves the last page below the ceiling in full", func() {
			songs := make(model.MediaFiles, maxSearchLimit+1)
			for i := range songs {
				songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
			}
			// The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold.
			slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) })
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET",
				fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1),
				nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			// Clipped to the window; the interleaved album takes one slot, shifting this song in by one.
			Expect(res.Items).To(HaveLen(1))
			Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-2].ID)))
		})

		It("bounds an unbounded multi-type search to the default in total, not per type", func() {
			songs := make(model.MediaFiles, defaultSearchLimit*2)
			for i := range songs {
				songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
			}
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil).
				WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(defaultSearchLimit))
		})

		It("pages an unbounded multi-type search past the default without dropping matches", func() {
			songs := make(model.MediaFiles, defaultSearchLimit*2)
			for i := range songs {
				songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"}
			}
			// The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold.
			slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) })
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs)
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET",
				fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50),
				nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).ToNot(BeEmpty())
			// The interleaved album takes one slot ahead of it, shifting this song in by one.
			Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+49].ID)))
		})

		It("reports a search total beyond the fetched page instead of the page length", func() {
			ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{
				{ID: testID("r1"), Name: "Alpha"}, {ID: testID("r2"), Name: "Beta"}, {ID: testID("r3"), Name: "Gamma"},
			})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var res dto.QueryResult
			Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
			Expect(res.Items).To(HaveLen(1))
			Expect(res.TotalRecordCount).To(Equal(3))
		})

		It("forwards StartIndex/Limit as Offset/Max", func() {
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser())
			invoke(api.getItems, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			Expect(albumRepo.Options.Offset).To(Equal(5))
			Expect(albumRepo.Options.Max).To(Equal(10))
		})

		Describe("Ids batch-fetch", func() {
			// Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=<id>; without
			// this, queryItems ignored Ids and returned the default type-dispatched list instead.
			It("returns exactly the requested item when Ids has a single id", func() {
				ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(1))
				Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
				Expect(res.Items[0].Name).To(Equal("Song"))
				Expect(res.TotalRecordCount).To(Equal(1))
			})

			It("returns items of different types for a lowercase ids param with multiple ids", func() {
				ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
				ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(2))
				ids := []string{res.Items[0].Id, res.Items[1].Id}
				Expect(ids).To(ConsistOf(dto.EncodeID(testID("a1")), dto.EncodeID(testID("s1"))))
				types := []string{res.Items[0].Type, res.Items[1].Type}
				Expect(types).To(ConsistOf("MusicAlbum", "Audio"))
				Expect(res.TotalRecordCount).To(Equal(2))
			})

			It("resolves song ids with one batched IN query, not a Get per id", func() {
				mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
				mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}, {ID: testID("s2"), Title: "Song2", LibraryID: 1}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(2))
				sql, args, err := mfRepo.Options.Filters.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("media_file.id IN"))
				Expect(args).To(ConsistOf(testID("s1"), testID("s2")))
			})

			It("omits an id in a library the user can't access, without erroring the whole batch", func() {
				ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
				ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) // alice only has access to library 1
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(1))
				Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("a1"))))
				Expect(res.TotalRecordCount).To(Equal(1))
			})
		})

		Describe("sorting", func() {
			DescribeTable("translates SortBy into the repo's sort keys",
				func(itemType, sortBy, want string) {
					albumRepo := ds.Album().(*tests.MockAlbumRepo)
					albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
					mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
					mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
					w := httptest.NewRecorder()
					r := httptest.NewRequest("GET", "/Items?IncludeItemTypes="+itemType+"&SortBy="+sortBy, nil).WithContext(ctxUser())
					invoke(api.getItems, w, r)
					Expect(w.Code).To(Equal(http.StatusOK))
					got := mfRepo.Options.Sort
					if itemType == "MusicAlbum" {
						got = albumRepo.Options.Sort
					}
					Expect(got).To(Equal(want))
				},
				Entry("PlayCount", "MusicAlbum", "PlayCount", "play_count"),
				Entry("DatePlayed", "Audio", "DatePlayed", "play_date"),
				Entry("Runtime on albums", "MusicAlbum", "Runtime", "duration"),
				Entry("RunTimeTicks alias", "MusicAlbum", "RunTimeTicks", "duration"),
				// Finamp leads its track sort with Runtime: unless that resolves, the first recognized
				// key is AlbumArtist and the list looks sorted while being sorted by the wrong thing.
				Entry("Finamp's Runtime-led track sort", "Audio", "Runtime,AlbumArtist,Album,SortName",
					"duration, album_artist, album, title"),
				Entry("every recognized key, in order", "MusicAlbum", "DateCreated,SortName", "recently_added, name"),
				Entry("a key repeating a column is dropped", "Audio",
					"PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName", "year, album, title"),
				// random is matched by exact string equality in the repo, so it can never share a sort.
				Entry("Random stays alone", "MusicAlbum", "Random,SortName", "random"),
				Entry("unrecognized keys are skipped", "Audio", "Runtime,Nonsense,SortName", "duration, title"),
				Entry("only the last key recognized", "Audio", "Unknown1,Unknown2,SortName", "title"),
				Entry("Finamp's album view is disc+track", "Audio", "ParentIndexNumber,IndexNumber,SortName", "album, title"),
				Entry("nothing recognized leaves the repo default", "MusicAlbum", "SeriesSortName", ""),
			)

			// Jellyfin allows a per-key SortOrder list; we cannot express that through one Order, so
			// we honor the first value for all keys, matching Jellyfin's fallback for extra keys.
			DescribeTable("reads the first SortOrder value for the whole sort",
				func(sortOrder, want string) {
					albumRepo := ds.Album().(*tests.MockAlbumRepo)
					albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
					w := httptest.NewRecorder()
					r := httptest.NewRequest("GET",
						"/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime,SortName&SortOrder="+sortOrder, nil).WithContext(ctxUser())
					invoke(api.getItems, w, r)
					Expect(w.Code).To(Equal(http.StatusOK))
					Expect(albumRepo.Options.Order).To(Equal(want))
				},
				Entry("ascending", "Ascending", ""),
				Entry("descending", "Descending", "desc"),
				Entry("descending leading a list", "Descending,Ascending", "desc"),
				Entry("ascending leading a list", "Ascending,Descending", ""),
			)
		})

		Describe("library scoping", func() {
			It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() {
				albumRepo := ds.Album().(*tests.MockAlbumRepo)
				albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
				w := httptest.NewRecorder()
				libs := model.Libraries{{ID: 1}, {ID: 2}}
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				sql, args, err := albumRepo.Options.Filters.ToSql()
				Expect(err).NotTo(HaveOccurred())
				Expect(sql).To(ContainSubstring("library_id"))
				Expect(args).To(ContainElements(1, 2))
			})

			It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() {
				mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
				mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
				w := httptest.NewRecorder()
				libs := model.Libraries{{ID: 1}, {ID: 2}}
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs))
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				sql, args, err := mfRepo.Options.Filters.ToSql()
				Expect(err).NotTo(HaveOccurred())
				Expect(sql).To(ContainSubstring("library_id"))
				Expect(args).To(ContainElements(1, 2))
			})

			It("scopes a MusicArtist listing to the user's accessible libraries", func() {
				artistRepo := ds.Artist().(*tests.MockArtistRepo)
				artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
				w := httptest.NewRecorder()
				libs := model.Libraries{{ID: 1}, {ID: 2}}
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs))
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				sql, args, err := artistRepo.Options.Filters.ToSql()
				Expect(err).NotTo(HaveOccurred())
				Expect(sql).To(ContainSubstring("library_artist.library_id"))
				Expect(args).To(ContainElements(1, 2))
			})

			It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() {
				albumRepo := ds.Album().(*tests.MockAlbumRepo)
				albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
				w := httptest.NewRecorder()
				libs := model.Libraries{{ID: 1}, {ID: 2}}
				r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(2)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				sql, args, err := albumRepo.Options.Filters.ToSql()
				Expect(err).NotTo(HaveOccurred())
				Expect(sql).NotTo(ContainSubstring("album_artists")) // not treated as an artist-parent filter
				Expect(sql).To(ContainSubstring("library_id"))
				Expect(args).To(ContainElement(2))
			})

			It("does not let ParentId=<inaccessible library id> scope results to that library", func() {
				albumRepo := ds.Album().(*tests.MockAlbumRepo)
				albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
				w := httptest.NewRecorder()
				libs := model.Libraries{{ID: 1}} // no access to library 99
				r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(99)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs))
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				sql, args, err := albumRepo.Options.Filters.ToSql()
				Expect(err).NotTo(HaveOccurred())
				// Falls back to treating "99" as an (empty-matching) artist-parent id...
				Expect(sql).To(ContainSubstring("album_artists"))
				// ...while still scoping to the user's own accessible libraries.
				Expect(sql).To(ContainSubstring("library_id"))
				Expect(args).To(ContainElement(1))
				Expect(args).NotTo(ContainElement(99))
			})

			It("does not restrict a default MusicAlbum listing for an admin user", func() {
				albumRepo := ds.Album().(*tests.MockAlbumRepo)
				albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}, {ID: testID("a2"), Name: "Two", LibraryID: 2}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				// accessibleLibraryIDs is empty for an admin (Libraries is nil), so
				// ApplyLibraryFilter([]) is a no-op: no library_id restriction is added.
				if albumRepo.Options.Filters == nil {
					return
				}
				sql, _, err := albumRepo.Options.Filters.ToSql()
				Expect(err).NotTo(HaveOccurred())
				Expect(sql).NotTo(ContainSubstring("library_id"))
			})
		})

		// A malformed id must 404, not silently drop the filter; a well-formed but unknown one must
		// still reach the entity filter, not the unfiltered default.
		Describe("stale and malformed id filtering", func() {
			It("404s a malformed ParentId instead of listing every song", func() {
				ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id", nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusNotFound))
			})

			It("404s a malformed AlbumArtistIds instead of listing every album", func() {
				ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds=not-a-valid-id", nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusNotFound))
			})

			It("404s a malformed ArtistIds instead of listing every song", func() {
				ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ArtistIds=not-a-valid-id", nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusNotFound))
			})

			It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() {
				albumRepo := ds.Album().(*tests.MockAlbumRepo)
				albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds="+dto.EncodeID(testID("no-such-artist")), nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				sql, _, err := albumRepo.Options.Filters.ToSql()
				Expect(err).NotTo(HaveOccurred())
				Expect(sql).To(ContainSubstring("album_artists"))
			})

			It("still applies the album filter (rather than dropping it) for a well-formed but unknown ParentId", func() {
				mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
				mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId="+dto.EncodeID(testID("no-such-album")), nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusOK))
				sql, args, err := mfRepo.Options.Filters.ToSql()
				Expect(err).NotTo(HaveOccurred())
				Expect(sql).To(ContainSubstring("album_id"))
				Expect(args).To(ContainElement(testID("no-such-album")))
			})
		})

		Describe("mixed IncludeItemTypes merge", func() {
			BeforeEach(func() {
				ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
				ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "S1"}, {ID: testID("s2"), Title: "S2"}})
			})

			It("returns a mix of both types, not all of one", func() {
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(4))
				Expect(res.TotalRecordCount).To(Equal(4))
				types := map[string]int{}
				for _, it := range res.Items {
					types[it.Type]++
				}
				Expect(types["Audio"]).To(Equal(2))
				Expect(types["MusicAlbum"]).To(Equal(2))
			})

			It("interleaves types round-robin (Audio first, per IncludeItemTypes order)", func() {
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				got := []string{res.Items[0].Type, res.Items[1].Type, res.Items[2].Type, res.Items[3].Type}
				Expect(got).To(Equal([]string{"Audio", "MusicAlbum", "Audio", "MusicAlbum"}))
			})

			It("honors Limit across the merged set", func() {
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=1", nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(1))
				Expect(res.TotalRecordCount).To(Equal(4))
			})

			It("serves a full random page from offset 0 regardless of StartIndex", func() {
				// A deep StartIndex on a random merge must not materialize offset+limit rows; since random
				// reshuffles per request, offset 0 is an equivalent fresh draw. Old behavior returned empty.
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SortBy=Random&Recursive=true&StartIndex=1000&Limit=4", nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				var res dto.QueryResult
				Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
				Expect(res.Items).To(HaveLen(4))
			})

			It("propagates a per-type query error", func() {
				ds.MediaFile().(*tests.MockMediaFileRepo).SetError(true)
				w := httptest.NewRecorder()
				r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser())
				invoke(api.getItems, w, r)
				Expect(w.Code).To(Equal(http.StatusInternalServerError))
			})
		})
	})

	Describe("getItem", func() {
		It("returns an album by id", func() {
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
			r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var item dto.BaseItemDto
			Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
			Expect(item.Id).To(Equal(dto.EncodeID(testID("a1"))))
			Expect(item.Type).To(Equal("MusicAlbum"))
		})

		It("returns 404 when the id doesn't match any entity", func() {
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser())
			r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing")))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusNotFound))
		})

		It("returns 404 for an album in a library the user can't access", func() {
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1
			r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusNotFound))
		})

		It("returns 404 for a song in a library the user can't access", func() {
			ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) // only has access to library 1
			r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusNotFound))
		})

		It("returns an album to an admin even when it's outside their (empty) Libraries", func() {
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxAdmin()) // admin, Libraries: nil
			r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var item dto.BaseItemDto
			Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
			Expect(item.Id).To(Equal(dto.EncodeID(testID("a1"))))
		})

		// Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the
		// library node before it can load the home screen or any library tab.
		It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() {
			w := httptest.NewRecorder()
			libs := model.Libraries{{ID: 1, Name: "Music Library"}}
			ds.Library().(*tests.MockLibraryRepo).SetData(libs)
			r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs))
			r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var item dto.BaseItemDto
			Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
			Expect(item.Id).To(Equal(dto.EncodeLibraryID(1)))
			Expect(item.Name).To(Equal("Music Library"))
			Expect(item.Type).To(Equal("CollectionFolder"))
			Expect(item.CollectionType).To(Equal("music"))
			Expect(item.IsFolder).To(BeTrue())
		})

		It("does not resolve a library-view id the user has no access to", func() {
			w := httptest.NewRecorder()
			libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1
			r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs))
			r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusNotFound))
		})

		// Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it
		// 404s with "Could not fetch BaseItemDto <playlist> from server."
		It("resolves a playlist id via the playlists service", func() {
			fp.getByIDPls = &model.Playlist{ID: testID("p1"), Name: "My Mix", SongCount: 5}
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser())
			r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var item dto.BaseItemDto
			Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
			Expect(item.Id).To(Equal(dto.EncodeID(testID("p1"))))
			Expect(item.Name).To(Equal("My Mix"))
			Expect(item.Type).To(Equal("Playlist"))
		})

		It("returns 404 for a non-owned or absent playlist id", func() {
			fp.getByIDErr = model.ErrNotFound
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser())
			r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusNotFound))
		})

		// Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID).
		It("resolves a genre id as a MusicGenre item", func() {
			Expect(ds.Genre().(*tests.MockedGenreRepo).Put(&model.Genre{ID: testID("g1"), Name: "Rock"})).To(Succeed())
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1")), nil).WithContext(ctxUser())
			r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1")))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var item dto.BaseItemDto
			Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
			Expect(item.Id).To(Equal(dto.EncodeID(testID("g1"))))
			Expect(item.Name).To(Equal("Rock"))
			Expect(item.Type).To(Equal("MusicGenre"))
		})

		It("resolves a library-view id for an admin even though their Libraries slice is empty", func() {
			ds.Library().(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxAdmin())
			r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1))
			invoke(api.getItem, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var item dto.BaseItemDto
			Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
			Expect(item.Id).To(Equal(dto.EncodeLibraryID(1)))
			Expect(item.Name).To(Equal("Music Library"))
			Expect(item.Type).To(Equal("CollectionFolder"))
		})
	})

	Describe("getLatest", func() {
		It("returns a bare array of the newest albums", func() {
			ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
			w := httptest.NewRecorder()
			r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser())
			invoke(api.getLatest, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			var items []dto.BaseItemDto
			Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed())
			Expect(items).To(HaveLen(1))
			Expect(items[0].Id).To(Equal(dto.EncodeID(testID("a1"))))
		})

		It("scopes to the user's accessible libraries", func() {
			albumRepo := ds.Album().(*tests.MockAlbumRepo)
			albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
			w := httptest.NewRecorder()
			libs := model.Libraries{{ID: 1}, {ID: 2}}
			r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs))
			invoke(api.getLatest, w, r)
			Expect(w.Code).To(Equal(http.StatusOK))
			sql, args, err := albumRepo.Options.Filters.ToSql()
			Expect(err).NotTo(HaveOccurred())
			Expect(sql).To(ContainSubstring("library_id"))
			Expect(args).To(ContainElements(1, 2))
		})
	})

	Describe("applySort random for all merge types", func() {
		DescribeTable("maps Random -> random",
			func(itemType string) {
				var opts model.QueryOptions
				applySort(&opts, itemType, "Random", "")
				Expect(opts.Sort).To(Equal("random"))
			},
			Entry("Audio", "Audio"),
			Entry("MusicAlbum", "MusicAlbum"),
			Entry("MusicArtist", "MusicArtist"),
			Entry("MusicGenre", "MusicGenre"),
			Entry("Playlist", "Playlist"),
		)
	})

	Describe("interleave", func() {
		It("round-robins one item per list in turn", func() {
			lists := [][]dto.BaseItemDto{
				{{Id: "a0"}, {Id: "a1"}, {Id: "a2"}},
				{{Id: "b0"}, {Id: "b1"}},
			}
			got := interleave(lists)
			ids := make([]string, len(got))
			for i, it := range got {
				ids[i] = it.Id
			}
			Expect(ids).To(Equal([]string{"a0", "b0", "a1", "b1", "a2"}))
		})

		It("returns empty for no lists", func() {
			Expect(interleave(nil)).To(BeEmpty())
		})
	})

	Describe("parseTypes", func() {
		It("dedupes repeated types, preserving first-seen order", func() {
			Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"}))
		})

		It("matches type names case-insensitively, like Jellyfin's enum binding", func() {
			Expect(parseTypes("musicalbum, AUDIO")).To(Equal([]string{"MusicAlbum", "Audio"}))
		})

		It("returns no types for real Jellyfin kinds Navidrome has none of", func() {
			Expect(parseTypes("Boxset")).To(BeEmpty())
			Expect(parseTypes("BoxSet,Movie")).To(BeEmpty())
		})

		It("drops names that aren't Jellyfin item kinds, case-insensitively", func() {
			Expect(knownItemKinds("music")).To(BeEmpty())
			Expect(knownItemKinds("music, audio,MUSICVIDEO")).To(Equal("audio,MUSICVIDEO"))
		})

		It("defaults to albums only when IncludeItemTypes is absent", func() {
			Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"}))
			Expect(parseTypes("Nonsense")).To(BeEmpty())
		})
	})

	Describe("decodeFilterParam", func() {
		It("reports ok for an absent param, decoding to \"\"", func() {
			id, ok := decodeFilterParam("")
			Expect(id).To(BeEmpty())
			Expect(ok).To(BeTrue())
		})

		It("reports ok for a well-formed id, whether or not it exists", func() {
			id, ok := decodeFilterParam(dto.EncodeID(testID("a1")))
			Expect(id).To(Equal(testID("a1")))
			Expect(ok).To(BeTrue())
		})

		It("reports not ok for a non-empty param that fails to decode", func() {
			id, ok := decodeFilterParam("not-a-valid-id")
			Expect(id).To(BeEmpty())
			Expect(ok).To(BeFalse())
		})
	})

	Describe("decodedQueryIDs", func() {
		It("decodes every entry when all are well-formed", func() {
			r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("a2")), nil)
			ids, ok := decodedQueryIDs(r, "ids")
			Expect(ok).To(BeTrue())
			Expect(ids).To(Equal([]string{testID("a1"), testID("a2")}))
		})

		It("reports not ok and an empty list, not a partially-decoded one, for a mix of valid and malformed entries", func() {
			r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+",not-a-valid-id", nil)
			ids, ok := decodedQueryIDs(r, "ids")
			Expect(ok).To(BeFalse())
			Expect(ids).To(BeEmpty())
		})

		It("reports ok for an absent param, decoding to an empty list", func() {
			r := httptest.NewRequest("GET", "/Items", nil)
			ids, ok := decodedQueryIDs(r, "ids")
			Expect(ok).To(BeTrue())
			Expect(ids).To(BeEmpty())
		})
	})
})