feat(plex): poster collages on the genre + collection browse tiles

The Browse-by-genre tiles and the Collections browse tiles were flat color-hash
gradients. Give each a 2×2 collage of its top films' poster art, with the color hash
kept as the fallback so a group with no matching art (a show-only or empty collection)
never renders blank.

Backend: a shared `top_art_keys` windowed query returns, per key (a genre value or a
collection rating_key), the top few items' rating_keys — best-rated then newest, in one
query. Wired into the Discover genre tiles and the collections union endpoint; the client
renders the posters from the existing /api/plex/image/{key} endpoint, so no new image
plumbing. Frontend: a shared PosterTile component (collage + scrim + label, color-hash
fallback) replaces the two ad-hoc colored-tile implementations.

Verified live on localdev: genre tiles and collection tiles render real poster collages,
with the color fallback correctly kicking in for groups without art.

Gate: tsc/lint/knip/prettier clean, 423 vitest; backend ruff clean, 11 discover tests
(incl. a new genre-art assertion).
This commit is contained in:
2026-08-23 03:10:36 +02:00
parent 1e5232f47a
commit da3cb5e694
9 changed files with 153 additions and 59 deletions
+8 -2
View File
@@ -9,13 +9,15 @@ from app.auth import admin_user, current_user
from app.db import get_db
from app.models import (
PlexCollection,
PlexItem,
PlexLibrary,
User,
)
from app.plex import sync as plex_sync
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.plex.query import like_escape
from app.plex.query import like_escape, top_art_keys
from app.routes.plex._shared import _content_libs
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -93,7 +95,11 @@ def collections(
func.lower(PlexCollection.title),
)
).scalars().all()
return {"collections": [_collection_card(c) for c in rows]}
# A 2×2 poster collage per tile: the top movies in each collection (color hash is the client
# fallback for show-only / empty collections). One windowed query across the content movie libs.
movie_ids, _show_ids = _content_libs(db)
art = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, [c.rating_key for c in rows])
return {"collections": [{**_collection_card(c), "art": art.get(c.rating_key, [])} for c in rows]}
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: