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
+28
View File
@@ -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 {