feat(plex): redesign browse tiles — 2-poster montage top, readable label band

The full-bleed 4-poster collage drowned the on-tile text. Split PosterTile in two: the top
half shows up to 2 montage posters, the bottom half is a solid colour-hash band that carries
the label, so the text is always readable. Genre tiles drop the count (just the label now);
collection tiles show the name on one truncated line plus a sub-line of item count · total
runtime. A group with no art falls back to an all-colour tile.

Backend: `/collections` cards gain `total_runtime_s` (a new `runtime_by_key` grouped sum) and
their montage art is capped at 2. Frontend: PosterTile reworked; a `collectionMeta` helper
builds the count·runtime sub-line via the existing formatRuntime.

Verified live: collection and genre tiles render 2 posters over a readable colour band with
the right sub-lines; colour fallback intact for art-less groups.

Gate: tsc/lint/knip/prettier clean, 423 vitest; backend ruff clean, collections tests green
(+ a new art/runtime assertion).
This commit is contained in:
2026-08-23 03:52:21 +02:00
parent da3cb5e694
commit 37d11b602f
7 changed files with 98 additions and 35 deletions
+17
View File
@@ -76,6 +76,23 @@ def top_art_keys(
return out
def runtime_by_key(db, model, lib_ids: list[int], key_col, keys: list[str]) -> dict[str, int]:
"""Total runtime (sum of duration_s, seconds) of the items under each key in `keys` (a member of the
JSONB array `key_col`), as {key: seconds}. ONE grouped query. Used for a collection tile's runtime."""
if not lib_ids or not keys:
return {}
k = func.jsonb_array_elements_text(key_col).label("k")
exp = (
select(func.coalesce(model.duration_s, 0).label("d"), k)
.where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array")
.subquery()
)
rows = db.execute(
select(exp.c.k, func.sum(exp.c.d)).where(exp.c.k.in_(keys)).group_by(exp.c.k)
).all()
return {key: int(total or 0) for key, total in rows}
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.
+16 -5
View File
@@ -16,7 +16,7 @@ from app.models import (
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, top_art_keys
from app.plex.query import like_escape, runtime_by_key, top_art_keys
from app.routes.plex._shared import _content_libs
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -95,11 +95,22 @@ def collections(
func.lower(PlexCollection.title),
)
).scalars().all()
# 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.
# A montage of the top films per tile + the collection's total runtime (color hash + no-runtime is
# the client fallback for show-only / empty collections). Two grouped queries over the 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]}
keys = [c.rating_key for c in rows]
art = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys)
return {
"collections": [
{
**_collection_card(c),
"art": art.get(c.rating_key, []),
"total_runtime_s": runtime.get(c.rating_key, 0),
}
for c in rows
]
}
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: