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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user