E5 S5: enriched Plex detail page (ratings + facts + keywords + crew)

Consumes the TMDB block the S2 engine already persists + serves (movie/show
detail's `tmdb`), turning the movie info page into a Seerr-style enriched detail
(page variant only — the player overlay stays lean):

- Multi-source ratings row: IMDb (links out) · TMDb (with vote count) · Plex
  audience — whichever are present. Only the primary score (IMDb, else audience)
  carries the ratingMin filter, mirroring the backend rating column.
- Facts panel: release · runtime · cert · budget · revenue · studios · countries.
- Keyword chips → tap to filter the library. Backend: a new `keywords` filter on
  LibraryFilters + meta_filter_conds (`keywords @> [kw]`, both models), wired
  through PlexFilters (appendPlexFilters / plexFilterCount / active-chip / clear).
- Full crew (TMDB key-crew) → tap a name to open their person page.

Tests: test_plex_keyword_filter.py (3) + PlexInfo.dom.test.tsx (5, incl. the
overlay-stays-lean guard). New i18n plex.info.facts.* / keywords / crew / audience
(en+hu). Gate: vitest 337, backend lane 396.
This commit is contained in:
2026-08-20 23:50:50 +02:00
parent f9b63745f9
commit f10915c223
8 changed files with 491 additions and 40 deletions
+241 -38
View File
@@ -1,11 +1,10 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { pickRatingBadge } from "../lib/plexRating";
import { notifyActionError } from "../lib/actionErrors";
import {
DetailCustomizeMenu,
@@ -22,6 +21,227 @@ import PlexPlaylistAdd from "./PlexPlaylistAdd";
// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay
// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable
// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row.
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew — E5 S5) render
// on the page variant only; the overlay stays lean.
const compact = (n: number) => new Intl.NumberFormat("en", { notation: "compact" }).format(n);
const money = (n: number) =>
new Intl.NumberFormat("en-US", {
notation: "compact",
style: "currency",
currency: "USD",
maximumFractionDigits: 1,
}).format(n);
// Multi-source ratings cluster (E5 S5): IMDb (links out) · TMDb (with vote count) · Plex audience —
// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column
// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational.
function RatingsRow({
imdbRating,
imdbUrl,
audience,
tmdbRating,
tmdbVotes,
onFilterRating,
}: {
imdbRating?: number | null;
imdbUrl?: string | null;
audience?: number | null;
tmdbRating?: number | null;
tmdbVotes?: number | null;
onFilterRating?: (score: number) => void;
}) {
const { t } = useTranslation();
const primary = imdbRating ?? audience ?? null; // the filterable score
const chipCls =
"inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500";
const Score = ({ score, filterable }: { score: number; filterable: boolean }) =>
filterable && onFilterRating ? (
<button
onClick={() => onFilterRating(Math.floor(score))}
className="inline-flex items-center gap-1 hover:opacity-80"
title={t("plex.info.filterRating", { n: Math.floor(score) })}
>
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
</button>
) : (
<span className="inline-flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
</span>
);
const has = (x?: number | null): x is number => x != null;
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
{has(imdbRating) && (
<span className={chipCls}>
<Score score={imdbRating} filterable={imdbRating === primary} />
<span className="text-2xs font-normal opacity-70">IMDb</span>
{imdbUrl && (
<a
href={imdbUrl}
target="_blank"
rel="noopener noreferrer"
title={t("plex.info.openImdb")}
>
<ExternalLink className="h-3 w-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
)}
{has(tmdbRating) && (
<span className={chipCls}>
<Score score={tmdbRating} filterable={false} />
<span className="text-2xs font-normal opacity-70">
TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""}
</span>
</span>
)}
{has(audience) && imdbRating !== audience && (
<span className={chipCls}>
<Score score={audience} filterable={!has(imdbRating)} />
<span className="text-2xs font-normal opacity-70">{t("plex.info.audience")}</span>
</span>
)}
</div>
);
}
// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries,
// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something.
function FactsPanel({
detail,
onFilter,
}: {
detail: PlexItemDetail;
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const tmdb = detail.tmdb;
const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : [];
const rows: { key: string; label: string; node: ReactNode }[] = [];
if (detail.year != null)
rows.push({ key: "year", label: t("plex.info.facts.release"), node: detail.year });
if (detail.duration_seconds)
rows.push({
key: "runtime",
label: t("plex.info.facts.runtime"),
node: formatRuntime(detail.duration_seconds),
});
if (detail.content_rating)
rows.push({ key: "cert", label: t("plex.info.facts.cert"), node: detail.content_rating });
if (tmdb?.budget)
rows.push({ key: "budget", label: t("plex.info.facts.budget"), node: money(tmdb.budget) });
if (tmdb?.revenue)
rows.push({ key: "revenue", label: t("plex.info.facts.revenue"), node: money(tmdb.revenue) });
if (studios.length)
rows.push({
key: "studios",
label: t("plex.info.facts.studios"),
node: (
<span className="flex flex-wrap gap-x-1.5 gap-y-0.5">
{studios.map((s, i) => (
<span key={s}>
<Filterable
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
className="hover:text-accent"
>
{s}
</Filterable>
{i < studios.length - 1 ? "," : ""}
</span>
))}
</span>
),
});
if (tmdb?.production_countries?.length)
rows.push({
key: "countries",
label: t("plex.info.facts.countries"),
node: tmdb.production_countries.join(", "),
});
if (rows.length === 0) return null;
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-muted">{r.label}</dt>
<dd className="min-w-0 font-medium">{r.node}</dd>
</div>
))}
</dl>
</div>
);
}
// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword (via applyFilter).
function KeywordsPanel({
keywords,
onFilter,
}: {
keywords: string[];
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
if (keywords.length === 0) return null;
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
<div className="flex flex-wrap gap-1.5">
{keywords.map((k) => (
<Filterable
key={k}
className="rounded-full bg-surface px-2.5 py-1 text-xs text-muted hover:text-accent"
title={onFilter ? t("plex.info.filterKeyword", { name: k }) : undefined}
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
>
{k}
</Filterable>
))}
</div>
</div>
);
}
// Full crew (E5 S5) — the TMDB key-crew (Director / Writer / Producer / …), each clickable to open
// their person page. Complements the hero's Director line (which comes from Plex directly).
function CrewPanel({
crew,
onOpenPerson,
}: {
crew: { name: string; job: string }[];
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
if (crew.length === 0) return null;
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-3 md:grid-cols-4">
{crew.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
<button
onClick={() => onOpenPerson(c.name)}
title={t("plex.info.viewPerson", { name: c.name })}
className="truncate text-left text-sm font-medium hover:text-accent"
>
{c.name}
</button>
) : (
<div className="truncate text-sm font-medium">{c.name}</div>
)}
<div className="truncate text-2xs text-muted">{c.job}</div>
</div>
))}
</div>
</div>
);
}
type Props = {
detail: PlexItemDetail;
@@ -196,42 +416,18 @@ export default function PlexInfo({
{detail.studio}
</Filterable>
)}
{(() => {
// IMDb score when we have one (with the IMDb link); otherwise the Plex audience
// score (no IMDb link/branding, so a non-IMDb score is never mislabelled as IMDb).
const badge = pickRatingBadge(detail.imdb_rating, detail.rating);
if (!badge) return null;
const { score, isImdb } = badge;
return (
// decorative rating gold — intentionally NOT a status token
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
<button
onClick={
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
title={
onFilter
? t("plex.info.filterRating", { n: Math.floor(score) })
: undefined
}
>
<Star className="w-3.5 h-3.5 fill-current" />
{score.toFixed(1)}
</button>
{isImdb && detail.imdb_url && (
<a
href={detail.imdb_url}
target="_blank"
rel="noopener noreferrer"
title={t("plex.info.openImdb")}
>
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
);
})()}
</div>
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
<div className="mt-2">
<RatingsRow
imdbRating={detail.imdb_rating}
imdbUrl={detail.imdb_url}
audience={detail.rating}
tmdbRating={detail.tmdb?.rating}
tmdbVotes={detail.tmdb?.vote_count}
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
/>
</div>
{detail.tagline && (
@@ -342,6 +538,10 @@ export default function PlexInfo({
</div>
{/* End hero panel. */}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. */}
{!overlay && <FactsPanel detail={detail} onFilter={onFilter} />}
{!overlay && <KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />}
{/* Cast row (toggleable), circular photos. */}
{cast.length > 0 && (
<div className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}>
@@ -407,6 +607,9 @@ export default function PlexInfo({
</div>
)}
{/* Full crew (E5 S5) — TMDB key-crew, page variant only. */}
{!overlay && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
{/* Collection strips — the other titles in this movie's collection(s), directly playable.
Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */}
{detail.collections