Merge: Plex cast-explore — full-cast filtering, AND/OR, person cards, backfill

Fixes the pre-existing Plex bug where clicking a lower-billed cast member emptied
the grid (the sync only stored the top ~3 cast, AND-matched). Now: the full cast
is persisted on info-view (lazy) + a batched rich re-sync backfills the whole
library (marker 0057); the actor filter defaults to OR with an AND toggle
(feed-rail pill style); the filtered actors show as person cards (photo + count +
remove); orphaned headshot-cache rows are pruned each sync. Shared app/plex/people.py
backs both enrichment paths. E2E 17/17; /code-review clean (2 fixed). UAT passed.
This commit is contained in:
2026-07-19 04:51:35 +02:00
11 changed files with 436 additions and 46 deletions
@@ -0,0 +1,34 @@
"""plex_people: a small cast/crew headshot cache for person filter-chips
Revision ID: 0056_plex_people
Revises: 0055_channel_keywords
Create Date: 2026-07-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0056_plex_people"
down_revision: Union[str, None] = "0055_channel_keywords"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"plex_people",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
# A representative proxied headshot URL (or NULL when none is available). Captured
# opportunistically from an item's rich metadata (info-page view + the rich re-sync).
sa.Column("photo_thumb", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_plex_people_name", "plex_people", ["name"], unique=True)
def downgrade() -> None:
op.drop_index("ix_plex_people_name", table_name="plex_people")
op.drop_table("plex_people")
@@ -0,0 +1,25 @@
"""plex_items/plex_shows.people_enriched_at: full-cast backfill marker
Revision ID: 0057_plex_ppl_enriched
Revises: 0056_plex_people
Create Date: 2026-07-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0057_plex_ppl_enriched"
down_revision: Union[str, None] = "0056_plex_people"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
for tbl in ("plex_items", "plex_shows"):
op.add_column(tbl, sa.Column("people_enriched_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
for tbl in ("plex_items", "plex_shows"):
op.drop_column(tbl, "people_enriched_at")
+21
View File
@@ -1027,6 +1027,8 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
directors: Mapped[list | None] = mapped_column(JSONB)
cast_names: Mapped[list | None] = mapped_column(JSONB)
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
@@ -1118,6 +1120,8 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
# finds titles by a person's name (and ranks them above summary-only mentions).
people_text: Mapped[str | None] = mapped_column(Text)
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
@@ -1129,6 +1133,23 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
)
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
"""A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the
actors currently in the filter) can show a photo + count without a live per-card metadata fetch.
Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the
periodic rich re-sync — since the cheap section listing that drives the main sync only carries the
top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library
removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned
metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the
row); this table is ONLY the headshot cache."""
__tablename__ = "plex_people"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
+97
View File
@@ -0,0 +1,97 @@
"""Shared Plex people helpers: parse cast/crew from rich metadata, persist the full cast onto a movie
or show (cast_names + the people_text search blob + the plex_people headshot cache), and prune
orphaned people. Used by BOTH the info-page lazy enrichment (routes.plex) and the background rich
re-sync (plex.sync), so the two paths never diverge."""
from urllib.parse import quote
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.models import PlexPerson
# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted)
# so the user's browser makes no third-party request (privacy + self-host CSP friendliness).
PERSON_IMG_HOST = "https://metadata-static.plex.tv/"
def person_thumb_url(thumb: str | None) -> str | None:
"""Proxied /person-image URL for a Plex CDN headshot, or None when it's missing / off-CDN."""
t = str(thumb or "")
return f"/api/plex/person-image?u={quote(t, safe='')}" if t.startswith(PERSON_IMG_HOST) else None
def cast_from_meta(meta: dict, limit: int = 24) -> list[dict]:
"""[{name, role, thumb}] for the cast (Role tags) in a full Plex metadata dict — thumb is the
proxied person-image URL or None. Shared by the info-page response, the lazy enrichment, and the
re-sync so the parsing lives in one place."""
out: list[dict] = []
for role in (meta.get("Role") or [])[:limit]:
name = role.get("tag")
if not name:
continue
out.append(
{"name": name, "role": role.get("role"), "thumb": person_thumb_url(role.get("thumb"))}
)
return out
def enrich_row_people(db: Session, row, cast: list[dict]) -> bool:
"""Persist the FULLER cast onto the row's `cast_names` + `people_text`, and cache each cast
member's headshot in plex_people. The cheap section listing that drives the main sync only carries
the top ~3 cast, so this — run whenever a rich fetch happens (info-page view + the rich re-sync) —
is what makes the full displayed cast filterable. Returns True if anything was written (the caller
commits). Best-effort by contract: never the reason a read 500s."""
names = list(dict.fromkeys(c["name"] for c in cast if c.get("name")))[:24]
if not names:
return False
dirty = False
# Grow/refresh cast_names only when the rich set adds names the stored (listing top-3) set lacks —
# avoid churn and never downgrade a fuller set back to the thin listing.
if set(names) - set(row.cast_names or []):
row.cast_names = names
row.people_text = " ".join(dict.fromkeys(list(row.directors or []) + names)) or None
dirty = True
# Headshot cache: upsert name -> proxied thumb (a later fetch fills a prior NULL; names without a
# whitelisted photo still get a row so a card can show a placeholder).
existing = {p.name: p for p in db.query(PlexPerson).filter(PlexPerson.name.in_(names))}
for c in cast:
nm, th = c.get("name"), c.get("thumb")
if not nm:
continue
p = existing.get(nm)
if p is None:
p = PlexPerson(name=nm, photo_thumb=th)
db.add(p)
existing[nm] = p
dirty = True
elif th and not p.photo_thumb:
p.photo_thumb = th
dirty = True
return dirty
# The person names still referenced by ANY movie/show (cast OR directors). plex_people rows outside
# this set are orphans left by a library removal.
_REFERENCED_PEOPLE_SQL = """
SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array'
UNION SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array'
UNION SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array'
UNION SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array'
"""
def prune_orphan_people(db: Session) -> int:
"""Delete plex_people (headshot cache) rows whose name no longer appears in any movie/show cast or
directors — orphaned by a library removal. Returns the number pruned. Commits its own work."""
# `WHERE ref IS NOT NULL` matters: a JSON-null array element surfaces as a SQL NULL in the
# referenced set, and `name NOT IN (…, NULL)` is NULL (never TRUE) for every row — the classic
# NOT IN trap that would silently prune nothing. Filtering NULLs out keeps the delete correct.
res = db.execute(
text(
"DELETE FROM plex_people WHERE name NOT IN "
f"(SELECT ref FROM ({_REFERENCED_PEOPLE_SQL}) AS refs(ref) WHERE ref IS NOT NULL)"
)
)
db.commit()
return res.rowcount or 0
+55
View File
@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from app import sysconfig
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
from app.plex import people
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
log = logging.getLogger("siftlode.plex")
@@ -129,16 +130,70 @@ def sync(db: Session) -> dict:
_sync_shows(db, plex, lib, stats)
_sync_collections(db, plex, lib, stats)
db.commit()
# Rich-fetch a bounded batch of not-yet-enriched titles' FULL cast, so the whole library
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
try:
stats["people_enriched"] = _enrich_people_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill
log.warning("Plex people enrich batch failed: %s", e)
except PlexNotConfigured as e:
return {"skipped": str(e)}
except PlexError as e:
db.rollback()
log.warning("Plex sync failed: %s", e)
return {"error": str(e)}
# Prune the headshot cache: a reconcile may have removed titles, orphaning people who no longer
# appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata.
try:
stats["people_pruned"] = people.prune_orphan_people(db)
except Exception as e: # best-effort housekeeping — never fail the sync over it
log.warning("Plex people prune failed: %s", e)
log.info("Plex sync done: %s", stats)
return stats
# How many titles to rich-fetch per sync run for the cast backfill — bounds the extra Plex load; the
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
_ENRICH_BATCH = 60
def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int:
"""Rich-fetch the FULL cast of up to `limit` not-yet-enriched movies/shows and persist it (via the
shared enrich_row_people). Oldest-id first, movies before shows. Stamps people_enriched_at only on
a SUCCESSFUL fetch, so a transient per-item failure retries next run rather than being lost.
Returns how many items were enriched this run."""
movies = (
db.query(PlexItem)
.filter(PlexItem.kind == "movie", PlexItem.people_enriched_at.is_(None))
.order_by(PlexItem.id)
.limit(limit)
.all()
)
remaining = limit - len(movies)
shows = (
db.query(PlexShow)
.filter(PlexShow.people_enriched_at.is_(None))
.order_by(PlexShow.id)
.limit(remaining)
.all()
if remaining > 0
else []
)
now = datetime.now(timezone.utc)
n = 0
for row in [*movies, *shows]:
try:
meta = plex.metadata(row.rating_key) or {}
except Exception as e:
log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e)
continue # leave people_enriched_at NULL so it's retried on a later run
people.enrich_row_people(db, row, people.cast_from_meta(meta))
row.people_enriched_at = now
n += 1
db.commit()
return n
def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary:
lib = db.query(PlexLibrary).filter_by(plex_key=key).first()
if lib is None:
+79 -26
View File
@@ -17,7 +17,6 @@ from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import quote
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
@@ -34,6 +33,7 @@ from app.models import (
PlexItem,
PlexLibrary,
PlexLink,
PlexPerson,
PlexPlaylist,
PlexPlaylistItem,
PlexSeason,
@@ -42,6 +42,7 @@ from app.models import (
User,
)
from app.plex import paths as plex_paths
from app.plex import people as plex_people
from app.plex import stream as plex_stream
from app.plex import sync as plex_sync
from app.plex import watch_sync as plex_watch
@@ -372,8 +373,10 @@ def _meta_filter_conds(model, p: dict) -> list:
conds.append(model.added_at >= cutoff)
for d in _csv(p.get("directors")): # AND
conds.append(model.directors.contains([d]))
for a in _csv(p.get("actors")): # AND
conds.append(model.cast_names.contains([a]))
alist = _csv(p.get("actors"))
if alist: # AND (all) or OR (any) — default OR, mirroring genre_mode
ac = [model.cast_names.contains([a]) for a in alist]
conds.append(and_(*ac) if p.get("actor_mode") == "all" else or_(*ac))
stds = _csv(p.get("studios"))
if stds: # OR
conds.append(model.studio.in_(stds))
@@ -404,6 +407,7 @@ class LibraryFilters:
added_within: str | None = None
directors: str | None = None
actors: str | None = None
actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND)
studios: str | None = None
collection: str | None = None
@@ -414,7 +418,8 @@ class LibraryFilters:
"year_max": self.year_max, "rating_min": self.rating_min,
"duration_min": self.duration_min, "duration_max": self.duration_max,
"added_within": self.added_within, "directors": self.directors,
"actors": self.actors, "studios": self.studios, "collection": self.collection,
"actors": self.actors, "actor_mode": self.actor_mode,
"studios": self.studios, "collection": self.collection,
}
@@ -1221,6 +1226,17 @@ def show_detail(
# failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500.
pass
# Lazy enrichment: persist the show's full cast so its cast members become filterable (same as the
# movie info page — the sync only stored the listing's top ~3), and stamp people_enriched_at so the
# backfill skips it. Best-effort.
if rich.get("cast"):
plex_people.enrich_row_people(db, sh, rich["cast"])
sh.people_enriched_at = datetime.now(timezone.utc)
try:
db.commit()
except Exception:
db.rollback()
return {
"show": {
"id": sh.rating_key,
@@ -1569,11 +1585,6 @@ _PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30
# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted)
# so the user's browser makes no third-party request (privacy + self-host CSP friendliness).
_PERSON_IMG_HOST = "https://metadata-static.plex.tv/"
def _rich_meta(meta: dict) -> dict:
"""Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content
rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any
@@ -1599,21 +1610,6 @@ def _rich_meta(meta: dict) -> dict:
if gid.startswith("imdb://"):
imdb_id = gid.split("imdb://", 1)[1]
break
cast = []
for role in (meta.get("Role") or [])[:24]:
name = role.get("tag")
if not name:
continue
thumb = str(role.get("thumb") or "")
cast.append(
{
"name": name,
"role": role.get("role"),
"thumb": f"/api/plex/person-image?u={quote(thumb, safe='')}"
if thumb.startswith(_PERSON_IMG_HOST)
else None,
}
)
return {
"imdb_rating": imdb_rating,
"imdb_id": imdb_id,
@@ -1623,16 +1619,61 @@ def _rich_meta(meta: dict) -> dict:
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
"studio": meta.get("studio"),
"tagline": meta.get("tagline"),
"cast": cast,
"cast": plex_people.cast_from_meta(meta),
}
@router.get("/people/cards")
def people_cards(
names: str,
scope: str = "both",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Cards for the actor filter-chips currently applied: each name + its cached headshot + how many
movies/shows (in the given scope) feature them. Photos come from the plex_people cache (populated
as items are rich-fetched — info-page view + the rich re-sync); counts are live against cast_names.
A name with no cached photo yet still returns a card (the UI shows a placeholder)."""
wanted = _csv(names)[:16]
if not wanted:
return {"people": []}
libs = db.query(PlexLibrary).filter_by(enabled=True).all()
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
photos = {p.name: p.photo_thumb for p in db.query(PlexPerson).filter(PlexPerson.name.in_(wanted))}
out = []
for nm in wanted:
count = 0
if want_movies:
count += (
db.query(func.count(PlexItem.id))
.filter(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_lib_ids),
PlexItem.cast_names.contains([nm]),
)
.scalar()
or 0
)
if want_shows:
count += (
db.query(func.count(PlexShow.id))
.filter(PlexShow.library_id.in_(show_lib_ids), PlexShow.cast_names.contains([nm]))
.scalar()
or 0
)
out.append({"name": nm, "photo": photos.get(nm), "count": count})
return {"people": out}
@router.get("/person-image")
def person_image(u: str, request: Request) -> Response:
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
_require_session(request)
if not u.startswith(_PERSON_IMG_HOST):
if not u.startswith(plex_people.PERSON_IMG_HOST):
raise HTTPException(status_code=400, detail="Unsupported image host")
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
hit = _img_cache_read(cache_key)
@@ -1779,6 +1820,18 @@ def item_detail(
except Exception:
pass # metadata extras are best-effort; any live-Plex failure must degrade, not 500 — playback works without them
# Lazy enrichment: the rich fetch above carries the FULL cast (the sync only stored the listing's
# top ~3), so persist it onto this movie so every cast member shown here becomes filterable, and
# stamp people_enriched_at so the backfill skips it. Movie only — episodes aren't in the grid; a
# show's cast is enriched from /show. Best-effort.
if it.kind == "movie" and rich.get("cast"):
plex_people.enrich_row_people(db, it, rich["cast"])
it.people_enriched_at = datetime.now(timezone.utc)
try:
db.commit()
except Exception:
db.rollback()
prev_id, next_id = _episode_nav(db, it)
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
return {
+49
View File
@@ -15,6 +15,8 @@ import {
RotateCcw,
Star,
Tv2,
User,
X,
type LucideIcon,
} from "lucide-react";
import {
@@ -136,6 +138,17 @@ export default function PlexBrowse() {
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
const total = browseQ.data?.pages[0]?.total ?? 0;
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
// grid so you SEE who you're exploring, not just chips in the rail.
const peopleCardsQ = useQuery({
queryKey: ["plex-people-cards", scope, filters.actors],
queryFn: () => api.plexPeopleCards(filters.actors, scope),
enabled: sub.view.kind === "grid" && filters.actors.length > 0,
staleTime: 60_000,
});
const personCards = peopleCardsQ.data?.people ?? [];
const removeActor = (name: string) =>
setFilters({ ...filters, actors: filters.actors.filter((x) => x !== name) });
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
@@ -280,6 +293,42 @@ export default function PlexBrowse() {
</div>
</PageToolbar>
{personCards.length > 0 && (
<div className="mb-4 flex flex-wrap gap-3">
{personCards.map((p) => (
<div
key={p.name}
className="glass-card flex items-center gap-2.5 rounded-xl py-1.5 pl-1.5 pr-2.5"
>
{p.photo ? (
<img
src={p.photo}
alt=""
loading="lazy"
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
<User className="w-5 h-5" />
</div>
)}
<div className="min-w-0">
<div className="text-sm font-medium truncate max-w-[150px]">{p.name}</div>
<div className="text-[11px] text-muted">{t("plex.people.count", { count: p.count })}</div>
</div>
<button
onClick={() => removeActor(p.name)}
title={t("plex.people.remove")}
aria-label={t("plex.people.remove")}
className="ml-1 p-1 rounded-full text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : items.length === 0 && episodes.length === 0 ? (
+49 -15
View File
@@ -33,6 +33,43 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
{ key: "long", min: 120 * 60, max: null },
];
// The any/all combine toggle for a multi-value filter (genres, actors). Same segmented-pill look as
// the feed rail's tag MATCH toggle (rounded-full track + inset active pill), not a boxed button row.
function MatchToggle({
mode,
onChange,
}: {
mode: "any" | "all";
onChange: (m: "any" | "all") => void;
}) {
const { t } = useTranslation();
return (
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wide text-muted">
{t("plex.filter.matchLabel")}
</span>
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-[10px]"
role="group"
aria-label={t("plex.filter.matchLabel")}
>
{(["any", "all"] as const).map((m) => (
<button
key={m}
onClick={() => onChange(m)}
aria-pressed={mode === m}
className={`rounded-full px-2 py-0.5 transition ${
mode === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{t(`plex.filter.match.${m}`)}
</button>
))}
</div>
</div>
);
}
export default function PlexSidebar({
layout,
setLayout,
@@ -211,6 +248,14 @@ export default function PlexSidebar({
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
/>
))}
{filters.actors.length > 1 && (
<div className="w-full">
<MatchToggle
mode={filters.actorMode ?? "any"}
onChange={(m) => patch({ actorMode: m })}
/>
</div>
)}
{filters.actors.map((a) => (
<RemovableChip
key={`a:${a}`}
@@ -295,21 +340,10 @@ export default function PlexSidebar({
body: (
<>
{filters.genres.length > 1 && (
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
{(["any", "all"] as const).map((m) => (
<button
key={m}
onClick={() => patch({ genreMode: m })}
className={`px-2 py-0.5 transition ${
(filters.genreMode ?? "any") === m
? "bg-accent text-accent-fg"
: "text-muted hover:bg-surface"
}`}
>
{t(`plex.filter.match.${m}`)}
</button>
))}
</div>
<MatchToggle
mode={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
)}
<ChipRow>
{facets.genres.map((g) => (
+8 -2
View File
@@ -57,7 +57,8 @@
},
"collection": "Collection",
"collectionSearch": "Search collections…",
"collectionEmpty": "No collections"
"collectionEmpty": "No collections",
"matchLabel": "Match"
},
"count": "{{count}} titles",
"searchCount": "{{count}} matches",
@@ -225,5 +226,10 @@
"addShow": "Add whole show to a playlist",
"addSeason": "Add season to a playlist",
"addEpisode": "Add episode to a playlist"
},
"people": {
"count_one": "{{count}} title",
"count_other": "{{count}} titles",
"remove": "Remove from filter"
}
}
}
+8 -2
View File
@@ -57,7 +57,8 @@
},
"collection": "Kollekció",
"collectionSearch": "Kollekciók keresése…",
"collectionEmpty": "Nincs kollekció"
"collectionEmpty": "Nincs kollekció",
"matchLabel": "Egyezés"
},
"count": "{{count}} cím",
"searchCount": "{{count}} találat",
@@ -225,5 +226,10 @@
"addShow": "Egész sorozat listához adása",
"addSeason": "Évad listához adása",
"addEpisode": "Epizód listához adása"
},
"people": {
"count_one": "{{count}} cím",
"count_other": "{{count}} cím",
"remove": "Eltávolítás a szűrőből"
}
}
}
+11 -1
View File
@@ -828,6 +828,11 @@ export interface PlexCastMember {
role?: string | null;
thumb?: string | null; // proxied person-image url, or null when Plex has no photo
}
export interface PlexPersonCard {
name: string;
photo?: string | null; // proxied person-image url, or null (placeholder)
count: number; // in-scope movies/shows featuring them
}
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
export interface PlexFilters {
genres: string[];
@@ -840,7 +845,8 @@ export interface PlexFilters {
durationMax?: number | null;
addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y"
directors: string[]; // AND — titles featuring ALL selected
actors: string[]; // AND — titles featuring ALL selected
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
collection?: string | null; // a single Plex collection (rating_key)
collectionTitle?: string | null; // its title, for the active-filter chip
@@ -882,6 +888,7 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.addedWithin) u.set("added_within", f.addedWithin);
if (f.directors?.length) u.set("directors", f.directors.join(","));
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.collection) u.set("collection", f.collection);
}
@@ -1349,6 +1356,9 @@ export const api = {
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
plexItem: (id: string): Promise<PlexItemDetail> =>
req(`/api/plex/item/${encodeURIComponent(id)}`),
// Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count.
plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> =>
req(`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`),
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
if (audio != null) p.set("audio", String(audio));