feat(jellycompat): expose library collections as an auto-shown Collections view (#175)

* feat(jellycompat): expose library collections as a Collections library view

Surface server library collections as a top-level Jellyfin "Collections"
library (CollectionType "boxsets") so compat clients see them as the first
library in /UserViews and can browse them by ParentId. The BoxSet machinery
(list/detail/children) already existed; this adds the library wrapper.

- catalog: add LibraryCollectionRepository.AnyVisibleInLibraries, an
  index-only EXISTS probe (no item join/aggregation) used to gate the view so
  an empty Collections tab never shows. Mirrors collectionVisible semantics
  (multi-library scope rows or the legacy single library_id fallback).
- jellycompat: add the synthetic Collections CollectionFolder (fixed Jellyfin
  sentinel ID, stable across servers), prepend it to the user's views when a
  visible collection exists, and route ParentId/Items-by-ID for that sentinel
  to the existing BoxSet listing. ChildCount is left omitted (no per-call
  count, no unwatched badge).

AI-use disclosure: implemented with assistance from Claude.

* fix(jellycompat): display collection posters and a generated Collections tile

Library collections surfaced as Jellyfin BoxSets showed blank cards: their
poster_url is frequently a bundled frontend template path
(/images/collection-templates/x.jpg), which the compat image route passed
through unchanged and then rejected in parseRemoteImageURL (no scheme/host),
returning BadGateway. Clients probed Images/Primary and got nothing.

- images: serve app-relative artwork (bundled template posters) straight from
  the embedded frontend FS (new ImagesHandler.frontendFS), with content-type
  and cache headers. Wired through jellycompat.Dependencies.FrontendFS.
- poster_gen: on-the-fly gradient poster generator (per-title hue, centered
  white caption with black outline, gobold/opentype), memoized in a bounded
  cache. Used for the synthetic Collections library tile and as a fallback for
  collections without usable artwork, so cards are never blank.
- consolidate collection/view image routing in HandleItemImage, authorized by
  the signed tag or an authenticated, visibility-checked session.

AI-use disclosure: implemented with assistance from Claude.

* fix(jellycompat): declare 2:3 PrimaryImageAspectRatio on BoxSets and Collections tile

Clients defaulted collection cards to a square and crop the 2:3 poster to fit.
Set PrimaryImageAspectRatio (portrait 2/3) on the BoxSet DTO and the synthetic
Collections library tile so the full poster is shown, matching Jellyfin.

AI-use disclosure: implemented with assistance from Claude.
This commit is contained in:
CoffeeKnyte
2026-06-18 10:20:48 -04:00
committed by GitHub
parent 14ffc91dfb
commit 6e1f79e8a9
10 changed files with 971 additions and 77 deletions
+1
View File
@@ -2142,6 +2142,7 @@ func main() {
NodePlanner: deps.NodePlanner,
JWTSecret: cfg.Auth.JWTSecret,
RecWorker: recWorker,
FrontendFS: deps.FrontendFS,
}
// Wire direct dependencies when DB is available.
@@ -407,6 +407,39 @@ func (r *LibraryCollectionRepository) ListAll(ctx context.Context, libraryID *in
return scanLibraryCollections(rows)
}
// AnyVisibleInLibraries reports whether at least one visible library collection
// is scoped to any of the given libraries. It mirrors the visibility rules of
// ListAll + the compat layer's collectionVisible (multi-library scope rows, or
// the legacy single library_id column when a collection has no scope rows) but
// is an index-only EXISTS probe: no item join, no aggregation, no row build. It
// short-circuits the first match, so it stays cheap enough to run on every
// /UserViews request to gate the synthetic "Collections" view.
func (r *LibraryCollectionRepository) AnyVisibleInLibraries(ctx context.Context, libraryIDs []int) (bool, error) {
if len(libraryIDs) == 0 {
return false, nil
}
const query = `SELECT EXISTS (
SELECT 1
FROM library_collections lc
WHERE lc.visibility = 'visible'
AND (
EXISTS (
SELECT 1 FROM library_collection_libraries lcl
WHERE lcl.collection_id = lc.id AND lcl.library_id = ANY($1)
)
OR (
NOT EXISTS (SELECT 1 FROM library_collection_libraries lcl WHERE lcl.collection_id = lc.id)
AND lc.library_id = ANY($1)
)
)
)`
var exists bool
if err := r.pool.QueryRow(ctx, query, libraryIDs).Scan(&exists); err != nil {
return false, fmt.Errorf("checking for visible library collections: %w", err)
}
return exists, nil
}
func (r *LibraryCollectionRepository) Update(ctx context.Context, input UpdateLibraryCollectionInput) error {
var (
sets []string
@@ -0,0 +1,172 @@
package jellycompat
import (
"bytes"
"image/png"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestRenderCollectionPosterPNG(t *testing.T) {
got, err := generatedCollectionPoster("My Favorite Films")
if err != nil {
t.Fatalf("render poster: %v", err)
}
img, err := png.Decode(bytes.NewReader(got))
if err != nil {
t.Fatalf("decode poster PNG: %v", err)
}
if w := img.Bounds().Dx(); w != generatedPosterWidth {
t.Fatalf("poster width = %d, want %d", w, generatedPosterWidth)
}
if h := img.Bounds().Dy(); h != generatedPosterHeight {
t.Fatalf("poster height = %d, want %d", h, generatedPosterHeight)
}
cached, err := generatedCollectionPoster("My Favorite Films")
if err != nil {
t.Fatalf("render cached poster: %v", err)
}
if !bytes.Equal(got, cached) {
t.Fatal("cached poster bytes differ from first render")
}
}
func TestServeCollectionImageServesBundledTemplatePoster(t *testing.T) {
const secret = "image-secret"
codec := NewResourceIDCodec()
collectionID := "129510738770395144"
routeID := codec.EncodeStringID(EncodedIDCollection, collectionID)
posterPath := "/images/collection-templates/tmdb_on_the_air.jpg"
jpegBytes := []byte("\xff\xd8\xfffake-jpeg-bytes")
collection := &models.LibraryCollection{
ID: collectionID,
Title: "On The Air",
Visibility: "visible",
PosterURL: posterPath,
}
tag := newImageTagSigner(secret).Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, posterPath, "", time.Time{}),
"",
)
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, time.Now),
imageTags: newImageTagSigner(secret),
collections: &fakeCollectionSource{collections: []*models.LibraryCollection{collection}},
frontendFS: fstest.MapFS{
"images/collection-templates/tmdb_on_the_air.jpg": {Data: jpegBytes},
},
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?fillHeight=360&fillWidth=360&tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !bytes.Equal(rec.Body.Bytes(), jpegBytes) {
t.Fatal("served body does not match bundled asset bytes")
}
if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("Content-Type = %q, want image/jpeg", ct)
}
}
func TestServeCollectionImageGeneratesFallbackWhenNoPoster(t *testing.T) {
const secret = "image-secret"
codec := NewResourceIDCodec()
collectionID := "abc123"
routeID := codec.EncodeStringID(EncodedIDCollection, collectionID)
collection := &models.LibraryCollection{
ID: collectionID,
Title: "Hidden Gems",
Visibility: "visible",
}
tag := newImageTagSigner(secret).Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, generatedPosterSeed(collection.Title), "", time.Time{}),
"",
)
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, time.Now),
imageTags: newImageTagSigner(secret),
collections: &fakeCollectionSource{collections: []*models.LibraryCollection{collection}},
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag="+tag, nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Fatalf("Content-Type = %q, want image/png", ct)
}
if _, err := png.Decode(bytes.NewReader(rec.Body.Bytes())); err != nil {
t.Fatalf("generated fallback is not a valid PNG: %v", err)
}
}
func TestServeCollectionImageRejectsBadTagWithoutSession(t *testing.T) {
const secret = "image-secret"
codec := NewResourceIDCodec()
collectionID := "abc123"
routeID := codec.EncodeStringID(EncodedIDCollection, collectionID)
collection := &models.LibraryCollection{ID: collectionID, Title: "X", Visibility: "visible"}
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, time.Now),
imageTags: newImageTagSigner(secret),
collections: &fakeCollectionSource{collections: []*models.LibraryCollection{collection}},
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+routeID+"/Images/Primary?tag=deadbeef", nil)
req = withImageRouteParams(req, routeID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestServeCollectionsViewImageGeneratesTile(t *testing.T) {
const secret = "image-secret"
codec := NewResourceIDCodec()
tag := newImageTagSigner(secret).Tag(
imageTagSeed(collectionsViewID, "Primary", compatCardImageSize, generatedPosterSeed(collectionsViewCaption), "", time.Time{}),
"",
)
h := &ImagesHandler{
codec: codec,
images: NewImageCache(time.Hour, time.Now),
imageTags: newImageTagSigner(secret),
}
req := httptest.NewRequest(http.MethodGet, "/Items/"+collectionsViewID+"/Images/Primary?tag="+tag, nil)
req = withImageRouteParams(req, collectionsViewID, "Primary")
rec := httptest.NewRecorder()
h.HandleItemImage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if _, err := png.Decode(bytes.NewReader(rec.Body.Bytes())); err != nil {
t.Fatalf("collections-view tile is not a valid PNG: %v", err)
}
}
+112 -10
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
@@ -20,6 +22,96 @@ type collectionSource interface {
ListAll(ctx context.Context, libraryID *int, opts catalog.ListLibraryCollectionsOptions) ([]*models.LibraryCollection, error)
GetByID(ctx context.Context, id string) (*models.LibraryCollection, error)
ListItems(ctx context.Context, collectionID string) ([]*models.LibraryCollectionItem, error)
AnyVisibleInLibraries(ctx context.Context, libraryIDs []int) (bool, error)
}
// collectionsViewID is the canonical Jellyfin "Collections" (boxsets)
// CollectionFolder GUID. It is stable across all Jellyfin servers, so clients
// recognise it as the box-set library; Silo reuses the same constant rather
// than minting a per-server ID. Emitted in the compact 32-char form Jellyfin
// uses for these views; isCollectionsViewID tolerates the dashed form clients
// may echo back as a ParentId.
const collectionsViewID = "9d7ad6afe9afa2dab1a2f6e00ad28fa6"
var collectionsViewUUID = uuid.MustParse(collectionsViewID)
// isCollectionsViewID reports whether raw refers to the synthetic Collections
// view, comparing parsed UUIDs so the compact and dashed forms both match.
func isCollectionsViewID(raw string) bool {
if raw == "" {
return false
}
parsed, err := uuid.Parse(raw)
return err == nil && parsed == collectionsViewUUID
}
// idsRequestCollectionsView reports whether a raw Ids= param references the
// synthetic Collections view. The sentinel decodes to neither an item nor a
// collection, so parseItemsQuery drops it; this lets the /Items?Ids= path
// re-hydrate the CollectionFolder the same way clients re-hydrate libraries.
func idsRequestCollectionsView(r *http.Request) bool {
for _, raw := range newCaseInsensitiveQuery(r.URL.Query()).Values("Ids") {
for part := range strings.SplitSeq(raw, ",") {
if isCollectionsViewID(strings.TrimSpace(part)) {
return true
}
}
}
return false
}
// collectionsView builds the synthetic CollectionFolder that wraps the server's
// library collections, exposing them as a top-level Jellyfin library whose
// children are BoxSets (CollectionType "boxsets"). It holds no per-collection
// state and never touches the database; the empty-tab gate lives in
// collectionsViewVisible. ChildCount is intentionally left zero (omitempty):
// counting members would re-run the heavy ListAll on every /UserViews, and an
// unwatched badge would need per-user state across every collection member.
func (h *ItemsHandler) collectionsView() baseItemDTO {
// Advertise a Primary image tag so clients fetch the generated "Collections"
// gradient tile; the seed matches serveCollectionsViewImage.
primaryTag := h.mapper.imageTagSigner.Tag(
imageTagSeed(collectionsViewID, "Primary", compatCardImageSize, generatedPosterSeed(collectionsViewCaption), "", time.Time{}),
generatedPosterSeed(collectionsViewCaption),
)
posterAspect := 2.0 / 3.0 // portrait tile; match the generated poster so clients don't square-crop
return baseItemDTO{
ID: collectionsViewID,
Type: "CollectionFolder",
CollectionType: "boxsets",
MediaType: "Unknown",
IsFolder: true,
Name: "Collections",
ServerID: h.mapper.serverID,
SortName: "collections",
PrimaryImageAspectRatio: &posterAspect,
ImageTags: map[string]string{"Primary": primaryTag},
UserData: &itemUserDataDTO{
Key: collectionsViewID,
ItemID: collectionsViewID,
},
}
}
// collectionsViewVisible reports whether the Collections view should appear in
// the session's library list. It is shown only when at least one collection is
// visible to the session, via an index-only EXISTS probe scoped to the
// libraries the session can already see. A probe error fails closed (no tab)
// rather than failing the whole /UserViews response.
func (h *ItemsHandler) collectionsViewVisible(ctx context.Context, libraries []upstreamUserLibrary) bool {
if h.collections == nil {
return false
}
ids := make([]int, 0, len(libraries))
for _, lib := range libraries {
ids = append(ids, lib.ID)
}
visible, err := h.collections.AnyVisibleInLibraries(ctx, ids)
if err != nil {
slog.DebugContext(ctx, "jellycompat collections view existence check failed", "error", err)
return false
}
return visible
}
// smartCollectionQueryExecutor resolves a smart (live-query) collection's members
@@ -107,18 +199,28 @@ func (h *ItemsHandler) boxSetFromCollection(ctx context.Context, c *models.Libra
imageTagSeed(routeID, "Primary", compatCardImageSize, c.PosterURL, "", time.Time{}),
posterURL,
)
} else {
// No stored poster: advertise a Primary tag anyway so clients request the
// generated gradient fallback instead of showing a blank card. The seed
// matches collectionImageTagSeed's generated branch.
imgTags["Primary"] = h.mapper.imageTagSigner.Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, generatedPosterSeed(c.Title), "", time.Time{}),
generatedPosterSeed(c.Title),
)
}
posterAspect := 2.0 / 3.0 // portrait poster; without it clients square-crop the card
dto := baseItemDTO{
ID: routeID,
Type: "BoxSet",
IsFolder: true,
Name: c.Title,
ServerID: h.mapper.serverID,
Overview: c.Description,
SortName: strings.ToLower(c.Title),
ChildCount: c.ItemCount,
RecursiveItemCount: c.ItemCount,
ImageTags: imgTags,
ID: routeID,
Type: "BoxSet",
IsFolder: true,
Name: c.Title,
ServerID: h.mapper.serverID,
Overview: c.Description,
SortName: strings.ToLower(c.Title),
ChildCount: c.ItemCount,
RecursiveItemCount: c.ItemCount,
ImageTags: imgTags,
PrimaryImageAspectRatio: &posterAspect,
UserData: &itemUserDataDTO{
Key: routeID,
ItemID: routeID,
@@ -62,6 +62,30 @@ func (f *fakeCollectionSource) ListItems(_ context.Context, collectionID string)
return f.items[collectionID], nil
}
func (f *fakeCollectionSource) AnyVisibleInLibraries(_ context.Context, libraryIDs []int) (bool, error) {
set := make(map[int]struct{}, len(libraryIDs))
for _, id := range libraryIDs {
set[id] = struct{}{}
}
for _, c := range f.collections {
if c.Visibility != "visible" {
continue
}
if len(c.LibraryIDs) == 0 {
if _, ok := set[c.LibraryID]; ok {
return true, nil
}
continue
}
for _, id := range c.LibraryIDs {
if _, ok := set[id]; ok {
return true, nil
}
}
}
return false, nil
}
// librariesContentService serves a fixed library list; other methods panic.
type librariesContentService struct {
countingContentService
@@ -374,3 +398,154 @@ func TestParseItemsQuery_BoxSetFlags(t *testing.T) {
t.Fatalf("expected sortExplicit=true with SortBy")
}
}
func TestUserViews_PrependsCollectionsViewWhenVisible(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible", ItemCount: 3},
},
}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{
{ID: 1, Name: "Movies", Type: "movies"},
}, nil)
result := performItemsRequest(t, h, "/Items")
if len(result.Items) != 2 {
t.Fatalf("expected Collections view + 1 library, got %+v", result.Items)
}
view := result.Items[0]
if view.ID != collectionsViewID {
t.Fatalf("expected Collections view first with ID %s, got %+v", collectionsViewID, view)
}
if view.Type != "CollectionFolder" || view.CollectionType != "boxsets" || !view.IsFolder || view.Name != "Collections" {
t.Fatalf("unexpected Collections view DTO: %+v", view)
}
if view.ChildCount != 0 {
t.Fatalf("expected ChildCount omitted (0), got %d", view.ChildCount)
}
if result.Items[1].Name != "Movies" {
t.Fatalf("expected real library after the Collections view, got %+v", result.Items[1])
}
}
func TestUserViews_OmitsCollectionsViewWhenNoVisibleCollections(t *testing.T) {
// Only collection lives in a library the session cannot see (library 2).
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "102", LibraryID: 2, Title: "Audiobook Picks", Visibility: "visible"},
{ID: "103", LibraryID: 1, Title: "Hidden", Visibility: "hidden"},
},
}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{
{ID: 1, Name: "Movies", Type: "movies"},
}, nil)
result := performItemsRequest(t, h, "/Items")
if len(result.Items) != 1 || result.Items[0].Name != "Movies" {
t.Fatalf("expected only the Movies library, got %+v", result.Items)
}
}
func TestUserViews_OmitsCollectionsViewWhenSourceNil(t *testing.T) {
h := newCollectionsTestHandler(&fakeCollectionSource{}, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, nil)
h.collections = nil // exercise the no-collection-source path (e.g. a DB-less deployment)
result := performItemsRequest(t, h, "/Items")
for _, item := range result.Items {
if item.ID == collectionsViewID {
t.Fatalf("did not expect Collections view without a collection source: %+v", result.Items)
}
}
}
func TestHandleItems_CollectionsViewParentHonorsTypeFilter(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible", ItemCount: 3},
},
}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, nil)
// A non-BoxSet type filter has no direct children under the view.
empty := performItemsRequest(t, h, "/Items?ParentId="+collectionsViewID+"&IncludeItemTypes=Movie")
if empty.TotalRecordCount != 0 || len(empty.Items) != 0 {
t.Fatalf("expected empty result for IncludeItemTypes=Movie, got %+v", empty.Items)
}
// An explicit BoxSet filter still lists the collections.
boxsets := performItemsRequest(t, h, "/Items?ParentId="+collectionsViewID+"&IncludeItemTypes=BoxSet")
if len(boxsets.Items) != 1 || boxsets.Items[0].Type != "BoxSet" {
t.Fatalf("expected the Marvel BoxSet, got %+v", boxsets.Items)
}
}
func TestHandleItems_CollectionsViewRehydratedByIds(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible"},
},
}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{{ID: 1, Name: "Movies", Type: "movies"}}, nil)
result := performItemsRequest(t, h, "/Items?Ids="+collectionsViewID)
if len(result.Items) != 1 {
t.Fatalf("expected the Collections view, got %+v", result.Items)
}
if result.Items[0].ID != collectionsViewID || result.Items[0].Type != "CollectionFolder" {
t.Fatalf("unexpected re-hydrated view: %+v", result.Items[0])
}
}
func TestHandleItems_CollectionsViewParentListsBoxSets(t *testing.T) {
collections := &fakeCollectionSource{
collections: []*models.LibraryCollection{
{ID: "101", LibraryID: 1, Title: "Marvel", Visibility: "visible", ItemCount: 3},
{ID: "104", LibraryID: 3, Title: "Shows Sets", Visibility: "visible", ItemCount: 2},
{ID: "102", LibraryID: 2, Title: "Hidden Library Set", Visibility: "visible"},
},
}
h := newCollectionsTestHandler(collections, []upstreamUserLibrary{
{ID: 1, Name: "Movies", Type: "movies"},
{ID: 3, Name: "Shows", Type: "series"},
}, nil)
// Compact and dashed forms both resolve to the Collections view.
for _, parentID := range []string{collectionsViewID, "9d7ad6af-e9af-a2da-b1a2-f6e00ad28fa6"} {
result := performItemsRequest(t, h, "/Items?ParentId="+parentID)
if result.TotalRecordCount != 2 || len(result.Items) != 2 {
t.Fatalf("ParentId %s: expected the 2 visible collections, got %+v", parentID, result.Items)
}
for _, item := range result.Items {
if item.Type != "BoxSet" || !item.IsFolder {
t.Fatalf("ParentId %s: expected BoxSet children, got %+v", parentID, item)
}
}
if collections.listAllLib != nil {
t.Fatalf("ParentId %s: expected unscoped ListAll, got library %v", parentID, *collections.listAllLib)
}
}
}
func TestHandleItem_CollectionsViewReturnsCollectionFolder(t *testing.T) {
h := newCollectionsTestHandler(&fakeCollectionSource{}, nil, nil)
req := httptest.NewRequest("GET", "/Items/"+collectionsViewID, nil)
ctx := context.WithValue(req.Context(), compatSessionKey, collectionsTestSession())
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", collectionsViewID)
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.HandleItem(rec, req)
if rec.Code != 200 {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var view baseItemDTO
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if view.ID != collectionsViewID || view.Type != "CollectionFolder" || view.CollectionType != "boxsets" {
t.Fatalf("unexpected Collections view: %+v", view)
}
}
+171 -46
View File
@@ -3,8 +3,11 @@ package jellycompat
import (
"context"
"errors"
"io/fs"
"mime"
"net/http"
"net/url"
"path"
"strings"
"time"
@@ -32,6 +35,11 @@ type ImagesHandler struct {
// collections is optional; when set, BoxSet (library collection) artwork
// resolves durably instead of depending on the in-memory image cache.
collections collectionSource
// frontendFS is optional; when set, app-relative artwork references (bundled
// collection-template posters like "/images/collection-templates/x.jpg") are
// served straight from the embedded frontend assets. Without it those paths
// have no fetchable origin on the compat surface.
frontendFS fs.FS
}
type imageItemRepository interface {
@@ -87,6 +95,20 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
routeID = canonicalRouteID
r = withCompatImageProxyRouteRequest(r)
}
// The synthetic Collections library tile and individual BoxSets resolve to
// generated or bundled artwork that the URL-redirect path below cannot serve,
// so they own a dedicated handler that authorizes via the signed tag or the
// session and writes bytes directly.
if isCollectionsViewID(routeID) {
h.serveCollectionsViewImage(w, r, imageType, tag)
return
}
if collectionID, err := h.codec.DecodeStringID(EncodedIDCollection, routeID); err == nil {
h.serveCollectionImage(w, r, routeID, imageType, tag, collectionID)
return
}
if tag != "" {
imageURL, ok, err := h.resolveItemImageURLFromTag(r.Context(), routeID, imageType, imageSize, tag)
if err != nil {
@@ -126,11 +148,6 @@ func (h *ImagesHandler) HandleItemImage(w http.ResponseWriter, r *http.Request)
return
}
if collectionID, err := h.codec.DecodeStringID(EncodedIDCollection, routeID); err == nil {
h.handleCollectionImage(w, r, session, routeID, imageType, imageSize, collectionID)
return
}
contentID, err := decodeContentID(h.codec, routeID)
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
@@ -268,9 +285,9 @@ func (h *ImagesHandler) resolveItemImageURLFromTag(ctx context.Context, routeID,
if libraryID, err := h.codec.DecodeIntID(EncodedIDLibrary, routeID); err == nil {
return h.resolveLibraryImageURLFromTag(ctx, routeID, int(libraryID), imageType, imageSize, tag)
}
if collectionID, err := h.codec.DecodeStringID(EncodedIDCollection, routeID); err == nil {
return h.resolveCollectionImageURLFromTag(ctx, routeID, collectionID, imageType, tag)
}
// Collection (BoxSet) and Collections-view artwork are intercepted earlier in
// HandleItemImage by serveCollectionImage / serveCollectionsViewImage, so they
// never reach this generic resolver.
contentID, err := decodeContentID(h.codec, routeID)
if err != nil {
return catalog.ResolvedImageURL{}, false, nil
@@ -304,34 +321,31 @@ func (h *ImagesHandler) presignCollectionArtwork(ctx context.Context, path strin
return h.presignLibraryPosterURL(ctx, path)
}
// resolveCollectionImageURLFromTag serves tag-authenticated BoxSet artwork.
// The tag must match the stable seed boxSetFromCollection signs.
func (h *ImagesHandler) resolveCollectionImageURLFromTag(ctx context.Context, routeID, collectionID, imageType, tag string) (catalog.ResolvedImageURL, bool, error) {
if h.collections == nil {
return catalog.ResolvedImageURL{}, false, nil
// collectionImageTagSeed returns the tag seed boxSetFromCollection signs for the
// given collection and compat image type, plus whether that type is served at
// all. Primary always resolves (a generated poster backs collections without
// stored art); Backdrop only when a stored backdrop exists.
func collectionImageTagSeed(routeID, imageType string, c *models.LibraryCollection) (string, bool) {
switch imageType {
case "Primary":
if key := strings.TrimSpace(c.PosterURL); key != "" {
return imageTagSeed(routeID, "Primary", compatCardImageSize, key, "", time.Time{}), true
}
return imageTagSeed(routeID, "Primary", compatCardImageSize, generatedPosterSeed(c.Title), "", time.Time{}), true
case "Backdrop":
if key := strings.TrimSpace(c.BackdropURL); key != "" {
return imageTagSeed(routeID, "Backdrop", compatCardImageSize, key, "", time.Time{}), true
}
}
collection, err := h.collections.GetByID(ctx, collectionID)
if err != nil || collection == nil {
return catalog.ResolvedImageURL{}, false, nil
}
key := collectionArtworkKey(collection, imageType)
if key == "" || !h.imageTags.Equal(
imageTagSeed(routeID, imageType, compatCardImageSize, key, "", time.Time{}),
"",
tag,
) {
return catalog.ResolvedImageURL{}, false, nil
}
imageURL := h.presignCollectionArtwork(ctx, key)
if imageURL == "" {
return catalog.ResolvedImageURL{}, false, nil
}
return catalog.ResolvedImageURL{URL: imageURL}, true, nil
return "", false
}
// handleCollectionImage serves session-authenticated BoxSet artwork, applying
// the same visibility rules as the BoxSet item endpoints.
func (h *ImagesHandler) handleCollectionImage(w http.ResponseWriter, r *http.Request, session *Session, routeID, imageType, imageSize, collectionID string) {
// serveCollectionImage serves BoxSet artwork. It authorizes via the signed tag
// (a capability minted only for visible collections) or, when no tag is given,
// via an authenticated session whose libraries include the collection. Stored
// artwork is presigned/served as before; collections without a usable poster
// fall back to a generated gradient poster captioned with the title.
func (h *ImagesHandler) serveCollectionImage(w http.ResponseWriter, r *http.Request, routeID, imageType, tag, collectionID string) {
if h.collections == nil {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
@@ -349,22 +363,97 @@ func (h *ImagesHandler) handleCollectionImage(w http.ResponseWriter, r *http.Req
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
visible, err := visibleLibraryIDSet(r.Context(), h.content, session)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
if !collectionVisible(collection, visible) {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
imageURL := h.presignCollectionArtwork(r.Context(), collectionArtworkKey(collection, imageType))
if imageURL == "" {
seed, served := collectionImageTagSeed(routeID, imageType, collection)
if !served {
writeError(w, http.StatusNotFound, "NotFound", "Image not found")
return
}
h.images.RememberSized(routeID, imageType, imageURL, imageSize)
h.serveImageURL(w, r, imageURL)
authorized := tag != "" && h.imageTags != nil && h.imageTags.Equal(seed, "", tag)
if !authorized {
ok, err := h.collectionVisibleToRequest(r, collection)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
if !ok {
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
return
}
}
if key := collectionArtworkKey(collection, imageType); key != "" {
if imageURL := h.presignCollectionArtwork(r.Context(), key); imageURL != "" {
h.serveImageURL(w, r, imageURL)
return
}
}
if imageType != "Primary" {
writeError(w, http.StatusNotFound, "NotFound", "Image not found")
return
}
h.serveGeneratedPoster(w, collection.Title)
}
// collectionVisibleToRequest reports whether the request's session may see the
// collection. A missing session resolves to not-visible (anonymous image GETs
// without a valid tag get a clean 404).
func (h *ImagesHandler) collectionVisibleToRequest(r *http.Request, collection *models.LibraryCollection) (bool, error) {
session := SessionFromContext(r.Context())
if session == nil && h.sessions != nil {
if token, ok := ExtractToken(r); ok {
session, _ = h.sessions.Get(token)
}
}
if session == nil {
return false, nil
}
visible, err := visibleLibraryIDSet(r.Context(), h.content, session)
if err != nil {
return false, err
}
return collectionVisible(collection, visible), nil
}
// serveCollectionsViewImage serves the synthetic Collections library tile. It is
// always a generated "Collections" poster, authorized by the signed tag or any
// authenticated session.
func (h *ImagesHandler) serveCollectionsViewImage(w http.ResponseWriter, r *http.Request, imageType, tag string) {
if imageType != "Primary" {
writeError(w, http.StatusNotFound, "NotFound", "Image not found")
return
}
seed := imageTagSeed(collectionsViewID, "Primary", compatCardImageSize, generatedPosterSeed(collectionsViewCaption), "", time.Time{})
authorized := tag != "" && h.imageTags != nil && h.imageTags.Equal(seed, "", tag)
if !authorized {
session := SessionFromContext(r.Context())
if session == nil && h.sessions != nil {
if token, ok := ExtractToken(r); ok {
session, _ = h.sessions.Get(token)
}
}
if session == nil {
writeError(w, http.StatusNotFound, "NotFound", "Image not found")
return
}
}
h.serveGeneratedPoster(w, collectionsViewCaption)
}
// serveGeneratedPoster renders (or reuses) a gradient poster captioned with text
// and writes it as a cacheable PNG.
func (h *ImagesHandler) serveGeneratedPoster(w http.ResponseWriter, caption string) {
pngBytes, err := generatedCollectionPoster(caption)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalError", "Failed to render image")
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(pngBytes)
}
func (h *ImagesHandler) resolveLibraryImageURLFromTag(ctx context.Context, routeID string, libraryID int, imageType, _ string, tag string) (catalog.ResolvedImageURL, bool, error) {
@@ -522,6 +611,13 @@ func firstResolvedImageURL(values ...catalog.ResolvedImageURL) catalog.ResolvedI
}
func (h *ImagesHandler) serveImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
// App-relative references (bundled collection-template posters) have no
// remote origin to redirect or proxy to, so serve their bytes from the
// embedded frontend assets instead.
if strings.HasPrefix(imageURL, "/") {
h.serveBundledAsset(w, imageURL)
return
}
if shouldProxyCompatImageRequest(r) {
h.proxyImageURL(w, r, imageURL)
return
@@ -529,6 +625,35 @@ func (h *ImagesHandler) serveImageURL(w http.ResponseWriter, r *http.Request, im
h.redirectImageURL(w, r, imageURL)
}
// serveBundledAsset serves an app-relative asset (e.g.
// "/images/collection-templates/x.jpg") straight from the embedded frontend
// filesystem. A missing FS or file degrades to a clean 404.
func (h *ImagesHandler) serveBundledAsset(w http.ResponseWriter, assetPath string) {
if h.frontendFS == nil {
writeError(w, http.StatusNotFound, "NotFound", "Image not found")
return
}
clean := path.Clean("/" + strings.TrimPrefix(assetPath, "/"))
rel := strings.TrimPrefix(clean, "/")
if rel == "" || strings.HasPrefix(rel, "../") {
writeError(w, http.StatusNotFound, "NotFound", "Image not found")
return
}
data, err := fs.ReadFile(h.frontendFS, rel)
if err != nil {
writeError(w, http.StatusNotFound, "NotFound", "Image not found")
return
}
contentType := mime.TypeByExtension(path.Ext(rel))
if contentType == "" {
contentType = http.DetectContentType(data)
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=86400")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func (h *ImagesHandler) redirectImageURL(w http.ResponseWriter, r *http.Request, imageURL string) {
if _, err := parseRemoteImageURL(imageURL); err != nil {
writeError(w, http.StatusBadGateway, "UpstreamError", "Failed to load image")
+52 -20
View File
@@ -92,18 +92,16 @@ func (h *ItemsHandler) HandleViews(w http.ResponseWriter, r *http.Request) {
return
}
libraries, err := h.content.ListUserLibraries(r.Context(), session)
h.handleViewsResponse(w, r, session)
}
// handleViewsResponse returns the user's library views as CollectionFolder items.
func (h *ItemsHandler) handleViewsResponse(w http.ResponseWriter, r *http.Request, session *Session) {
items, err := h.userViews(r.Context(), session)
if err != nil {
writeCompatUpstreamError(w, err)
return
}
items := make([]baseItemDTO, 0, len(libraries))
for _, library := range libraries {
dto := h.mapper.viewFromLibrary(library)
h.rememberLibraryImages(library, dto.ID)
items = append(items, dto)
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
@@ -111,25 +109,25 @@ func (h *ItemsHandler) HandleViews(w http.ResponseWriter, r *http.Request) {
})
}
// handleViewsResponse returns the user's library views as CollectionFolder items.
func (h *ItemsHandler) handleViewsResponse(w http.ResponseWriter, r *http.Request, session *Session) {
libraries, err := h.content.ListUserLibraries(r.Context(), session)
// userViews builds the session's library views as CollectionFolder items. The
// synthetic "Collections" view is prepended (first library) when the session
// can see at least one collection; see collectionsView/collectionsViewVisible.
func (h *ItemsHandler) userViews(ctx context.Context, session *Session) ([]baseItemDTO, error) {
libraries, err := h.content.ListUserLibraries(ctx, session)
if err != nil {
writeCompatUpstreamError(w, err)
return
return nil, err
}
items := make([]baseItemDTO, 0, len(libraries))
items := make([]baseItemDTO, 0, len(libraries)+1)
if h.collectionsViewVisible(ctx, libraries) {
items = append(items, h.collectionsView())
}
for _, library := range libraries {
dto := h.mapper.viewFromLibrary(library)
h.rememberLibraryImages(library, dto.ID)
items = append(items, dto)
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
StartIndex: 0,
})
return items, nil
}
// HandleItems serves GET /Items.
@@ -145,8 +143,28 @@ func (h *ItemsHandler) HandleItems(w http.ResponseWriter, r *http.Request) {
}
query := parseItemsQuery(r, h.codec)
// Browsing the synthetic Collections view lists its BoxSets. The view ID is
// a fixed Jellyfin sentinel (not a codec-encoded ID), so it is matched here
// at the router rather than decoded in parseItemsQuery. With no
// parentLibraryID set, handleBoxSetsList lists every visible collection
// across libraries.
if isCollectionsViewID(newCaseInsensitiveQuery(r.URL.Query()).Get("ParentId")) {
// The view's only children are BoxSets. Clients commonly omit
// IncludeItemTypes when browsing a library by ParentId, so an absent
// filter still lists BoxSets; but an explicit filter that excludes
// BoxSet (e.g. IncludeItemTypes=Movie) has no direct children here and
// returns empty rather than the BoxSet list.
if query.hasItemTypeFilter && !query.wantsBoxSets {
writeJSON(w, http.StatusOK, emptyQueryResult(query.startIndex))
return
}
h.handleBoxSetsList(w, r, session, query)
return
}
switch {
case len(query.specificIDs) > 0 || len(query.specificCollectionIDs) > 0:
case len(query.specificIDs) > 0 || len(query.specificCollectionIDs) > 0 || idsRequestCollectionsView(r):
h.handleSpecificItems(w, r, session, query)
case query.parentCollectionID != "":
h.handleBoxSetChildren(w, r, session, query)
@@ -267,6 +285,14 @@ func (h *ItemsHandler) HandleItem(w http.ResponseWriter, r *http.Request) {
rawID := chi.URLParam(r, "id")
// The synthetic Collections view is a fixed sentinel ID, not a codec-encoded
// one; clients fetch the CollectionFolder by ID (e.g. Infuse) before browsing
// its children, so resolve it before the codec decode attempts.
if isCollectionsViewID(rawID) {
writeJSON(w, http.StatusOK, h.collectionsView())
return
}
// Handle library IDs — clients like Infuse request /Items/{id} for
// CollectionFolder items using the library UUID from /UserViews.
if libraryID, err := h.codec.DecodeIntID(EncodedIDLibrary, rawID); err == nil {
@@ -1929,6 +1955,12 @@ func (h *ItemsHandler) handleSpecificItems(w http.ResponseWriter, r *http.Reques
}
items = append(items, boxSets...)
// Ids= may also reference the synthetic Collections view; prepend its DTO so
// clients re-hydrate the CollectionFolder the same way as a real library.
if idsRequestCollectionsView(r) {
items = append([]baseItemDTO{h.collectionsView()}, items...)
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
+249
View File
@@ -0,0 +1,249 @@
package jellycompat
import (
"bytes"
"crypto/sha1"
"fmt"
"image"
"image/color"
"image/png"
"math"
"strings"
"sync"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
// Generated poster geometry. Collection/BoxSet artwork is a 2:3 portrait poster
// in Jellyfin; clients down-scale to whatever fill size they request (the
// observed 360x360 probe among them), so one fixed render serves every size.
const (
generatedPosterWidth = 400
generatedPosterHeight = 600
)
// generatedPosterFace is the parsed font face used for poster captions. Parsed
// once at init (gobold is embedded), so a parse failure here is impossible to
// miss and never reaches a request.
var generatedPosterFont = mustParseGeneratedPosterFont()
func mustParseGeneratedPosterFont() *opentype.Font {
f, err := opentype.Parse(gobold.TTF)
if err != nil {
panic(fmt.Sprintf("jellycompat: parse poster font: %v", err))
}
return f
}
// generatedPosterCache memoizes rendered posters by caption text. The render
// routes are gated by a signed tag or an authenticated, visibility-checked
// session, so the keyspace is bounded by the real collection set rather than
// arbitrary caller input; a simple cap with reset guards against unbounded
// growth without the complexity of an LRU.
var (
generatedPosterCacheMu sync.Mutex
generatedPosterCache = map[string][]byte{}
)
const generatedPosterCacheCap = 1024
// collectionsViewCaption is the caption rendered on the synthetic Collections
// library tile.
const collectionsViewCaption = "Collections"
// generatedPosterSeed builds the stable artwork-key surrogate used in image tag
// seeds for collections (and the Collections view) that fall back to a generated
// poster. Keeping it in one place ensures the DTO signer and the image handler
// derive the same tag.
func generatedPosterSeed(caption string) string {
return "generated-poster:v1:" + strings.TrimSpace(caption)
}
// generatedCollectionPoster returns the PNG bytes for a gradient poster
// captioned with text, rendering and caching it on first use.
func generatedCollectionPoster(text string) ([]byte, error) {
text = strings.TrimSpace(text)
if text == "" {
text = "Collection"
}
generatedPosterCacheMu.Lock()
if cached, ok := generatedPosterCache[text]; ok {
generatedPosterCacheMu.Unlock()
return cached, nil
}
generatedPosterCacheMu.Unlock()
pngBytes, err := renderCollectionPosterPNG(text)
if err != nil {
return nil, err
}
generatedPosterCacheMu.Lock()
if len(generatedPosterCache) >= generatedPosterCacheCap {
generatedPosterCache = map[string][]byte{}
}
generatedPosterCache[text] = pngBytes
generatedPosterCacheMu.Unlock()
return pngBytes, nil
}
// renderCollectionPosterPNG draws a diagonal gradient (hue derived from the
// caption so each collection gets a stable, distinct backdrop) with the caption
// centered in white text and a black outline for legibility on any background.
func renderCollectionPosterPNG(text string) ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, generatedPosterWidth, generatedPosterHeight))
drawPosterGradient(img, text)
if err := drawPosterCaption(img, text); err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func drawPosterGradient(img *image.RGBA, seed string) {
hue := float64(posterSeedHue(seed))
top := hslToRGB(hue, 0.32, 0.28)
bottom := hslToRGB(math.Mod(hue+28, 360), 0.30, 0.10)
b := img.Bounds()
w := float64(b.Dx())
h := float64(b.Dy())
denom := w + h
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
t := (float64(x) + float64(y)) / denom
img.SetRGBA(x, y, color.RGBA{
R: lerp(top.R, bottom.R, t),
G: lerp(top.G, bottom.G, t),
B: lerp(top.B, bottom.B, t),
A: 255,
})
}
}
}
func drawPosterCaption(img *image.RGBA, text string) error {
const (
fontSize = 44.0
outline = 2
lineSpace = 1.25
)
face, err := opentype.NewFace(generatedPosterFont, &opentype.FaceOptions{
Size: fontSize,
DPI: 72,
})
if err != nil {
return err
}
defer face.Close()
metrics := face.Metrics()
lineHeight := int(math.Round(float64(metrics.Height.Round()) * lineSpace))
margin := generatedPosterWidth / 10
maxLineWidth := generatedPosterWidth - 2*margin
lines := wrapPosterText(face, text, fixed.I(maxLineWidth))
blockHeight := lineHeight * len(lines)
baselineTop := (generatedPosterHeight-blockHeight)/2 + metrics.Ascent.Round()
drawer := &font.Drawer{Dst: img, Face: face}
for i, line := range lines {
lineWidth := drawer.MeasureString(line)
x := (fixed.I(generatedPosterWidth) - lineWidth) / 2
y := fixed.I(baselineTop + i*lineHeight)
// Outline: stamp the glyphs in black around the target before the
// white fill so the caption stays legible over any gradient.
drawer.Src = image.NewUniform(color.RGBA{A: 255})
for dy := -outline; dy <= outline; dy++ {
for dx := -outline; dx <= outline; dx++ {
if dx == 0 && dy == 0 {
continue
}
drawer.Dot = fixed.Point26_6{X: x + fixed.I(dx), Y: y + fixed.I(dy)}
drawer.DrawString(line)
}
}
drawer.Src = image.NewUniform(color.RGBA{R: 255, G: 255, B: 255, A: 255})
drawer.Dot = fixed.Point26_6{X: x, Y: y}
drawer.DrawString(line)
}
return nil
}
// wrapPosterText greedily wraps text to fit maxWidth, splitting on spaces. A
// single word wider than the line is kept whole (the face down-scales visually
// only via client fill, so over-wide words are accepted rather than truncated).
func wrapPosterText(face font.Face, text string, maxWidth fixed.Int26_6) []string {
words := strings.Fields(text)
if len(words) == 0 {
return []string{text}
}
drawer := &font.Drawer{Face: face}
lines := make([]string, 0, 4)
current := words[0]
for _, word := range words[1:] {
candidate := current + " " + word
if drawer.MeasureString(candidate) <= maxWidth {
current = candidate
continue
}
lines = append(lines, current)
current = word
}
lines = append(lines, current)
return lines
}
func posterSeedHue(seed string) int {
sum := sha1.Sum([]byte(seed))
return (int(sum[0])<<8 | int(sum[1])) % 360
}
func lerp(a, b uint8, t float64) uint8 {
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
return uint8(math.Round(float64(a) + (float64(b)-float64(a))*t))
}
// hslToRGB converts an HSL color (h in [0,360), s and l in [0,1]) to RGB.
func hslToRGB(h, s, l float64) color.RGBA {
c := (1 - math.Abs(2*l-1)) * s
hp := h / 60
x := c * (1 - math.Abs(math.Mod(hp, 2)-1))
var r, g, b float64
switch {
case hp < 1:
r, g, b = c, x, 0
case hp < 2:
r, g, b = x, c, 0
case hp < 3:
r, g, b = 0, c, x
case hp < 4:
r, g, b = 0, x, c
case hp < 5:
r, g, b = x, 0, c
default:
r, g, b = c, 0, x
}
m := l - c/2
return color.RGBA{
R: uint8(math.Round((r + m) * 255)),
G: uint8(math.Round((g + m) * 255)),
B: uint8(math.Round((b + m) * 255)),
A: 255,
}
}
+1
View File
@@ -106,6 +106,7 @@ func NewRouter(deps Dependencies) chi.Router {
}
imagesHandler := NewImagesHandler(deps.ContentService, deps.IDCodec, deps.SessionStore, deps.ImageCache, deps.PersonRepo, deps.DetailSvc, deps.ItemRepo, deps.FolderRepo, deps.SeasonRepo, deps.EpisodeRepo, deps.AccessFilterFn, deps.PosterPresigner, deps.PresignTTL, deps.JWTSecret, deps.HTTPClient)
imagesHandler.collections = itemsHandler.collections
imagesHandler.frontendFS = deps.FrontendFS
displayPrefsHandler := NewDisplayPreferencesHandler(deps.UserStoreProvider)
recsHandler := NewRecommendationsHandler(deps.Recommender, deps.ItemRepo, deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config, deps.AccessFilterFn)
+5 -1
View File
@@ -40,7 +40,11 @@ type Dependencies struct {
LoginResolver loginResolver
Authenticator *Authenticator
WebFS fs.FS
HTTPClient *http.Client
// FrontendFS is the embedded Silo frontend asset filesystem (web/dist),
// used to serve app-relative artwork such as bundled collection-template
// posters that have no remote origin. Optional.
FrontendFS fs.FS
HTTPClient *http.Client
// Direct service dependencies (replaces Client)
ContentService ContentService