Files
silo-server/internal/jellycompat/collection_images_test.go
CoffeeKnyteandGitHub 6e1f79e8a9 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.
2026-06-18 10:20:48 -04:00

173 lines
5.5 KiB
Go

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)
}
}