perf(plex): batch bulk pushes, cache show enrichment; remove dead code + DRY (F6/F8/F10)

F6: coalesce whole-show/season Plex watch-pushes into ONE background task
(push_bulk_state_to_plex) that reuses a single DB session + keep-alive Plex
client, instead of scheduling one task (own session + HTTP client) per episode.

F8: cache a show's live Plex enrichment (metadata + related) per rating_key for
a short TTL and fetch the two calls in parallel; repeat opens skip the network.
Raw payloads are cached; per-user shaping stays out. An empty related list is not
cached (plex.related swallows a transient failure as [] — don't pin it for the TTL).

F10: remove dead code — the pre-unified /browse route + browse(), the /people
route + people() + _person_photo(), api.plexPeople, interface PlexPerson, and the
orphaned plex.people i18n block. DRY: appendPlexFilters() shared by plexLibrary +
plexFacets; one exported plexDetailUi.Filterable (was Fil + Filterable); PlexInfo
migrated onto the shared plexDetailUi hooks/menu (DetailCustomizeMenu gained
overlay + extra props; useDetailPrefs exposes savePref; PrefToggle exported).

Reviewed (high) → clean. F7 (facet aggregate collapse) deferred: self-exclusion
gives each sub-aggregate a distinct WHERE, and no measurement shows /facets slow.
This commit is contained in:
npeter83 2026-07-11 03:10:02 +02:00
parent 9f8e410033
commit b3af04a997
9 changed files with 180 additions and 450 deletions

View file

@ -206,6 +206,51 @@ def push_state_to_plex(
db.commit() db.commit()
def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -> None:
"""Coalesced Siftlode→Plex push for a whole-show / whole-season mark (see `_bulk_state`). Same
contract as `push_state_to_plex` (own DB session, best-effort, never raises) but ONE background
task for the entire batch: it reuses a single DB session and a single keep-alive PlexClient across
all `changes` instead of scheduling N tasks that each rebuild a session + HTTP client. Plex has no
batch-scrobble endpoint, so the per-item HTTP calls remain (looped over the shared connection), but
the O(N) task/session/client setup is gone. `changes` is a list of (item_id, rating_key, action)
with action ``watched`` (scrobble) or ``unwatched`` (unscrobble)."""
if not changes:
return
with SessionLocal() as db:
if link_for_push(db, user_id) is None:
return
synced_ids: list[int] = []
try:
with PlexClient(db) as plex:
for item_id, rating_key, action in changes:
try:
if action == "watched":
plex.scrobble(rating_key)
elif action == "unwatched":
plex.unscrobble(rating_key)
else:
continue
except PlexError as e:
# One bad item mustn't abort the rest of the batch; leave its row dirty
# (synced_to_plex=False) for a later reconcile.
log.warning(
"Plex bulk push failed (user %s, item %s, %s): %s", user_id, item_id, action, e
)
continue
synced_ids.append(item_id)
except PlexError as e:
# Couldn't even open the client — nothing pushed; everything stays dirty for reconcile.
log.warning("Plex bulk push could not connect (user %s): %s", user_id, e)
return
# Flag every successfully-pushed row that still exists (an "unwatched" deletes the row, so
# only the "watched" changes have a row to flag).
if synced_ids:
db.query(PlexState).filter(
PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids)
).update({PlexState.synced_to_plex: True}, synchronize_session=False)
db.commit()
# --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------ # --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------
# Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just # Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just

View file

@ -11,6 +11,9 @@ import os
import re import re
import subprocess import subprocess
import tempfile import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@ -18,7 +21,7 @@ from urllib.parse import quote
import httpx import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_, text from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app import sysconfig from app import sysconfig
@ -389,171 +392,6 @@ def _apply_meta_filters(q, model, p: dict):
return q.filter(*conds) if conds else q return q.filter(*conds) if conds else q
@router.get("/browse")
def browse(
library: str,
q: str | None = None,
sort: str = "added",
show: str = "all",
offset: int = 0,
limit: int = Query(default=40, ge=1, le=100),
genres: str | None = None,
genre_mode: str = "any",
content_ratings: str | None = None,
year_min: int | None = None,
year_max: int | None = None,
rating_min: float | None = None,
duration_min: int | None = None,
duration_max: int | None = None,
added_within: str | None = None,
directors: str | None = None,
actors: str | None = None,
studios: str | None = None,
collection: str | None = None,
sort_dir: str = "desc",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""List a library's movies (leaves) or shows, with optional FTS search + sorting + a per-user
watch-state filter (`show`: all|unwatched|in_progress|watched movie libraries only). Movie
libraries return playable movie cards; show libraries return show cards (drill in via /show)."""
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
if lib is None:
raise HTTPException(status_code=404, detail="Unknown Plex library")
offset = max(0, offset)
model = PlexItem if lib.kind == "movie" else PlexShow
query = db.query(model)
if lib.kind == "movie":
query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
# Per-user watch-state filter (movies only; a show isn't a single watch unit).
st = aliased(PlexState)
query = query.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
if show == "watched":
query = query.filter(st.status == "watched")
elif show == "in_progress":
query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
elif show == "unwatched":
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
else: # all — hide only the explicitly hidden
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
# --- Metadata filters (movie libraries; @> containment is GIN-indexed) ---
gsel = _csv(genres)
if gsel:
conds = [PlexItem.genres.contains([g]) for g in gsel]
query = query.filter(and_(*conds) if genre_mode == "all" else or_(*conds))
crs = _csv(content_ratings)
if crs:
query = query.filter(PlexItem.content_rating.in_(crs))
if year_min is not None:
query = query.filter(PlexItem.year >= year_min)
if year_max is not None:
query = query.filter(PlexItem.year <= year_max)
if rating_min is not None:
query = query.filter(PlexItem.rating >= rating_min)
if duration_min is not None:
query = query.filter(PlexItem.duration_s >= duration_min)
if duration_max is not None:
query = query.filter(PlexItem.duration_s <= duration_max)
cutoff = _added_cutoff(added_within)
if cutoff is not None:
query = query.filter(PlexItem.added_at >= cutoff)
for d in _csv(directors): # AND — titles this whole set directed
query = query.filter(PlexItem.directors.contains([d]))
for a in _csv(actors): # AND — titles featuring all these people
query = query.filter(PlexItem.cast_names.contains([a]))
stds = _csv(studios)
if stds: # OR — a title has one studio
query = query.filter(PlexItem.studio.in_(stds))
else:
query = query.filter(PlexShow.library_id == lib.id)
# Aggregate per-user watch-state across the show's episodes (a show isn't one row of state).
if show in ("watched", "in_progress", "unwatched"):
agg = _show_agg_subq(db, user.id).subquery()
query = query.outerjoin(agg, agg.c.show_id == PlexShow.id)
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
if show == "watched":
query = query.filter(tot > 0, wat >= tot)
elif show == "in_progress":
query = query.filter(or_(wat > 0, inp > 0), wat < tot)
else: # unwatched — no episode started
query = query.filter(wat == 0, inp == 0)
# --- Metadata filters (same as movies, minus duration which shows don't have) ---
gsel = _csv(genres)
if gsel:
conds = [PlexShow.genres.contains([g]) for g in gsel]
query = query.filter(and_(*conds) if genre_mode == "all" else or_(*conds))
crs = _csv(content_ratings)
if crs:
query = query.filter(PlexShow.content_rating.in_(crs))
if year_min is not None:
query = query.filter(PlexShow.year >= year_min)
if year_max is not None:
query = query.filter(PlexShow.year <= year_max)
if rating_min is not None:
query = query.filter(PlexShow.rating >= rating_min)
cutoff = _added_cutoff(added_within)
if cutoff is not None:
query = query.filter(PlexShow.added_at >= cutoff)
for d in _csv(directors): # AND
query = query.filter(PlexShow.directors.contains([d]))
for a in _csv(actors): # AND
query = query.filter(PlexShow.cast_names.contains([a]))
stds = _csv(studios)
if stds: # OR
query = query.filter(PlexShow.studio.in_(stds))
# Collection filter applies to both movies and shows (both carry collection_keys).
if collection:
query = query.filter(model.collection_keys.contains([collection]))
ts = _to_tsquery_str(q) if q else None
tsq = None
if ts:
tsq = func.to_tsquery(_TS_CONFIG, ts)
query = query.filter(model.search_vector.op("@@")(tsq))
total = query.count()
if tsq is not None:
order = [func.ts_rank(model.search_vector, tsq).desc()]
else:
if sort == "title":
col = func.lower(model.title)
elif sort == "year": # both movies and shows have year
col = model.year
elif sort == "rating": # both have rating (audienceRating)
col = model.rating
elif sort == "duration" and model is PlexItem:
col = PlexItem.duration_s
elif sort == "release": # both have originally_available_at
col = model.originally_available_at
else: # added
col = model.added_at
asc = sort_dir == "asc"
order = [(col.asc() if asc else col.desc()).nullslast()]
order.append(model.id.desc())
rows = query.order_by(*order).offset(offset).limit(limit).all()
if lib.kind == "movie":
by_item = {r.id: r for r in rows}
states = {
s.item_id: s
for s in db.query(PlexState).filter(
PlexState.user_id == user.id, PlexState.item_id.in_(list(by_item.keys()) or [0])
)
}
items = [_movie_card(r, states.get(r.id)) for r in rows]
else:
statuses = _show_state_map(db, user.id, [r.id for r in rows])
items = [_show_card(r, statuses.get(r.id, "new")) for r in rows]
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
@router.get("/library") @router.get("/library")
def unified_library( def unified_library(
scope: str = "both", scope: str = "both",
@ -888,69 +726,6 @@ def _like_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@router.get("/people")
def people(
q: str,
library: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Cast/crew whose name matches the search term — drives the virtual person cards shown above the
grid. Returns up to a few matches (most films first) with a count + a photo."""
term = (q or "").strip()
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
if lib is None or lib.kind != "movie" or len(term) < 2:
return {"people": []}
esc = _like_escape(term)
pfx, word = f"{esc}%", f"% {esc}%" # name starts with the term, or any of its words does
rows = db.execute(
text(
"SELECT name, kind, count(*) c FROM ("
" SELECT jsonb_array_elements_text(cast_names) AS name, 'actor' AS kind FROM plex_items"
" WHERE library_id = :lib AND kind = 'movie'"
" UNION ALL"
" SELECT jsonb_array_elements_text(directors) AS name, 'director' AS kind FROM plex_items"
" WHERE library_id = :lib AND kind = 'movie'"
") x WHERE name ILIKE :pfx OR name ILIKE :word "
"GROUP BY name, kind ORDER BY c DESC, name LIMIT 5"
),
{"lib": lib.id, "pfx": pfx, "word": word},
).all()
return {
"people": [
{"name": name, "kind": kind, "count": c, "photo": _person_photo(db, lib.id, name, kind)}
for name, kind, c in rows
]
}
def _person_photo(db: Session, lib_id: int, name: str, kind: str) -> str | None:
"""A person's headshot from one representative film's live metadata (image bytes are disk-cached)."""
col = PlexItem.directors if kind == "director" else PlexItem.cast_names
rk = (
db.query(PlexItem.rating_key)
.filter(PlexItem.library_id == lib_id, col.contains([name]))
.limit(1)
.scalar()
)
if not rk:
return None
try:
with PlexClient(db) as plex:
meta = plex.metadata(rk) or {}
except (PlexError, PlexNotConfigured):
return None
for r in meta.get("Director" if kind == "director" else "Role") or []:
if r.get("tag") == name:
thumb = str(r.get("thumb") or "")
return (
f"/api/plex/person-image?u={quote(thumb, safe='')}"
if thumb.startswith(_PERSON_IMG_HOST)
else None
)
return None
def _collection_editable(c: PlexCollection) -> bool: def _collection_editable(c: PlexCollection) -> bool:
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never """Effective editability: the admin marked it editable AND it's a plain manual collection — never
smart, never an external auto-list (IMDb/TMDb/), which are managed outside Plex.""" smart, never an external auto-list (IMDb/TMDb/), which are managed outside Plex."""
@ -1351,6 +1126,42 @@ def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user)
return {"ok": True} return {"ok": True}
# Live-Plex enrichment for a show (cast/IMDb + related) is user-independent raw payload, so it's cached
# per rating_key for a short TTL and shared across users; per-user shaping happens in the caller. Bounds
# staleness to the TTL while turning repeat opens of the same show into zero network calls.
_SHOW_ENRICH_TTL_S = 300
_show_enrich_cache: dict[str, tuple[float, dict, list]] = {}
_show_enrich_lock = threading.Lock()
def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]:
"""(meta, related_raw) for a show's live Plex enrichment — served from the per-show TTL cache when
warm, otherwise fetched with the two Plex calls (metadata + related) run IN PARALLEL. Returns
({}, []) when Plex is unavailable/unconfigured (not cached, so it retries on the next open)."""
now = time.monotonic()
with _show_enrich_lock:
hit = _show_enrich_cache.get(rating_key)
if hit is not None and hit[0] > now:
return hit[1], hit[2]
try:
with PlexClient(db) as plex:
with ThreadPoolExecutor(max_workers=2) as ex:
meta_f = ex.submit(plex.metadata, rating_key)
related_f = ex.submit(plex.related, rating_key) # swallows its own errors → []
meta = meta_f.result() or {}
related_raw = related_f.result()
except (PlexError, PlexNotConfigured):
return {}, []
# Don't cache an EMPTY related list: plex.related() swallows a transient failure as [] (same as a
# genuinely no-related show), so caching it would serve an empty "related" section for the whole TTL
# even after Plex recovers. Skip the cache in that case (retry next open, as before); shows that DO
# have related items — the payload worth caching — still get cached.
if related_raw:
with _show_enrich_lock:
_show_enrich_cache[rating_key] = (now + _SHOW_ENRICH_TTL_S, meta, related_raw)
return meta, related_raw
@router.get("/show/{rating_key}") @router.get("/show/{rating_key}")
def show_detail( def show_detail(
rating_key: str, rating_key: str,
@ -1384,14 +1195,15 @@ def show_detail(
all_cards.append(card) all_cards.append(card)
show_roll = _rollup(all_cards) show_roll = _rollup(all_cards)
# Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). # Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). The two live
# Plex calls are cached per-show + fetched in parallel by _show_enrichment; the per-user shaping
# (_related_show_cards adds this user's watch-state) stays out of the shared cache.
rich: dict = {} rich: dict = {}
related: list[dict] = [] related: list[dict] = []
try: try:
with PlexClient(db) as plex: meta, related_raw = _show_enrichment(db, sh.rating_key)
meta = plex.metadata(sh.rating_key) or {} rich = _rich_meta(meta)
rich = _rich_meta(meta) related = _related_show_cards(db, user.id, related_raw, exclude=sh.id)
related = _related_show_cards(db, user.id, plex.related(sh.rating_key), exclude=sh.id)
except Exception: except Exception:
# Best-effort enrichment only — the local episode list is already assembled, so any live-Plex # Best-effort enrichment only — the local episode list is already assembled, so any live-Plex
# failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500. # failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500.
@ -2143,9 +1955,10 @@ def _bulk_state(
if prev != "new": if prev != "new":
to_push.append((e.id, e.rating_key, "unwatched")) to_push.append((e.id, e.rating_key, "unwatched"))
db.commit() db.commit()
if pushable: if pushable and to_push:
for item_id, rk, action in to_push: # One coalesced background task for the whole batch (reuses a single session + Plex client),
background.add_task(plex_watch.push_state_to_plex, user_id, item_id, rk, action, 0, 0) # not one task per episode — a 200-ep show mark is 1 task, not 200.
background.add_task(plex_watch.push_bulk_state_to_plex, user_id, to_push)
return len(to_push) return len(to_push)

View file

@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import { import {
@ -28,7 +28,7 @@ import {
} from "../lib/api"; } from "../lib/api";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history"; import { useHistorySubview } from "../lib/history";
import { DetailCustomizeMenu, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi"; import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
@ -553,16 +553,6 @@ function epLabel(ep?: PlexCard | null): string | undefined {
return ep.title; return ep.title;
} }
// A metadata value that filters the unified grid when clicked (if onClick is wired), else plain text.
function Fil({ onClick, className, children }: { onClick?: () => void; className?: string; children: ReactNode }) {
if (!onClick) return <span className={className}>{children}</span>;
return (
<button onClick={onClick} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
{children}
</button>
);
}
function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
return ( return (
<button <button
@ -967,14 +957,14 @@ function PlexShowView({
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1> <h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted"> <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
{show.year != null && ( {show.year != null && (
<Fil onClick={onFilter ? () => onFilter({ yearMin: show.year, yearMax: show.year }) : undefined}> <Filterable onClick={onFilter ? () => onFilter({ yearMin: show.year, yearMax: show.year }) : undefined}>
{show.year} {show.year}
</Fil> </Filterable>
)} )}
{show.content_rating && ( {show.content_rating && (
<Fil onClick={onFilter ? () => onFilter({ contentRatings: [show.content_rating!] }) : undefined}> <Filterable onClick={onFilter ? () => onFilter({ contentRatings: [show.content_rating!] }) : undefined}>
· {show.content_rating} · {show.content_rating}
</Fil> </Filterable>
)} )}
{show.season_count != null && <span>· {t("plex.seasons", { count: show.season_count })}</span>} {show.season_count != null && <span>· {t("plex.seasons", { count: show.season_count })}</span>}
{show.imdb_rating != null && ( {show.imdb_rating != null && (
@ -990,13 +980,13 @@ function PlexShowView({
{show.genres.length > 0 && ( {show.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{show.genres.map((g) => ( {show.genres.map((g) => (
<Fil <Filterable
key={g} key={g}
className="text-[11px] rounded-full bg-surface px-2 py-0.5 text-muted" className="text-[11px] rounded-full bg-surface px-2 py-0.5 text-muted"
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined} onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
> >
{g} {g}
</Fil> </Filterable>
))} ))}
</div> </div>
)} )}

View file

@ -1,19 +1,9 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useDismiss } from "../lib/useDismiss"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import {
Check,
ExternalLink,
FolderPlus,
ListPlus,
Play,
RotateCcw,
SlidersHorizontal,
Star,
X,
} from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd"; import PlexPlaylistAdd from "./PlexPlaylistAdd";
@ -38,26 +28,6 @@ type Props = {
library?: string; library?: string;
}; };
// A metadata value that filters the grid when clicked (if onFilter is wired), else plain text.
function Filterable({
onClick,
title,
className,
children,
}: {
onClick?: () => void;
title?: string;
className?: string;
children: ReactNode;
}) {
if (!onClick) return <span className={className}>{children}</span>;
return (
<button onClick={onClick} title={title} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
{children}
</button>
);
}
function fmtDur(s?: number | null): string { function fmtDur(s?: number | null): string {
if (!s) return ""; if (!s) return "";
const h = Math.floor(s / 3600); const h = Math.floor(s / 3600);
@ -78,11 +48,12 @@ export default function PlexInfo({
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// Personal display prefs (default ON). Stored per-user; the backend merges any pref key. // Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs:
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
// is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs();
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
{}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean; plexHiddenStripSources?: string[] }; {}) as { plexHiddenStripSources?: string[] };
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled // Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
// per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here. // per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
const [hiddenSources, setHiddenSources] = useState<string[]>( const [hiddenSources, setHiddenSources] = useState<string[]>(
@ -98,46 +69,10 @@ export default function PlexInfo({
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin"; const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [playlistOpen, setPlaylistOpen] = useState(false); const [playlistOpen, setPlaylistOpen] = useState(false);
const [customizing, setCustomizing] = useState(false);
const custBtnRef = useRef<HTMLButtonElement>(null);
const custMenuRef = useRef<HTMLDivElement>(null);
useDismiss(customizing, () => setCustomizing(false), [custMenuRef, custBtnRef]);
// Faint art backdrop, HTPC-style: paint the item's art as a FIXED background on the page's scroll // Faint art backdrop, HTPC-style (page variant only) — the shared hook paints the item's art as a
// container (<main>) so it fills the content area and stays put while the info page scrolls over it // fixed background on <main> and clears it on unmount / when disabled / in the overlay variant.
// (like the body's ambient pools). Scoped to <main> → never covers the nav/filter sidebars. A soft useArtBackdrop(detail.art, variant !== "overlay" && artBg);
// scrim keeps text readable; kept subtle. Page variant only, toggleable, restored on unmount/off.
useLayoutEffect(() => {
if (variant === "overlay") return;
const main = document.querySelector("main");
if (!main) return;
const s = main.style;
const clear = () => {
s.backgroundImage = "";
s.backgroundSize = "";
s.backgroundPosition = "";
s.backgroundRepeat = "";
s.backgroundAttachment = "";
};
if (artBg && detail.art) {
s.backgroundImage =
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${detail.art}")`;
s.backgroundSize = "cover";
s.backgroundPosition = "center 20%";
s.backgroundRepeat = "no-repeat";
s.backgroundAttachment = "fixed";
} else {
clear();
}
return clear;
}, [variant, artBg, detail.art]);
const savePref = (patch: Record<string, unknown>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
);
};
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched"; const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
const setState = async (status: "new" | "watched") => { const setState = async (status: "new" | "watched") => {
@ -180,48 +115,22 @@ export default function PlexInfo({
</p> </p>
)} )}
</div> </div>
<div className="relative shrink-0"> <DetailCustomizeMenu
<button overlay={overlay}
ref={custBtnRef} artBg={artBg}
onClick={() => setCustomizing((v) => !v)} onToggleArtBg={toggleArtBg}
title={t("plex.info.customize")} showCast={showCast}
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`} onToggleCast={toggleCast}
> hasCast={detail.cast.length > 0}
<SlidersHorizontal className="w-4 h-4" /> extra={presentSources.map((src) => (
</button> <PrefToggle
{customizing && ( key={src}
<div label={sourceLabel(src)}
ref={custMenuRef} on={!hiddenSources.includes(src)}
className="glass-menu absolute right-0 top-full z-20 mt-1 w-52 rounded-xl p-1.5 text-sm" onClick={() => toggleSource(src)}
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }} />
> ))}
<PrefToggle />
label={t("plex.info.prefArtBg")}
on={artBg}
onClick={() => {
setArtBg((v) => !v);
savePref({ plexInfoArtBg: !artBg });
}}
/>
<PrefToggle
label={t("plex.info.prefCast")}
on={showCast}
onClick={() => {
setShowCast((v) => !v);
savePref({ plexInfoCast: !showCast });
}}
/>
{presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
</div>
)}
</div>
{overlay && onClose && ( {overlay && onClose && (
<button <button
onClick={onClose} onClick={onClose}
@ -534,21 +443,3 @@ export default function PlexInfo({
</div> </div>
); );
} }
function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
>
<span>{label}</span>
<span
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
/>
</span>
</button>
);
}

View file

@ -93,13 +93,6 @@
"addShowCollection": "Zur Sammlung" "addShowCollection": "Zur Sammlung"
}, },
"playerSoon": "Player kommt bald — „{{title}}“", "playerSoon": "Player kommt bald — „{{title}}“",
"people": {
"match": "Personen",
"actor": "Schauspieler",
"director": "Regie",
"count": "{{count}} Titel",
"added": "hinzugefügt"
},
"collEditor": { "collEditor": {
"manage": "Sammlungen", "manage": "Sammlungen",
"title": "Sammlungen — {{title}}", "title": "Sammlungen — {{title}}",

View file

@ -93,13 +93,6 @@
"addShowCollection": "Add to collection" "addShowCollection": "Add to collection"
}, },
"playerSoon": "Player coming soon — “{{title}}”", "playerSoon": "Player coming soon — “{{title}}”",
"people": {
"match": "People",
"actor": "Actor",
"director": "Director",
"count": "{{count}} titles",
"added": "added"
},
"collEditor": { "collEditor": {
"manage": "Collections", "manage": "Collections",
"title": "Collections — {{title}}", "title": "Collections — {{title}}",

View file

@ -93,13 +93,6 @@
"addShowCollection": "Kollekcióhoz adás" "addShowCollection": "Kollekcióhoz adás"
}, },
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”", "playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
"people": {
"match": "Személyek",
"actor": "Színész",
"director": "Rendező",
"count": "{{count}} cím",
"added": "hozzáadva"
},
"collEditor": { "collEditor": {
"manage": "Kollekciók", "manage": "Kollekciók",
"title": "Kollekciók — {{title}}", "title": "Kollekciók — {{title}}",

View file

@ -820,11 +820,23 @@ export function plexFilterCount(f: PlexFilters): number {
(f.collection ? 1 : 0) (f.collection ? 1 : 0)
); );
} }
export interface PlexPerson { // Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid
name: string; // and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are
kind: "actor" | "director"; // each caller's own concern and stay out of here).
count: number; export function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
photo?: string | null; if (f.genres?.length) u.set("genres", f.genres.join(","));
if (f.genreMode === "all") u.set("genre_mode", "all");
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
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.studios?.length) u.set("studios", f.studios.join(","));
if (f.collection) u.set("collection", f.collection);
} }
export interface PlexFacets { export interface PlexFacets {
genres: { value: string; count: number }[]; genres: { value: string; count: number }[];
@ -1193,19 +1205,7 @@ export const api = {
if (p.limit) u.set("limit", String(p.limit)); if (p.limit) u.set("limit", String(p.limit));
const f = p.filters; const f = p.filters;
if (f) { if (f) {
if (f.genres?.length) u.set("genres", f.genres.join(",")); appendPlexFilters(u, f);
if (f.genreMode === "all") u.set("genre_mode", "all");
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
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.studios?.length) u.set("studios", f.studios.join(","));
if (f.collection) u.set("collection", f.collection);
u.set("sort_dir", f.sortDir ?? "asc"); // default ascending u.set("sort_dir", f.sortDir ?? "asc"); // default ascending
} }
return req(`/api/plex/library?${u.toString()}`); return req(`/api/plex/library?${u.toString()}`);
@ -1215,25 +1215,9 @@ export const api = {
plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise<PlexFacets> => { plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise<PlexFacets> => {
const u = new URLSearchParams({ scope }); const u = new URLSearchParams({ scope });
if (show && show !== "all") u.set("show", show); if (show && show !== "all") u.set("show", show);
if (f) { if (f) appendPlexFilters(u, f);
if (f.genres?.length) u.set("genres", f.genres.join(","));
if (f.genreMode === "all") u.set("genre_mode", "all");
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
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.studios?.length) u.set("studios", f.studios.join(","));
if (f.collection) u.set("collection", f.collection);
}
return req(`/api/plex/facets?${u.toString()}`); return req(`/api/plex/facets?${u.toString()}`);
}, },
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> => plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`), req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`),
// --- Collection editing (admin only; writes back to Plex) --- // --- Collection editing (admin only; writes back to Plex) ---

View file

@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from "react"; import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react"; import { SlidersHorizontal } from "lucide-react";
@ -30,6 +30,7 @@ export function useDetailPrefs() {
artBg, artBg,
showCast, showCast,
showRelated, showRelated,
savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources)
toggleArtBg: () => { toggleArtBg: () => {
const next = !artBg; const next = !artBg;
setArtBg(next); setArtBg(next);
@ -48,6 +49,28 @@ export function useDetailPrefs() {
}; };
} }
// A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared
// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional
// `title` gives a hover hint (e.g. "Filter by 1998"); callers that don't need it just omit it.
export function Filterable({
onClick,
title,
className,
children,
}: {
onClick?: () => void;
title?: string;
className?: string;
children: ReactNode;
}) {
if (!onClick) return <span className={className}>{children}</span>;
return (
<button onClick={onClick} title={title} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
{children}
</button>
);
}
// Paint the item's art as a faint FIXED backdrop on the page's <main> scroll container (HTPC-style), // Paint the item's art as a faint FIXED backdrop on the page's <main> scroll container (HTPC-style),
// so the glass panels float over it. Cleared on unmount / when disabled / when there's no art. // so the glass panels float over it. Cleared on unmount / when disabled / when there's no art.
export function useArtBackdrop(art: string | null | undefined, enabled: boolean) { export function useArtBackdrop(art: string | null | undefined, enabled: boolean) {
@ -86,6 +109,8 @@ export function DetailCustomizeMenu({
showRelated, showRelated,
onToggleRelated, onToggleRelated,
hasRelated, hasRelated,
overlay,
extra,
}: { }: {
artBg: boolean; artBg: boolean;
onToggleArtBg: () => void; onToggleArtBg: () => void;
@ -95,6 +120,8 @@ export function DetailCustomizeMenu({
showRelated?: boolean; showRelated?: boolean;
onToggleRelated?: () => void; onToggleRelated?: () => void;
hasRelated?: boolean; hasRelated?: boolean;
overlay?: boolean; // white-on-dark button styling when floated over the player
extra?: ReactNode; // additional toggles rendered after the built-in ones (e.g. strip-source buckets)
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -107,7 +134,7 @@ export function DetailCustomizeMenu({
ref={btnRef} ref={btnRef}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
title={t("plex.info.customize")} title={t("plex.info.customize")}
className="p-1.5 rounded-lg text-muted hover:bg-surface hover:text-fg" className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
> >
<SlidersHorizontal className="w-4 h-4" /> <SlidersHorizontal className="w-4 h-4" />
</button> </button>
@ -122,13 +149,14 @@ export function DetailCustomizeMenu({
{hasRelated && onToggleRelated && ( {hasRelated && onToggleRelated && (
<PrefToggle label={t("plex.info.prefRelated")} on={showRelated ?? true} onClick={onToggleRelated} /> <PrefToggle label={t("plex.info.prefRelated")} on={showRelated ?? true} onClick={onToggleRelated} />
)} )}
{extra}
</div> </div>
)} )}
</div> </div>
); );
} }
function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { export function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}