From f10915c223554cddd10f23bc442327e1989e7560 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 20 Aug 2026 23:50:50 +0200 Subject: [PATCH] E5 S5: enriched Plex detail page (ratings + facts + keywords + crew) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/plex/query.py | 4 + backend/tests/test_plex_keyword_filter.py | 61 ++++ frontend/src/components/PlexInfo.dom.test.tsx | 120 ++++++++ frontend/src/components/PlexInfo.tsx | 279 +++++++++++++++--- .../src/components/usePlexActiveFilters.ts | 7 + frontend/src/i18n/locales/en/plex.json | 16 +- frontend/src/i18n/locales/hu/plex.json | 16 +- frontend/src/lib/api/plex.ts | 28 ++ 8 files changed, 491 insertions(+), 40 deletions(-) create mode 100644 backend/tests/test_plex_keyword_filter.py create mode 100644 frontend/src/components/PlexInfo.dom.test.tsx diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 56d0efe6..71734322 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -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, } diff --git a/backend/tests/test_plex_keyword_filter.py b/backend/tests/test_plex_keyword_filter.py new file mode 100644 index 00000000..05016887 --- /dev/null +++ b/backend/tests/test_plex_keyword_filter.py @@ -0,0 +1,61 @@ +"""E5 S5: the TMDB keyword filter. A keyword chip on the detail page sets ?keywords=, 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"] == [] diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx new file mode 100644 index 00000000..dcf1b629 --- /dev/null +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -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(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: Record) => + 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({ui}); +} + +describe("PlexInfo enriched detail (page)", () => { + it("renders the multi-source ratings row (IMDb / TMDb / audience)", () => { + wrap(); + 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(); + 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(); + fireEvent.click(screen.getByText("heist")); + expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] }); + }); + + it("crew members open the person page on click", () => { + const onOpenPerson = vi.fn(); + wrap(); + 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(); + 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(); + }); +}); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 10de6c2a..0c5e6f8c 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -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 ? ( + + ) : ( + + + {score.toFixed(1)} + + ); + const has = (x?: number | null): x is number => x != null; + if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null; + return ( +
+ {has(imdbRating) && ( + + + IMDb + {imdbUrl && ( + + + + )} + + )} + {has(tmdbRating) && ( + + + + TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""} + + + )} + {has(audience) && imdbRating !== audience && ( + + + {t("plex.info.audience")} + + )} +
+ ); +} + +// 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) => 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: ( + + {studios.map((s, i) => ( + + onFilter({ studios: [s] }) : undefined} + className="hover:text-accent" + > + {s} + + {i < studios.length - 1 ? "," : ""} + + ))} + + ), + }); + 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 ( +
+

{t("plex.info.facts.title")}

+
+ {rows.map((r) => ( +
+
{r.label}
+
{r.node}
+
+ ))} +
+
+ ); +} + +// 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) => void; +}) { + const { t } = useTranslation(); + if (keywords.length === 0) return null; + return ( +
+

{t("plex.info.keywords")}

+
+ {keywords.map((k) => ( + onFilter({ keywords: [k] }) : undefined} + > + {k} + + ))} +
+
+ ); +} + +// 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 ( +
+

{t("plex.info.crew")}

+
+ {crew.map((c, i) => ( +
+ {onOpenPerson ? ( + + ) : ( +
{c.name}
+ )} +
{c.job}
+
+ ))} +
+
+ ); +} type Props = { detail: PlexItemDetail; @@ -196,42 +416,18 @@ export default function PlexInfo({ {detail.studio} )} - {(() => { - // 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 - - - {isImdb && detail.imdb_url && ( - - - - )} - - ); - })()} + + + {/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */} +
+ onFilter({ ratingMin: n }) : undefined} + />
{detail.tagline && ( @@ -342,6 +538,10 @@ export default function PlexInfo({ {/* End hero panel. */} + {/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. */} + {!overlay && } + {!overlay && } + {/* Cast row (toggleable), circular photos. */} {cast.length > 0 && (
@@ -407,6 +607,9 @@ export default function PlexInfo({
)} + {/* Full crew (E5 S5) — TMDB key-crew, page variant only. */} + {!overlay && } + {/* 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 diff --git a/frontend/src/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts index 5283cd44..fbbb391d 100644 --- a/frontend/src/components/usePlexActiveFilters.ts +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -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", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index c9b3d2b2..9467c9b3 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 2039b5c9..da220a4d 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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", diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 47e8a5ff..87cb4486 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -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 {