Files
silo-server/internal/jellycompat/poster_gen.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

250 lines
6.9 KiB
Go

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