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:
+11 -1
View File
@@ -29,6 +29,7 @@ from app.plex.query import (
apply_show_state_filter,
genres_col,
meta_filter_conds,
top_art_keys,
)
from app.plex.tmdb import resolve_recs
from app.routes.plex._shared import _content_libs, _norm_lang
@@ -199,7 +200,16 @@ def _genre_tiles(
).all():
counts[g] = counts.get(g, 0) + c
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:GENRE_TILES]
return [{"value": g, "count": c} for g, c in top]
# A 2×2 poster collage per tile: the top movies in that genre (color hash is the client fallback).
art = top_art_keys(
db,
PlexItem,
movie_ids,
genres_col(PlexItem, lang),
[g for g, _ in top],
extra_conds=(PlexItem.kind == "movie", *meta_filter_conds(PlexItem, p, lang)),
)
return [{"value": g, "count": c, "art": art.get(g, [])} for g, c in top]
def _because_you_watched(