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:
parent
9f8e410033
commit
b3af04a997
9 changed files with 180 additions and 450 deletions
|
|
@ -206,6 +206,51 @@ def push_state_to_plex(
|
|||
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) ------------------------
|
||||
|
||||
# Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import os
|
|||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
|
@ -18,7 +21,7 @@ from urllib.parse import quote
|
|||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||
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 app import sysconfig
|
||||
|
|
@ -389,171 +392,6 @@ def _apply_meta_filters(q, model, p: dict):
|
|||
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")
|
||||
def unified_library(
|
||||
scope: str = "both",
|
||||
|
|
@ -888,69 +726,6 @@ def _like_escape(s: str) -> str:
|
|||
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:
|
||||
"""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."""
|
||||
|
|
@ -1351,6 +1126,42 @@ def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user)
|
|||
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}")
|
||||
def show_detail(
|
||||
rating_key: str,
|
||||
|
|
@ -1384,14 +1195,15 @@ def show_detail(
|
|||
all_cards.append(card)
|
||||
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 = {}
|
||||
related: list[dict] = []
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
meta = plex.metadata(sh.rating_key) or {}
|
||||
rich = _rich_meta(meta)
|
||||
related = _related_show_cards(db, user.id, plex.related(sh.rating_key), exclude=sh.id)
|
||||
meta, related_raw = _show_enrichment(db, sh.rating_key)
|
||||
rich = _rich_meta(meta)
|
||||
related = _related_show_cards(db, user.id, related_raw, exclude=sh.id)
|
||||
except Exception:
|
||||
# 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.
|
||||
|
|
@ -2143,9 +1955,10 @@ def _bulk_state(
|
|||
if prev != "new":
|
||||
to_push.append((e.id, e.rating_key, "unwatched"))
|
||||
db.commit()
|
||||
if pushable:
|
||||
for item_id, rk, action in to_push:
|
||||
background.add_task(plex_watch.push_state_to_plex, user_id, item_id, rk, action, 0, 0)
|
||||
if pushable and to_push:
|
||||
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
||||
# 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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||
import {
|
||||
|
|
@ -28,7 +28,7 @@ import {
|
|||
} from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
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 PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
|
||||
|
|
@ -553,16 +553,6 @@ function epLabel(ep?: PlexCard | null): string | undefined {
|
|||
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 }) {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -967,14 +957,14 @@ function PlexShowView({
|
|||
<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">
|
||||
{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}
|
||||
</Fil>
|
||||
</Filterable>
|
||||
)}
|
||||
{show.content_rating && (
|
||||
<Fil onClick={onFilter ? () => onFilter({ contentRatings: [show.content_rating!] }) : undefined}>
|
||||
<Filterable onClick={onFilter ? () => onFilter({ contentRatings: [show.content_rating!] }) : undefined}>
|
||||
· {show.content_rating}
|
||||
</Fil>
|
||||
</Filterable>
|
||||
)}
|
||||
{show.season_count != null && <span>· {t("plex.seasons", { count: show.season_count })}</span>}
|
||||
{show.imdb_rating != null && (
|
||||
|
|
@ -990,13 +980,13 @@ function PlexShowView({
|
|||
{show.genres.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{show.genres.map((g) => (
|
||||
<Fil
|
||||
<Filterable
|
||||
key={g}
|
||||
className="text-[11px] rounded-full bg-surface px-2 py-0.5 text-muted"
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
</Fil>
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,9 @@
|
|||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
ListPlus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
|
||||
|
|
@ -38,26 +28,6 @@ type Props = {
|
|||
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 {
|
||||
if (!s) return "";
|
||||
const h = Math.floor(s / 3600);
|
||||
|
|
@ -78,11 +48,12 @@ export default function PlexInfo({
|
|||
const { t } = useTranslation();
|
||||
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 ??
|
||||
{}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean; plexHiddenStripSources?: string[] };
|
||||
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
||||
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
||||
{}) as { plexHiddenStripSources?: string[] };
|
||||
// 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.
|
||||
const [hiddenSources, setHiddenSources] = useState<string[]>(
|
||||
|
|
@ -98,46 +69,10 @@ export default function PlexInfo({
|
|||
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
|
||||
const [editorOpen, setEditorOpen] = 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
|
||||
// container (<main>) so it fills the content area and stays put while the info page scrolls over it
|
||||
// (like the body's ambient pools). Scoped to <main> → never covers the nav/filter sidebars. A soft
|
||||
// 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,
|
||||
);
|
||||
};
|
||||
// Faint art backdrop, HTPC-style (page variant only) — the shared hook paints the item's art as a
|
||||
// fixed background on <main> and clears it on unmount / when disabled / in the overlay variant.
|
||||
useArtBackdrop(detail.art, variant !== "overlay" && artBg);
|
||||
|
||||
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
|
||||
const setState = async (status: "new" | "watched") => {
|
||||
|
|
@ -180,48 +115,22 @@ export default function PlexInfo({
|
|||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
ref={custBtnRef}
|
||||
onClick={() => setCustomizing((v) => !v)}
|
||||
title={t("plex.info.customize")}
|
||||
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" />
|
||||
</button>
|
||||
{customizing && (
|
||||
<div
|
||||
ref={custMenuRef}
|
||||
className="glass-menu absolute right-0 top-full z-20 mt-1 w-52 rounded-xl p-1.5 text-sm"
|
||||
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>
|
||||
<DetailCustomizeMenu
|
||||
overlay={overlay}
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={detail.cast.length > 0}
|
||||
extra={presentSources.map((src) => (
|
||||
<PrefToggle
|
||||
key={src}
|
||||
label={sourceLabel(src)}
|
||||
on={!hiddenSources.includes(src)}
|
||||
onClick={() => toggleSource(src)}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
{overlay && onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
|
@ -534,21 +443,3 @@ export default function PlexInfo({
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,13 +93,6 @@
|
|||
"addShowCollection": "Zur Sammlung"
|
||||
},
|
||||
"playerSoon": "Player kommt bald — „{{title}}“",
|
||||
"people": {
|
||||
"match": "Personen",
|
||||
"actor": "Schauspieler",
|
||||
"director": "Regie",
|
||||
"count": "{{count}} Titel",
|
||||
"added": "hinzugefügt"
|
||||
},
|
||||
"collEditor": {
|
||||
"manage": "Sammlungen",
|
||||
"title": "Sammlungen — {{title}}",
|
||||
|
|
|
|||
|
|
@ -93,13 +93,6 @@
|
|||
"addShowCollection": "Add to collection"
|
||||
},
|
||||
"playerSoon": "Player coming soon — “{{title}}”",
|
||||
"people": {
|
||||
"match": "People",
|
||||
"actor": "Actor",
|
||||
"director": "Director",
|
||||
"count": "{{count}} titles",
|
||||
"added": "added"
|
||||
},
|
||||
"collEditor": {
|
||||
"manage": "Collections",
|
||||
"title": "Collections — {{title}}",
|
||||
|
|
|
|||
|
|
@ -93,13 +93,6 @@
|
|||
"addShowCollection": "Kollekcióhoz adás"
|
||||
},
|
||||
"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": {
|
||||
"manage": "Kollekciók",
|
||||
"title": "Kollekciók — {{title}}",
|
||||
|
|
|
|||
|
|
@ -820,11 +820,23 @@ export function plexFilterCount(f: PlexFilters): number {
|
|||
(f.collection ? 1 : 0)
|
||||
);
|
||||
}
|
||||
export interface PlexPerson {
|
||||
name: string;
|
||||
kind: "actor" | "director";
|
||||
count: number;
|
||||
photo?: string | null;
|
||||
// Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid
|
||||
// and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are
|
||||
// each caller's own concern and stay out of here).
|
||||
export function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
|
||||
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 {
|
||||
genres: { value: string; count: number }[];
|
||||
|
|
@ -1193,19 +1205,7 @@ export const api = {
|
|||
if (p.limit) u.set("limit", String(p.limit));
|
||||
const f = p.filters;
|
||||
if (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);
|
||||
appendPlexFilters(u, f);
|
||||
u.set("sort_dir", f.sortDir ?? "asc"); // default ascending
|
||||
}
|
||||
return req(`/api/plex/library?${u.toString()}`);
|
||||
|
|
@ -1215,25 +1215,9 @@ export const api = {
|
|||
plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise<PlexFacets> => {
|
||||
const u = new URLSearchParams({ scope });
|
||||
if (show && show !== "all") u.set("show", show);
|
||||
if (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);
|
||||
}
|
||||
if (f) appendPlexFilters(u, f);
|
||||
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[] }> =>
|
||||
req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`),
|
||||
// --- Collection editing (admin only; writes back to Plex) ---
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
|
|
@ -30,6 +30,7 @@ export function useDetailPrefs() {
|
|||
artBg,
|
||||
showCast,
|
||||
showRelated,
|
||||
savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||
toggleArtBg: () => {
|
||||
const next = !artBg;
|
||||
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),
|
||||
// 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) {
|
||||
|
|
@ -86,6 +109,8 @@ export function DetailCustomizeMenu({
|
|||
showRelated,
|
||||
onToggleRelated,
|
||||
hasRelated,
|
||||
overlay,
|
||||
extra,
|
||||
}: {
|
||||
artBg: boolean;
|
||||
onToggleArtBg: () => void;
|
||||
|
|
@ -95,6 +120,8 @@ export function DetailCustomizeMenu({
|
|||
showRelated?: boolean;
|
||||
onToggleRelated?: () => void;
|
||||
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 [open, setOpen] = useState(false);
|
||||
|
|
@ -107,7 +134,7 @@ export function DetailCustomizeMenu({
|
|||
ref={btnRef}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
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" />
|
||||
</button>
|
||||
|
|
@ -122,13 +149,14 @@ export function DetailCustomizeMenu({
|
|||
{hasRelated && onToggleRelated && (
|
||||
<PrefToggle label={t("plex.info.prefRelated")} on={showRelated ?? true} onClick={onToggleRelated} />
|
||||
)}
|
||||
{extra}
|
||||
</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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue