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:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -103,6 +103,16 @@ def test_discover_shape_and_recently_added_order(db, monkeypatch):
|
||||
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
|
||||
|
||||
|
||||
def test_genre_tiles_carry_poster_art(db, monkeypatch):
|
||||
# Each genre tile carries up to 4 poster rating_keys for the 2×2 collage (top_art_keys): the top
|
||||
# movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
art = {g["value"]: g["art"] for g in d["genres"]}
|
||||
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
|
||||
assert art["Comedy"] == ["m2"]
|
||||
|
||||
|
||||
def test_continue_watching_movies_and_shows(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
|
||||
Reference in New Issue
Block a user