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
+4
View File
@@ -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,
}
+61
View File
@@ -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();
});
});
+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
@@ -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",
+15 -1
View File
@@ -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",
+15 -1
View File
@@ -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",
+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 {