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
+36 -1
View File
@@ -6,7 +6,7 @@ Pure query fragments — every function returns SQLAlchemy expressions or a para
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, case, func, or_, type_coerce
from sqlalchemy import and_, case, func, or_, select, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
TS_CONFIG = "public.unaccent_simple"
@@ -41,6 +41,41 @@ def genres_col(model, lang: str):
return model.genres
def top_art_keys(
db, model, lib_ids: list[int], key_col, keys: list[str], *, per: int = 4, extra_conds=()
) -> dict[str, list[str]]:
"""For each value in `keys` (a member of the JSONB array `key_col` — e.g. a genre value or a
collection rating_key), the rating_keys of its top `per` items (best-rated, then most-recent) as
{key: [rating_key, …]}. ONE windowed query: unnest the array, rank within each key, take the top.
The caller renders posters from /api/plex/image/{rating_key}, so only the keys are returned."""
if not lib_ids or not keys:
return {}
k = func.jsonb_array_elements_text(key_col).label("k")
expanded = (
select(model.rating_key.label("rk"), model.rating.label("r"), model.added_at.label("a"), k)
.where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array", *extra_conds)
.subquery()
)
ranked = select(
expanded.c.rk,
expanded.c.k,
func.row_number()
.over(
partition_by=expanded.c.k,
order_by=(expanded.c.r.desc().nullslast(), expanded.c.a.desc().nullslast()),
)
.label("rn"),
).subquery()
out: dict[str, list[str]] = {}
for rk, key in db.execute(
select(ranked.c.rk, ranked.c.k)
.where(ranked.c.k.in_(keys), ranked.c.rn <= per)
.order_by(ranked.c.k, ranked.c.rn)
).all():
out.setdefault(key, []).append(rk)
return out
def vector_col(model, lang: str):
# RANKING vector. In HU we rank by the Hungarian vector; a row matched only via its English vector
# (see `vector_match`) gets rank 0 and sorts last, which is the intended HU-first ordering.