From 6e1f79e8a92e0f84023345cb87fc12ed889cc5d0 Mon Sep 17 00:00:00 2001 From: CoffeeKnyte <67730400+CoffeeKnyte@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:20:48 +0800 Subject: [PATCH] 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. --- cmd/silo/main.go | 1 + internal/catalog/library_collection_repo.go | 33 +++ .../jellycompat/collection_images_test.go | 172 ++++++++++++ internal/jellycompat/handlers_collections.go | 122 ++++++++- .../jellycompat/handlers_collections_test.go | 175 ++++++++++++ internal/jellycompat/handlers_images.go | 217 +++++++++++---- internal/jellycompat/handlers_items.go | 72 +++-- internal/jellycompat/poster_gen.go | 249 ++++++++++++++++++ internal/jellycompat/router.go | 1 + internal/jellycompat/server.go | 6 +- 10 files changed, 971 insertions(+), 77 deletions(-) create mode 100644 internal/jellycompat/collection_images_test.go create mode 100644 internal/jellycompat/poster_gen.go diff --git a/cmd/silo/main.go b/cmd/silo/main.go index a2a68b3c..bf3dfea4 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -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. diff --git a/internal/catalog/library_collection_repo.go b/internal/catalog/library_collection_repo.go index e3510304..17f8c7ce 100644 --- a/internal/catalog/library_collection_repo.go +++ b/internal/catalog/library_collection_repo.go @@ -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 diff --git a/internal/jellycompat/collection_images_test.go b/internal/jellycompat/collection_images_test.go new file mode 100644 index 00000000..f6b58492 --- /dev/null +++ b/internal/jellycompat/collection_images_test.go @@ -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) + } +} diff --git a/internal/jellycompat/handlers_collections.go b/internal/jellycompat/handlers_collections.go index 4fe6decb..dec928ca 100644 --- a/internal/jellycompat/handlers_collections.go +++ b/internal/jellycompat/handlers_collections.go @@ -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, diff --git a/internal/jellycompat/handlers_collections_test.go b/internal/jellycompat/handlers_collections_test.go index 742b87d4..9dfcd1a3 100644 --- a/internal/jellycompat/handlers_collections_test.go +++ b/internal/jellycompat/handlers_collections_test.go @@ -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) + } +} diff --git a/internal/jellycompat/handlers_images.go b/internal/jellycompat/handlers_images.go index 5a82002f..669a79a8 100644 --- a/internal/jellycompat/handlers_images.go +++ b/internal/jellycompat/handlers_images.go @@ -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") diff --git a/internal/jellycompat/handlers_items.go b/internal/jellycompat/handlers_items.go index 0532b112..841a047b 100644 --- a/internal/jellycompat/handlers_items.go +++ b/internal/jellycompat/handlers_items.go @@ -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), diff --git a/internal/jellycompat/poster_gen.go b/internal/jellycompat/poster_gen.go new file mode 100644 index 00000000..14f3b549 --- /dev/null +++ b/internal/jellycompat/poster_gen.go @@ -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, + } +} diff --git a/internal/jellycompat/router.go b/internal/jellycompat/router.go index ba71a16f..137c355d 100644 --- a/internal/jellycompat/router.go +++ b/internal/jellycompat/router.go @@ -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) diff --git a/internal/jellycompat/server.go b/internal/jellycompat/server.go index 6a79e32e..be3f6db5 100644 --- a/internal/jellycompat/server.go +++ b/internal/jellycompat/server.go @@ -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