Files
silo-server/internal/jellycompat/list_seasons_batch_test.go
CoffeeKnyteandGitHub 652b5d1e9f perf(jellycompat): fast-paths for slow Jellyfin-compat queries (#247)
* perf(jellycompat): fast-paths for slow Jellyfin-compat queries

Rebuilds the jellycompat slow-query fast-paths cleanly on main, without
the hub-sections virtual-library feature (which is intentionally absent
on this branch). Each path is bounded so behavior stays predictable
under load.

Fast-paths:
- Episode catalog: add the load-bearing `si.type = 'series'` hydration
  guard plus a matching entry-scan series-parent guard, so the page and
  count scans stay aligned with hydration (no under-filled pages, no
  inflated TotalRecordCount from podcast/audiobook episode rows).
- /Shows/{id}/Episodes AdjacentTo: serve a bounded prev/self/next
  neighbor window with series-membership validation and a cross-series
  id guard, falling back to the full listing.
- Resume: serve Continue Watching through the capped native
  continue-watching fetcher (next-up suppressed, in-progress only)
  instead of loadProgressPage's unbounded scan. Wires only the
  sections read-time fetcher (StoreProvider) into ItemsHandler; no
  hub-section/virtual-library exposure.
- Played-history: push the type + library filter down into SQL.
- recently_added: fan a no-parentId browse out across libraries with an
  app-side dedup merge, clamped to BrowseFilters.MaxLimit, and exclude
  compat-hidden (audiobook/podcast) libraries from the fan-out.
- Detail fields: batch per-item GetItemDetail in the Latest/browse
  detail-field paths via GetItemDetailsByIDs, with per-item fallback.
- Search: term-length and result-count guards for aux search.

* perf(jellycompat): clamp Resume fast-path fetch to maxResumeItems

A large client-supplied StartIndex on /UserItems/Resume inflated
loadResumeViaSections' fetchLimit (pageSize + StartIndex) and fed it
straight into ResolvedSection.ItemLimit, letting the continue-watching
fetcher scan up to its continueProgressMaxScanned hard cap (1000 rows)
instead of stopping near maxResumeItems. Clamp fetchLimit to
maxResumeItems and short-circuit to an empty page when StartIndex is at
or beyond the cap (no visible row can land there), keeping the capped
fetcher's scan bounded and predictable under load.

Addresses CodeRabbit review finding on PR #247.
2026-07-01 09:06:24 -04:00

190 lines
6.7 KiB
Go

package jellycompat
import (
"context"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// stubItemAccessSource always permits access. The Search/GetByIDs methods are
// not exercised by ListSeasons.
type stubItemAccessSource struct{}
func (stubItemAccessSource) EnsureAccessible(_ context.Context, _ string, _ catalog.AccessFilter) error {
return nil
}
func (stubItemAccessSource) Search(_ context.Context, _ string, _ []string, _, _ int, _ catalog.AccessFilter) ([]*models.MediaItem, int, error) {
panic("unused")
}
func (stubItemAccessSource) GetByIDs(_ context.Context, _ []string) ([]*models.MediaItem, error) {
panic("unused")
}
// stubSeasonListSource returns a fixed slice from ListBySeries. Other
// methods panic so the test catches unexpected use.
type stubSeasonListSource struct {
seasons []*models.Season
}
func (s *stubSeasonListSource) ListBySeries(_ context.Context, _ string) ([]*models.Season, error) {
return s.seasons, nil
}
func (s *stubSeasonListSource) GetBySeriesAndNumber(_ context.Context, _ string, _ int) (*models.Season, error) {
panic("unused")
}
func (s *stubSeasonListSource) GetByID(_ context.Context, _ string) (*models.Season, error) {
panic("unused")
}
// countingEpisodeListSource counts calls to ListBySeason and
// ListBySeriesGroupedBySeason. ListBySeason should be zero in the batched
// ListSeasons path.
type countingEpisodeListSource struct {
listBySeasonCalls int
listGroupedCalls int
groupedReturn map[int][]*models.Episode
}
func (c *countingEpisodeListSource) ListBySeason(_ context.Context, seriesID string, seasonNum int) ([]*models.Episode, error) {
c.listBySeasonCalls++
return c.groupedReturn[seasonNum], nil
}
func (c *countingEpisodeListSource) ListBySeriesGroupedBySeason(_ context.Context, _ string) (map[int][]*models.Episode, error) {
c.listGroupedCalls++
out := make(map[int][]*models.Episode, len(c.groupedReturn))
for k, v := range c.groupedReturn {
out[k] = v
}
return out, nil
}
func (c *countingEpisodeListSource) ListBySeriesIDs(_ context.Context, _ []string) (map[string][]*models.Episode, error) {
return map[string][]*models.Episode{}, nil
}
func (c *countingEpisodeListSource) GetByIDs(_ context.Context, _ []string) ([]*models.Episode, error) {
return nil, nil
}
// TestListSeasons_UsesBatchEpisodeFetch verifies ListSeasons makes exactly
// one ListBySeriesGroupedBySeason call (replacing N per-season ListBySeason
// calls) and exactly one ListProgressByMediaItems call (replacing N
// per-season fetches inside enrichSeasonUserData) (audit 2026-05-01 §2.2).
func TestListSeasons_UsesBatchEpisodeFetch(t *testing.T) {
provider := newProgressCountingStoreProvider()
episodeRepo := &countingEpisodeListSource{
groupedReturn: map[int][]*models.Episode{
1: {
{ContentID: "e1", SeriesID: "series-abc", SeasonNumber: 1, EpisodeNumber: 1},
{ContentID: "e2", SeriesID: "series-abc", SeasonNumber: 1, EpisodeNumber: 2},
},
2: {
{ContentID: "e3", SeriesID: "series-abc", SeasonNumber: 2, EpisodeNumber: 1},
},
},
}
seasonRepo := &stubSeasonListSource{
seasons: []*models.Season{
{ContentID: "s1", SeriesID: "series-abc", SeasonNumber: 1},
{ContentID: "s2", SeriesID: "series-abc", SeasonNumber: 2},
},
}
svc := &directContentService{
itemRepo: stubItemAccessSource{},
seasonRepo: seasonRepo,
episodeRepo: episodeRepo,
storeProvider: provider,
}
session := &Session{StreamAppUserID: 1, ProfileID: "profile-1"}
result, err := svc.ListSeasons(context.Background(), session, "series-abc", nil)
if err != nil {
t.Fatalf("ListSeasons returned error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 seasons in result; got %d", len(result))
}
if episodeRepo.listBySeasonCalls != 0 {
t.Fatalf("ListSeasons must not call ListBySeason on the batched path; got %d calls",
episodeRepo.listBySeasonCalls)
}
if episodeRepo.listGroupedCalls != 1 {
t.Fatalf("ListSeasons should call ListBySeriesGroupedBySeason exactly once; got %d",
episodeRepo.listGroupedCalls)
}
if provider.store.listProgressCalls != 1 {
t.Fatalf("ListSeasons should batch into 1 ListProgressByMediaItems call; got %d",
provider.store.listProgressCalls)
}
// The single batch must include every episode from every season.
if got, want := len(provider.store.lastListedMediaItemIDs), 3; got != want {
t.Fatalf("expected single progress batch to cover all %d episodes; got %d ids",
want, got)
}
}
// TestListSeasons_FallbackUsesGroupedEpisodes verifies that when the
// seasons table is empty, ListSeasons still uses the single grouped fetch
// and derives synthetic seasons without issuing an extra aggregation
// query (audit 2026-05-01 §2.2 fallback path).
func TestListSeasons_FallbackUsesGroupedEpisodes(t *testing.T) {
provider := newProgressCountingStoreProvider()
episodeRepo := &countingEpisodeListSource{
groupedReturn: map[int][]*models.Episode{
1: {
{ContentID: "e1", SeriesID: "series-abc", SeasonNumber: 1, EpisodeNumber: 1},
},
2: {
{ContentID: "e2", SeriesID: "series-abc", SeasonNumber: 2, EpisodeNumber: 1},
{ContentID: "e3", SeriesID: "series-abc", SeasonNumber: 2, EpisodeNumber: 2},
},
},
}
// seasons table empty triggers the fallback path.
seasonRepo := &stubSeasonListSource{seasons: nil}
svc := &directContentService{
itemRepo: stubItemAccessSource{},
seasonRepo: seasonRepo,
episodeRepo: episodeRepo,
storeProvider: provider,
}
session := &Session{StreamAppUserID: 1, ProfileID: "profile-1"}
result, err := svc.ListSeasons(context.Background(), session, "series-abc", nil)
if err != nil {
t.Fatalf("ListSeasons returned error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 synthetic seasons; got %d", len(result))
}
// Seasons must be returned in ascending season-number order.
if result[0].SeasonNumber != 1 || result[1].SeasonNumber != 2 {
t.Fatalf("expected seasons in ascending order; got %d, %d",
result[0].SeasonNumber, result[1].SeasonNumber)
}
// Episode counts derived from grouped, no per-season ListBySeason fan-out.
if result[0].EpisodeCount != 1 || result[1].EpisodeCount != 2 {
t.Fatalf("synthetic season EpisodeCount mismatch; got %d, %d",
result[0].EpisodeCount, result[1].EpisodeCount)
}
if episodeRepo.listBySeasonCalls != 0 {
t.Fatalf("fallback path must not call ListBySeason; got %d", episodeRepo.listBySeasonCalls)
}
if episodeRepo.listGroupedCalls != 1 {
t.Fatalf("fallback path should call ListBySeriesGroupedBySeason exactly once; got %d",
episodeRepo.listGroupedCalls)
}
if provider.store.listProgressCalls != 1 {
t.Fatalf("fallback path should batch into 1 ListProgressByMediaItems call; got %d",
provider.store.listProgressCalls)
}
}