feat(plex): person cards in the grid for the filtered actors (S3)
The actors currently in the filter now render as cards above the poster grid — headshot + name + in-scope title count + a remove x — so you SEE who you're exploring, not just chips in the rail (generalises the old search-only person cards to the applied filter). New GET /plex/people/cards (photo from the plex_people cache, count live against cast_names); PlexBrowse fetches + renders them; i18n en/hu. Verified: John Goodman (11) / Douglas M. Griffin (1) / Bradley Cooper (14) with photos + counts. (Plex cast-explore S3)
This commit is contained in:
@@ -1677,6 +1677,51 @@ def _enrich_people_from_rich(db: Session, row, rich: dict) -> bool:
|
|||||||
return dirty
|
return dirty
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/people/cards")
|
||||||
|
def people_cards(
|
||||||
|
names: str,
|
||||||
|
scope: str = "both",
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Cards for the actor filter-chips currently applied: each name + its cached headshot + how many
|
||||||
|
movies/shows (in the given scope) feature them. Photos come from the plex_people cache (populated
|
||||||
|
as items are rich-fetched — info-page view + the rich re-sync); counts are live against cast_names.
|
||||||
|
A name with no cached photo yet still returns a card (the UI shows a placeholder)."""
|
||||||
|
wanted = _csv(names)[:16]
|
||||||
|
if not wanted:
|
||||||
|
return {"people": []}
|
||||||
|
libs = db.query(PlexLibrary).filter_by(enabled=True).all()
|
||||||
|
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
|
||||||
|
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
|
||||||
|
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||||
|
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
||||||
|
photos = {p.name: p.photo_thumb for p in db.query(PlexPerson).filter(PlexPerson.name.in_(wanted))}
|
||||||
|
out = []
|
||||||
|
for nm in wanted:
|
||||||
|
count = 0
|
||||||
|
if want_movies:
|
||||||
|
count += (
|
||||||
|
db.query(func.count(PlexItem.id))
|
||||||
|
.filter(
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.library_id.in_(movie_lib_ids),
|
||||||
|
PlexItem.cast_names.contains([nm]),
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
if want_shows:
|
||||||
|
count += (
|
||||||
|
db.query(func.count(PlexShow.id))
|
||||||
|
.filter(PlexShow.library_id.in_(show_lib_ids), PlexShow.cast_names.contains([nm]))
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
out.append({"name": nm, "photo": photos.get(nm), "count": count})
|
||||||
|
return {"people": out}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/person-image")
|
@router.get("/person-image")
|
||||||
def person_image(u: str, request: Request) -> Response:
|
def person_image(u: str, request: Request) -> Response:
|
||||||
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
|
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Star,
|
Star,
|
||||||
Tv2,
|
Tv2,
|
||||||
|
User,
|
||||||
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -136,6 +138,17 @@ export default function PlexBrowse() {
|
|||||||
|
|
||||||
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
const total = browseQ.data?.pages[0]?.total ?? 0;
|
||||||
|
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
|
||||||
|
// grid so you SEE who you're exploring, not just chips in the rail.
|
||||||
|
const peopleCardsQ = useQuery({
|
||||||
|
queryKey: ["plex-people-cards", scope, filters.actors],
|
||||||
|
queryFn: () => api.plexPeopleCards(filters.actors, scope),
|
||||||
|
enabled: sub.view.kind === "grid" && filters.actors.length > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const personCards = peopleCardsQ.data?.people ?? [];
|
||||||
|
const removeActor = (name: string) =>
|
||||||
|
setFilters({ ...filters, actors: filters.actors.filter((x) => x !== name) });
|
||||||
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
|
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
|
||||||
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
|
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
|
||||||
|
|
||||||
@@ -280,6 +293,42 @@ export default function PlexBrowse() {
|
|||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
|
{personCards.length > 0 && (
|
||||||
|
<div className="mb-4 flex flex-wrap gap-3">
|
||||||
|
{personCards.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.name}
|
||||||
|
className="glass-card flex items-center gap-2.5 rounded-xl py-1.5 pl-1.5 pr-2.5"
|
||||||
|
>
|
||||||
|
{p.photo ? (
|
||||||
|
<img
|
||||||
|
src={p.photo}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-10 h-10 rounded-full object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate max-w-[150px]">{p.name}</div>
|
||||||
|
<div className="text-[11px] text-muted">{t("plex.people.count", { count: p.count })}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeActor(p.name)}
|
||||||
|
title={t("plex.people.remove")}
|
||||||
|
aria-label={t("plex.people.remove")}
|
||||||
|
className="ml-1 p-1 rounded-full text-muted hover:text-fg hover:bg-card transition"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{browseQ.isLoading ? (
|
{browseQ.isLoading ? (
|
||||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||||
) : items.length === 0 && episodes.length === 0 ? (
|
) : items.length === 0 && episodes.length === 0 ? (
|
||||||
|
|||||||
@@ -225,5 +225,10 @@
|
|||||||
"addShow": "Add whole show to a playlist",
|
"addShow": "Add whole show to a playlist",
|
||||||
"addSeason": "Add season to a playlist",
|
"addSeason": "Add season to a playlist",
|
||||||
"addEpisode": "Add episode to a playlist"
|
"addEpisode": "Add episode to a playlist"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"count_one": "{{count}} title",
|
||||||
|
"count_other": "{{count}} titles",
|
||||||
|
"remove": "Remove from filter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,5 +225,10 @@
|
|||||||
"addShow": "Egész sorozat listához adása",
|
"addShow": "Egész sorozat listához adása",
|
||||||
"addSeason": "Évad listához adása",
|
"addSeason": "Évad listához adása",
|
||||||
"addEpisode": "Epizód listához adása"
|
"addEpisode": "Epizód listához adása"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"count_one": "{{count}} cím",
|
||||||
|
"count_other": "{{count}} cím",
|
||||||
|
"remove": "Eltávolítás a szűrőből"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -828,6 +828,11 @@ export interface PlexCastMember {
|
|||||||
role?: string | null;
|
role?: string | null;
|
||||||
thumb?: string | null; // proxied person-image url, or null when Plex has no photo
|
thumb?: string | null; // proxied person-image url, or null when Plex has no photo
|
||||||
}
|
}
|
||||||
|
export interface PlexPersonCard {
|
||||||
|
name: string;
|
||||||
|
photo?: string | null; // proxied person-image url, or null (placeholder)
|
||||||
|
count: number; // in-scope movies/shows featuring them
|
||||||
|
}
|
||||||
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
|
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
|
||||||
export interface PlexFilters {
|
export interface PlexFilters {
|
||||||
genres: string[];
|
genres: string[];
|
||||||
@@ -1351,6 +1356,9 @@ export const api = {
|
|||||||
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
|
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
||||||
|
// Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count.
|
||||||
|
plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> =>
|
||||||
|
req(`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`),
|
||||||
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
|
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
|
||||||
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
||||||
if (audio != null) p.set("audio", String(audio));
|
if (audio != null) p.set("audio", String(audio));
|
||||||
|
|||||||
Reference in New Issue
Block a user