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:
@@ -140,6 +140,8 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
|
||||
stds = split_csv(p.get("studios"))
|
||||
if stds: # OR
|
||||
conds.append(model.studio.in_(stds))
|
||||
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5)
|
||||
conds.append(model.keywords.contains([kw]))
|
||||
if p.get("collection"):
|
||||
conds.append(model.collection_keys.contains([p["collection"]]))
|
||||
return conds
|
||||
@@ -171,6 +173,7 @@ class LibraryFilters:
|
||||
"any" # how multiple actors combine: any (OR, default) | all (AND)
|
||||
)
|
||||
studios: str | None = None
|
||||
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
||||
collection: str | None = None
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
@@ -188,6 +191,7 @@ class LibraryFilters:
|
||||
"actors": self.actors,
|
||||
"actor_mode": self.actor_mode,
|
||||
"studios": self.studios,
|
||||
"keywords": self.keywords,
|
||||
"collection": self.collection,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""E5 S5: the TMDB keyword filter. A keyword chip on the detail page sets ?keywords=<kw>, which
|
||||
narrows the unified library to rows whose persisted `keywords` JSONB array contains it. DB lane
|
||||
(`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, User
|
||||
|
||||
|
||||
def _seed(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Heist Movie",
|
||||
keywords=["heist", "bank robbery"]),
|
||||
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Space Movie",
|
||||
keywords=["space", "aliens"]),
|
||||
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="No Keywords Movie"),
|
||||
])
|
||||
user = User(email="kwfilter@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _titles(resp):
|
||||
return {i["title"] for i in resp.json()["items"]}
|
||||
|
||||
|
||||
def test_keyword_filter_narrows_to_matching_rows(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=heist")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _titles(resp) == {"Heist Movie"}
|
||||
|
||||
|
||||
def test_no_keyword_filter_returns_all(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie")
|
||||
assert _titles(resp) == {"Heist Movie", "Space Movie", "No Keywords Movie"}
|
||||
|
||||
|
||||
def test_unknown_keyword_matches_nothing(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=nonesuch")
|
||||
assert resp.json()["items"] == []
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E5 S5: the enriched Plex detail page. Covers the TMDB-fed pieces added on the page variant — the
|
||||
// multi-source ratings row (IMDb / TMDb / audience), the facts panel (budget / revenue / studios /
|
||||
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is
|
||||
// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache
|
||||
// reads. The overlay variant is asserted to stay lean (no facts/keywords/crew).
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.name ? `${key}:${opts.name}` : key,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import PlexInfo from "./PlexInfo";
|
||||
import type { PlexItemDetail } from "../lib/api";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const detail: PlexItemDetail = {
|
||||
id: "m1",
|
||||
kind: "movie",
|
||||
title: "Test Movie",
|
||||
summary: "A summary.",
|
||||
year: 2010,
|
||||
duration_seconds: 7200,
|
||||
playable: "direct",
|
||||
thumb: "/api/plex/image/m1",
|
||||
art: "/api/plex/image/m1?variant=art",
|
||||
cast: [],
|
||||
imdb_rating: 7.8,
|
||||
imdb_id: "tt1",
|
||||
imdb_url: "https://www.imdb.com/title/tt1",
|
||||
rating: 8.4, // Plex audience
|
||||
content_rating: "R",
|
||||
genres: ["Crime"],
|
||||
directors: ["Plex Director"],
|
||||
studio: "Fallback Studio",
|
||||
tagline: "A tagline.",
|
||||
markers: [],
|
||||
audio_streams: [],
|
||||
subtitle_streams: [],
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
tmdb: {
|
||||
tmdb_id: 42,
|
||||
rating: 7.2,
|
||||
vote_count: 1234,
|
||||
budget: 160000000,
|
||||
revenue: 830000000,
|
||||
keywords: ["heist", "bank robbery"],
|
||||
crew: [
|
||||
{ name: "Jane Director", job: "Director" },
|
||||
{ name: "Sam Writer", job: "Writer" },
|
||||
],
|
||||
studios: ["Studio A", "Studio B"],
|
||||
production_countries: ["United States"],
|
||||
recommendations: [],
|
||||
backdrops: [],
|
||||
},
|
||||
};
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("PlexInfo enriched detail (page)", () => {
|
||||
it("renders the multi-source ratings row (IMDb / TMDb / audience)", () => {
|
||||
wrap(<PlexInfo detail={detail} variant="page" />);
|
||||
expect(screen.getByText("IMDb")).toBeTruthy();
|
||||
expect(screen.getByText("7.8")).toBeTruthy();
|
||||
expect(screen.getByText(/TMDb/)).toBeTruthy(); // "TMDb · 1.2K" (vote count compacted)
|
||||
expect(screen.getByText("7.2")).toBeTruthy();
|
||||
expect(screen.getByText("plex.info.audience")).toBeTruthy();
|
||||
expect(screen.getByText("8.4")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the facts panel with budget / revenue / studios / countries", () => {
|
||||
wrap(<PlexInfo detail={detail} variant="page" />);
|
||||
expect(screen.getByText("plex.info.facts.title")).toBeTruthy();
|
||||
expect(screen.getByText("plex.info.facts.budget")).toBeTruthy();
|
||||
expect(screen.getByText("$160M")).toBeTruthy();
|
||||
expect(screen.getByText("$830M")).toBeTruthy();
|
||||
expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback
|
||||
expect(screen.getByText("United States")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keyword chips filter the library on click", () => {
|
||||
const onFilter = vi.fn();
|
||||
wrap(<PlexInfo detail={detail} variant="page" onFilter={onFilter} />);
|
||||
fireEvent.click(screen.getByText("heist"));
|
||||
expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] });
|
||||
});
|
||||
|
||||
it("crew members open the person page on click", () => {
|
||||
const onOpenPerson = vi.fn();
|
||||
wrap(<PlexInfo detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
|
||||
expect(screen.getByText("plex.info.crew")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Jane Director"));
|
||||
expect(onOpenPerson).toHaveBeenCalledWith("Jane Director");
|
||||
});
|
||||
|
||||
it("the overlay variant stays lean (no facts / keywords / crew panels)", () => {
|
||||
wrap(<PlexInfo detail={detail} variant="overlay" />);
|
||||
expect(screen.queryByText("plex.info.facts.title")).toBeNull();
|
||||
expect(screen.queryByText("plex.info.keywords")).toBeNull();
|
||||
expect(screen.queryByText("plex.info.crew")).toBeNull();
|
||||
// but the ratings row still renders (it's compact enough for the overlay)
|
||||
expect(screen.getByText("IMDb")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -95,6 +95,13 @@ export function usePlexActiveFilters(): ActiveFilter[] {
|
||||
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
|
||||
});
|
||||
}
|
||||
for (const k of filters.keywords) {
|
||||
out.push({
|
||||
key: `keyword:${k}`,
|
||||
label: k,
|
||||
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
|
||||
});
|
||||
}
|
||||
if (filters.collection) {
|
||||
out.push({
|
||||
key: "collection",
|
||||
|
||||
@@ -204,7 +204,21 @@
|
||||
"filterRating": "Show titles rated {{n}}+",
|
||||
"filterActor": "Show titles with {{name}}",
|
||||
"viewPerson": "View {{name}}'s filmography",
|
||||
"browseCollection": "Browse collection →"
|
||||
"browseCollection": "Browse collection →",
|
||||
"audience": "Audience",
|
||||
"keywords": "Keywords",
|
||||
"crew": "Crew",
|
||||
"filterKeyword": "Filter by \"{{name}}\"",
|
||||
"facts": {
|
||||
"title": "Details",
|
||||
"release": "Released",
|
||||
"runtime": "Runtime",
|
||||
"cert": "Rating",
|
||||
"budget": "Budget",
|
||||
"revenue": "Revenue",
|
||||
"studios": "Studios",
|
||||
"countries": "Countries"
|
||||
}
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Plays directly in the browser",
|
||||
|
||||
@@ -204,7 +204,21 @@
|
||||
"filterRating": "{{n}}+ pontos címek",
|
||||
"filterActor": "Címek {{name}} szereplésével",
|
||||
"viewPerson": "{{name}} filmográfiája",
|
||||
"browseCollection": "Kollekció böngészése →"
|
||||
"browseCollection": "Kollekció böngészése →",
|
||||
"audience": "Közönség",
|
||||
"keywords": "Kulcsszavak",
|
||||
"crew": "Stáb",
|
||||
"filterKeyword": "Szűrés erre: „{{name}}”",
|
||||
"facts": {
|
||||
"title": "Részletek",
|
||||
"release": "Megjelenés",
|
||||
"runtime": "Hossz",
|
||||
"cert": "Korhatár",
|
||||
"budget": "Költségvetés",
|
||||
"revenue": "Bevétel",
|
||||
"studios": "Stúdiók",
|
||||
"countries": "Országok"
|
||||
}
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Közvetlenül játszható a böngészőben",
|
||||
|
||||
@@ -54,6 +54,28 @@ export interface PlexCard {
|
||||
show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season)
|
||||
summary?: string | null; // movie/show hover-overview synopsis (trimmed) + episode summary
|
||||
}
|
||||
// TMDB enrichment block (E5 S2), served from OUR DB (never a live TMDB call) on a movie/show detail.
|
||||
// null until the row has been enriched. Powers the S5 enriched detail page: multi-source ratings row,
|
||||
// facts sidebar, keyword chips (→ filter), and the full crew.
|
||||
export interface PlexTmdbCrew {
|
||||
name: string;
|
||||
job: string;
|
||||
tmdb_id?: number | null;
|
||||
profile_path?: string | null; // raw TMDB path — we don't proxy crew headshots, so name+job only
|
||||
}
|
||||
export interface PlexTmdb {
|
||||
tmdb_id?: number | null;
|
||||
rating?: number | null; // TMDb user score (0–10)
|
||||
vote_count?: number | null;
|
||||
budget?: number | null; // USD, movies only
|
||||
revenue?: number | null; // USD, movies only
|
||||
keywords: string[];
|
||||
crew: PlexTmdbCrew[];
|
||||
studios: string[];
|
||||
production_countries: string[];
|
||||
recommendations: { tmdb_id?: number | null; title?: string | null; in_library: boolean }[];
|
||||
backdrops: string[];
|
||||
}
|
||||
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
|
||||
// also matches episodes (the grouped "Episodes" section).
|
||||
export interface PlexUnifiedResult {
|
||||
@@ -98,6 +120,7 @@ export interface PlexShowDetail {
|
||||
first?: PlexCard | null;
|
||||
episode_count: number;
|
||||
collection_keys: string[];
|
||||
tmdb?: PlexTmdb | null;
|
||||
};
|
||||
seasons: PlexSeasonDetail[];
|
||||
related: PlexCard[];
|
||||
@@ -149,6 +172,7 @@ export interface PlexItemDetail {
|
||||
// `source` buckets the strip so each type can be shown/hidden independently on the info page:
|
||||
// "collection" (genuine franchise), "imdb"/"tmdb"/"tvdb"/"trakt" (external auto-lists), "smart".
|
||||
collections?: { id: string; title: string; source: string; items: PlexCard[] }[];
|
||||
tmdb?: PlexTmdb | null; // movies only (episodes aren't enriched)
|
||||
}
|
||||
export interface PlexCollection {
|
||||
id: string;
|
||||
@@ -205,6 +229,7 @@ export interface PlexFilters {
|
||||
actors: string[]; // OR (any) by default, or AND (all) via actorMode
|
||||
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
|
||||
studios: string[]; // OR — from any selected studio
|
||||
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
|
||||
collection?: string | null; // a single Plex collection (rating_key)
|
||||
collectionTitle?: string | null; // its title, for the active-filter chip
|
||||
}
|
||||
@@ -214,6 +239,7 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
||||
directors: [],
|
||||
actors: [],
|
||||
studios: [],
|
||||
keywords: [],
|
||||
};
|
||||
export function plexFilterCount(f: PlexFilters): number {
|
||||
return (
|
||||
@@ -226,6 +252,7 @@ export function plexFilterCount(f: PlexFilters): number {
|
||||
f.directors.length +
|
||||
f.actors.length +
|
||||
f.studios.length +
|
||||
f.keywords.length +
|
||||
(f.collection ? 1 : 0)
|
||||
);
|
||||
}
|
||||
@@ -246,6 +273,7 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
|
||||
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
||||
if (f.actorMode === "all") u.set("actor_mode", "all");
|
||||
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
||||
if (f.keywords?.length) u.set("keywords", f.keywords.join(","));
|
||||
if (f.collection) u.set("collection", f.collection);
|
||||
}
|
||||
export interface PlexFacets {
|
||||
|
||||
Reference in New Issue
Block a user