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")}