diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index 516946b8..79bfb9a2 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -235,10 +235,9 @@ func (s *directContentService) BrowseItems(ctx context.Context, session *Session mi = localized } } - item := mediaItemToListItem(mi) - s.presignListItem(ctx, &item) - batch = append(batch, item) + batch = append(batch, mediaItemToListItem(mi)) } + s.presignListItems(ctx, batch) if isPlayedFilter != "" { // Need user data to filter by played status. The handler's // resolveUserStateForContentIDs call covers UserData on the wire @@ -313,10 +312,9 @@ func (s *directContentService) SearchItems(ctx context.Context, session *Session mi = localized } } - item := mediaItemToListItem(mi) - s.presignListItem(ctx, &item) - listItems = append(listItems, item) + listItems = append(listItems, mediaItemToListItem(mi)) } + s.presignListItems(ctx, listItems) s.enrichListItemsUserData(ctx, session, listItems) return &upstreamBrowseResponse{ @@ -652,6 +650,39 @@ func seasonUserDataFromProgress(progress userstore.WatchProgress) *catalog.Seaso // --- Image URL presigning helpers --- func (s *directContentService) presignListItem(ctx context.Context, item *upstreamListItem) { + if item == nil { + return + } + items := []upstreamListItem{*item} + s.presignListItems(ctx, items) + *item = items[0] +} + +func (s *directContentService) presignListItems(ctx context.Context, items []upstreamListItem) { + if len(items) == 0 { + return + } + for i := range items { + ensureListItemImagePaths(&items[i]) + } + if s.detailSvc == nil { + return + } + + posterURLs := s.detailSvc.PresignImageURLsWithExpiry(ctx, collectListImagePaths(items, func(item upstreamListItem) string { return item.PosterURL }), "poster", compatCardImageSize) + backdropURLs := s.detailSvc.PresignImageURLsWithExpiry(ctx, collectListImagePaths(items, func(item upstreamListItem) string { return item.BackdropURL }), "backdrop", compatCardImageSize) + logoURLs := s.detailSvc.PresignImageURLsWithExpiry(ctx, collectListImagePaths(items, func(item upstreamListItem) string { return item.LogoURL }), "logo", compatCardImageSize) + stillURLs := s.detailSvc.PresignImageURLsWithExpiry(ctx, collectListImagePaths(items, func(item upstreamListItem) string { return item.StillURL }), "still", compatCardImageSize) + + for i := range items { + items[i].PosterURL = resolvedListImageURL(posterURLs, items[i].PosterURL) + items[i].BackdropURL = resolvedListImageURL(backdropURLs, items[i].BackdropURL) + items[i].LogoURL = resolvedListImageURL(logoURLs, items[i].LogoURL) + items[i].StillURL = resolvedListImageURL(stillURLs, items[i].StillURL) + } +} + +func ensureListItemImagePaths(item *upstreamListItem) { if item.PosterPath == "" { item.PosterPath = item.PosterURL } @@ -664,10 +695,33 @@ func (s *directContentService) presignListItem(ctx context.Context, item *upstre if item.StillPath == "" { item.StillPath = item.StillURL } - item.PosterURL = compatPresignImage(s.detailSvc, ctx, item.PosterURL, "poster", compatCardImageSize) - item.BackdropURL = compatPresignImage(s.detailSvc, ctx, item.BackdropURL, "backdrop", compatCardImageSize) - item.LogoURL = compatPresignImage(s.detailSvc, ctx, item.LogoURL, "logo", compatCardImageSize) - item.StillURL = compatPresignImage(s.detailSvc, ctx, item.StillURL, "still", compatCardImageSize) +} + +func collectListImagePaths(items []upstreamListItem, pick func(upstreamListItem) string) []string { + paths := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + path := pick(item) + if path == "" { + continue + } + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + paths = append(paths, path) + } + return paths +} + +func resolvedListImageURL(resolved map[string]catalog.ResolvedImageURL, path string) string { + if path == "" { + return "" + } + if value, ok := resolved[path]; ok { + return value.URL + } + return "" } func (s *directContentService) presignSeason(ctx context.Context, season *upstreamSeason) { diff --git a/internal/jellycompat/content_direct_test.go b/internal/jellycompat/content_direct_test.go index 990521e0..d8f79cde 100644 --- a/internal/jellycompat/content_direct_test.go +++ b/internal/jellycompat/content_direct_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "slices" "testing" "time" @@ -57,6 +58,95 @@ func TestItemEtagIncludesPremiereDate(t *testing.T) { } } +type countingCompatImageResolver struct { + singleCalls int + batchCalls int + variants []string + paths []string +} + +func (r *countingCompatImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string { + r.singleCalls++ + return "single:" + variant + ":" + path +} + +func (r *countingCompatImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string { + resolved := r.ResolveImageURLsWithExpiry(context.Background(), paths, variant) + urls := make(map[string]string, len(resolved)) + for path, value := range resolved { + urls[path] = value.URL + } + return urls +} + +func (r *countingCompatImageResolver) ResolveImageURLWithExpiry(_ context.Context, path string, variant string) catalog.ResolvedImageURL { + r.singleCalls++ + return catalog.ResolvedImageURL{URL: "single:" + variant + ":" + path} +} + +func (r *countingCompatImageResolver) ResolveImageURLsWithExpiry(_ context.Context, paths []string, variant string) map[string]catalog.ResolvedImageURL { + r.batchCalls++ + r.variants = append(r.variants, variant) + r.paths = append(r.paths, paths...) + resolved := make(map[string]catalog.ResolvedImageURL, len(paths)) + for _, path := range paths { + resolved[path] = catalog.ResolvedImageURL{URL: "batch:" + variant + ":" + path} + } + return resolved +} + +func TestPresignListItemsBatchResolvesImages(t *testing.T) { + resolver := &countingCompatImageResolver{} + detailSvc := &catalog.DetailService{} + detailSvc.SetImageResolver(resolver) + svc := &directContentService{detailSvc: detailSvc} + + items := []upstreamListItem{ + { + ContentID: "movie-1", + PosterURL: "plug://poster-1", + BackdropURL: "plug://backdrop-1", + LogoURL: "plug://logo-1", + }, + { + ContentID: "movie-2", + PosterURL: "plug://poster-2", + StillURL: "plug://still-2", + }, + } + + svc.presignListItems(context.Background(), items) + + if resolver.singleCalls != 0 { + t.Fatalf("single image resolver calls = %d, want 0", resolver.singleCalls) + } + if resolver.batchCalls != 4 { + t.Fatalf("batch image resolver calls = %d, want 4", resolver.batchCalls) + } + for _, variant := range resolver.variants { + if variant != "card" { + t.Fatalf("batch variant = %q, want card", variant) + } + } + for _, path := range []string{"plug://poster-1", "plug://poster-2", "plug://backdrop-1", "plug://logo-1", "plug://still-2"} { + if !slices.Contains(resolver.paths, path) { + t.Fatalf("batch paths %v missing %q", resolver.paths, path) + } + } + if items[0].PosterPath != "plug://poster-1" { + t.Fatalf("PosterPath = %q, want original path", items[0].PosterPath) + } + if got := items[0].PosterURL; got != "batch:card:plug://poster-1" { + t.Fatalf("PosterURL = %q", got) + } + if got := items[0].BackdropURL; got != "batch:card:plug://backdrop-1" { + t.Fatalf("BackdropURL = %q", got) + } + if got := items[1].StillURL; got != "batch:card:plug://still-2" { + t.Fatalf("StillURL = %q", got) + } +} + // progressCountingStoreProvider is a test double that records calls to // ForUser and ListProgressByMediaItems. Used to assert that BrowseItems // does not duplicate the handler-level user-data fetch. diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index ae6bf322..97e9622e 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -39,6 +39,7 @@ func NewRouter(deps Dependencies) chi.Router { MaxAge: 86400, })) r.Use(normalizeCompatPathMiddleware) + r.Use(middleware.Compress(5, "application/json")) if debugPath := os.Getenv("JELLYCOMPAT_DEBUG_LOG"); debugPath != "" { rotator := &lumberjack.Logger{ Filename: debugPath, diff --git a/internal/jellycompat/router_test.go b/internal/jellycompat/router_test.go new file mode 100644 index 00000000..1b94950e --- /dev/null +++ b/internal/jellycompat/router_test.go @@ -0,0 +1,46 @@ +package jellycompat + +import ( + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/config" +) + +func TestRouterCompressesJSONResponses(t *testing.T) { + cfg, err := config.LoadFromDB(map[string]string{}) + if err != nil { + t.Fatalf("LoadFromDB: %v", err) + } + router := NewRouter(Dependencies{Config: cfg}) + + req := httptest.NewRequest(http.MethodGet, "/System/Info/Public", nil) + req.Header.Set("Accept-Encoding", "gzip") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("Content-Encoding = %q, want gzip", got) + } + + reader, err := gzip.NewReader(rec.Body) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + defer reader.Close() + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read compressed body: %v", err) + } + if !strings.Contains(string(body), `"ProductName":"Jellyfin Server"`) { + t.Fatalf("unexpected response body %q", string(body)) + } +}