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 dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
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
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
TS_CONFIG = "public.unaccent_simple"
|
TS_CONFIG = "public.unaccent_simple"
|
||||||
@@ -41,6 +41,41 @@ def genres_col(model, lang: str):
|
|||||||
return model.genres
|
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):
|
def vector_col(model, lang: str):
|
||||||
# RANKING vector. In HU we rank by the Hungarian vector; a row matched only via its English vector
|
# 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.
|
# (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.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
PlexCollection,
|
PlexCollection,
|
||||||
|
PlexItem,
|
||||||
PlexLibrary,
|
PlexLibrary,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
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"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
@@ -93,7 +95,11 @@ def collections(
|
|||||||
func.lower(PlexCollection.title),
|
func.lower(PlexCollection.title),
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).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:
|
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from app.plex.query import (
|
|||||||
apply_show_state_filter,
|
apply_show_state_filter,
|
||||||
genres_col,
|
genres_col,
|
||||||
meta_filter_conds,
|
meta_filter_conds,
|
||||||
|
top_art_keys,
|
||||||
)
|
)
|
||||||
from app.plex.tmdb import resolve_recs
|
from app.plex.tmdb import resolve_recs
|
||||||
from app.routes.plex._shared import _content_libs, _norm_lang
|
from app.routes.plex._shared import _content_libs, _norm_lang
|
||||||
@@ -199,7 +200,16 @@ def _genre_tiles(
|
|||||||
).all():
|
).all():
|
||||||
counts[g] = counts.get(g, 0) + c
|
counts[g] = counts.get(g, 0) + c
|
||||||
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:GENRE_TILES]
|
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(
|
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
|
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):
|
def test_continue_watching_movies_and_shows(db, monkeypatch):
|
||||||
user = _seed(db)
|
user = _seed(db)
|
||||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const fullPayload = {
|
|||||||
scope: "both",
|
scope: "both",
|
||||||
continue_watching: [card("cw1", "Resume Me")],
|
continue_watching: [card("cw1", "Resume Me")],
|
||||||
recently_added: [card("ra1", "Just Added")],
|
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")] },
|
because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw } from "lucide-react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api";
|
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 { plexLang } from "../i18n";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
import { usePlex } from "./PlexProvider";
|
import { usePlex } from "./PlexProvider";
|
||||||
@@ -199,21 +199,16 @@ export default function PlexDiscover({
|
|||||||
<section className="mb-5">
|
<section className="mb-5">
|
||||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||||
{d.genres.map((g) => {
|
{d.genres.map((g) => (
|
||||||
const hue = hueOf(g.value);
|
<PosterTile
|
||||||
return (
|
key={g.value}
|
||||||
<button
|
title={g.value}
|
||||||
key={g.value}
|
art={g.art}
|
||||||
type="button"
|
topRight={<span className="text-2xs text-white/80 drop-shadow">{g.count}</span>}
|
||||||
onClick={() => onGenre(g.value)}
|
onClick={() => onGenre(g.value)}
|
||||||
className="glass-hover relative flex h-20 items-end overflow-hidden rounded-xl p-3 text-left transition"
|
className="h-20"
|
||||||
style={{ background: hueTileGradient(hue) }}
|
/>
|
||||||
>
|
))}
|
||||||
<span className="text-sm font-semibold text-white drop-shadow">{g.value}</span>
|
|
||||||
<span className="absolute right-2 top-2 text-2xs text-white/70">{g.count}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
className={`glass-hover group relative flex flex-col justify-end overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
|
||||||
|
style={posters.length === 0 ? { background: hueTileGradient(hueOf(title)) } : undefined}
|
||||||
|
>
|
||||||
|
{posters.length > 0 && (
|
||||||
|
<div className="absolute inset-0 grid grid-cols-2 grid-rows-2">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) =>
|
||||||
|
posters[i] ? (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={`/api/plex/image/${posters[i]}`}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
|
||||||
|
{/* After the scrim in DOM order → paints above it without a z-index token. */}
|
||||||
|
{topRight != null && <span className="absolute right-2 top-2">{topRight}</span>}
|
||||||
|
<div className="relative p-3">
|
||||||
|
<div className="line-clamp-2 text-sm font-semibold text-white drop-shadow">{title}</div>
|
||||||
|
{subtitle != null && <div className="text-2xs text-white/80">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,45 +1,19 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Layers, ListMusic } from "lucide-react";
|
import { Layers, ListMusic } from "lucide-react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
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,
|
// 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
|
// 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
|
// 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
|
// union), so this is a pure front-end surface. Collection tiles show a poster collage of their top
|
||||||
// fetch/proxy), matching the Discover genre tiles.
|
// films (PosterTile), falling back to a title-hash colour; playlists have no server art yet, so they
|
||||||
|
// take the colour fallback.
|
||||||
function Tile({
|
|
||||||
title,
|
|
||||||
count,
|
|
||||||
icon,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
count: ReactNode;
|
|
||||||
icon: ReactNode;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
const hue = hueOf(title);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
title={title}
|
|
||||||
className="glass-hover group relative flex h-28 flex-col justify-end overflow-hidden rounded-xl p-3 text-left transition"
|
|
||||||
style={{ background: hueTileGradient(hue) }}
|
|
||||||
>
|
|
||||||
<span className="absolute right-2 top-2 text-white/70">{icon}</span>
|
|
||||||
<span className="line-clamp-2 text-sm font-semibold text-white drop-shadow">{title}</span>
|
|
||||||
<span className="text-2xs text-white/70">{count}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
|
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 (
|
return (
|
||||||
<div className={GRID}>
|
<div className={GRID}>
|
||||||
{playlists.map((p) => (
|
{playlists.map((p) => (
|
||||||
<Tile
|
<PosterTile
|
||||||
key={p.id}
|
key={p.id}
|
||||||
title={p.title}
|
title={p.title}
|
||||||
count={t("plex.browse.items", { count: p.item_count })}
|
subtitle={t("plex.browse.items", { count: p.item_count })}
|
||||||
icon={<ListMusic className="h-4 w-4" />}
|
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
|
||||||
onClick={() => onOpen(p.id)}
|
onClick={() => onOpen(p.id)}
|
||||||
|
className="h-28"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -108,12 +83,16 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
|
|||||||
) : (
|
) : (
|
||||||
<div className={GRID}>
|
<div className={GRID}>
|
||||||
{collections.map((c) => (
|
{collections.map((c) => (
|
||||||
<Tile
|
<PosterTile
|
||||||
key={c.id}
|
key={c.id}
|
||||||
title={c.title}
|
title={c.title}
|
||||||
count={c.child_count != null ? t("plex.browse.items", { count: c.child_count }) : ""}
|
art={c.art}
|
||||||
icon={<Layers className="h-4 w-4" />}
|
subtitle={
|
||||||
|
c.child_count != null ? t("plex.browse.items", { count: c.child_count }) : undefined
|
||||||
|
}
|
||||||
|
topRight={<Layers className="h-4 w-4 text-white/80 drop-shadow" />}
|
||||||
onClick={() => onPick(c.id, c.title)}
|
onClick={() => onPick(c.id, c.title)}
|
||||||
|
className="h-28"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export interface PlexCollection {
|
|||||||
source?: string;
|
source?: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection
|
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 {
|
export interface PlexPlaylist {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -342,7 +343,8 @@ export interface PlexDiscover {
|
|||||||
scope: string;
|
scope: string;
|
||||||
continue_watching: PlexCard[];
|
continue_watching: PlexCard[];
|
||||||
recently_added: 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;
|
because_you_watched: { anchor: { id: string; title: string }; items: PlexCard[] } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user