From da3cb5e694c814ecc157696d1e613b2b3539ee51 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 23 Aug 2026 03:10:36 +0200 Subject: [PATCH] feat(plex): poster collages on the genre + collection browse tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/app/plex/query.py | 37 +++++++++++- backend/app/routes/plex/collections.py | 10 +++- backend/app/routes/plex/discover.py | 12 +++- backend/tests/test_plex_discover.py | 10 ++++ .../src/components/PlexDiscover.dom.test.tsx | 2 +- frontend/src/components/PlexDiscover.tsx | 27 ++++----- frontend/src/components/PosterTile.tsx | 57 +++++++++++++++++++ frontend/src/components/plexBrowseLists.tsx | 53 ++++++----------- frontend/src/lib/api/plex.ts | 4 +- 9 files changed, 153 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/PosterTile.tsx diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 21f33825..d4698502 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -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. diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index 36540b94..f2d15cdd 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -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: diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py index 97ce7417..0e6f448b 100644 --- a/backend/app/routes/plex/discover.py +++ b/backend/app/routes/plex/discover.py @@ -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( diff --git a/backend/tests/test_plex_discover.py b/backend/tests/test_plex_discover.py index 3976af94..161643c7 100644 --- a/backend/tests/test_plex_discover.py +++ b/backend/tests/test_plex_discover.py @@ -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() diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx index 4845ebea..f1848ae4 100644 --- a/frontend/src/components/PlexDiscover.dom.test.tsx +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -78,7 +78,7 @@ const fullPayload = { scope: "both", continue_watching: [card("cw1", "Resume Me")], recently_added: [card("ra1", "Just Added")], - genres: [{ value: "Drama", count: 12 }], + genres: [{ value: "Drama", count: 12, art: [] }], because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] }, }; diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index e1c1d6a1..53b5cb3f 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { RotateCcw } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api"; -import { hueOf, hueTileGradient } from "../lib/hueTile"; +import PosterTile from "./PosterTile"; import { plexLang } from "../i18n"; import { toggleWatchState } from "../lib/plexWatch"; import { usePlex } from "./PlexProvider"; @@ -199,21 +199,16 @@ export default function PlexDiscover({

{t("plex.discover.byGenre")}

- {d.genres.map((g) => { - const hue = hueOf(g.value); - return ( - - ); - })} + {d.genres.map((g) => ( + {g.count}} + onClick={() => onGenre(g.value)} + className="h-20" + /> + ))}
)} diff --git a/frontend/src/components/PosterTile.tsx b/frontend/src/components/PosterTile.tsx new file mode 100644 index 00000000..700ac912 --- /dev/null +++ b/frontend/src/components/PosterTile.tsx @@ -0,0 +1,57 @@ +import { type ReactNode } from "react"; +import { hueOf, hueTileGradient } from "../lib/hueTile"; + +// A browse tile: a 2×2 collage of the group's top poster art (served from /api/plex/image/{key}) under +// a dark scrim with the label, falling back to a color hash when the group has no art — so a tile never +// renders blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles. +export default function PosterTile({ + title, + subtitle, + topRight, + art, + onClick, + className, +}: { + title: string; + subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items") + topRight?: ReactNode; // top-right corner content (a count, or an icon) + art?: string[]; // up to 4 poster rating_keys; fewer/none falls back to the color hash + onClick: () => void; + className?: string; // caller sets the height (e.g. "h-20" / "h-28") +}) { + const posters = (art ?? []).slice(0, 4); + return ( + + ); +} diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx index 91e69634..12040dc1 100644 --- a/frontend/src/components/plexBrowseLists.tsx +++ b/frontend/src/components/plexBrowseLists.tsx @@ -1,45 +1,19 @@ -import { useState, type ReactNode } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { Layers, ListMusic } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api } from "../lib/api"; -import { hueOf, hueTileGradient } from "../lib/hueTile"; import { useDebounced } from "../lib/useDebounced"; import { LoadingState, StateMessage } from "./QueryState"; +import PosterTile from "./PosterTile"; // The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles, // each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the // title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections -// union), so this is a pure front-end surface. Tiles are coloured by a title hash (no per-item art to -// fetch/proxy), matching the Discover genre tiles. - -function Tile({ - title, - count, - icon, - onClick, -}: { - title: string; - count: ReactNode; - icon: ReactNode; - onClick: () => void; -}) { - const hue = hueOf(title); - return ( - - ); -} +// union), so this is a pure front-end surface. Collection tiles show a poster collage of their top +// films (PosterTile), falling back to a title-hash colour; playlists have no server art yet, so they +// take the colour fallback. const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"; @@ -63,12 +37,13 @@ export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void } return (
{playlists.map((p) => ( - } + subtitle={t("plex.browse.items", { count: p.item_count })} + topRight={} onClick={() => onOpen(p.id)} + className="h-28" /> ))}
@@ -108,12 +83,16 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: ) : (
{collections.map((c) => ( - } + art={c.art} + subtitle={ + c.child_count != null ? t("plex.browse.items", { count: c.child_count }) : undefined + } + topRight={} onClick={() => onPick(c.id, c.title)} + className="h-28" /> ))}
diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 262ffe74..9b6171c8 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -184,6 +184,7 @@ export interface PlexCollection { source?: string; editable: boolean; can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection + art?: string[]; // up to 4 poster rating_keys for the browse-tile collage (browse union only) } export interface PlexPlaylist { id: number; @@ -342,7 +343,8 @@ export interface PlexDiscover { scope: string; continue_watching: PlexCard[]; recently_added: PlexCard[]; - genres: { value: string; count: number }[]; // top genres → tap a tile to filter the grid + // top genres → tap a tile to filter the grid; `art` = up to 4 poster rating_keys for the collage + genres: { value: string; count: number; art: string[] }[]; because_you_watched: { anchor: { id: string; title: string }; items: PlexCard[] } | null; }