diff --git a/backend/app/plex/watch_sync.py b/backend/app/plex/watch_sync.py index 42a9d37..9799972 100644 --- a/backend/app/plex/watch_sync.py +++ b/backend/app/plex/watch_sync.py @@ -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 diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 22afe1a..18ef4e8 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -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) diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index ba67459..0164b4c 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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 {children}; - return ( - - ); -} - function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { return ( - ); -} - 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 }>(["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( @@ -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(null); - const custMenuRef = useRef(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 (
) so it fills the content area and stays put while the info page scrolls over it - // (like the body's ambient pools). Scoped to
→ 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) => { - api.savePrefs(patch).catch(() => {}); - qc.setQueryData<{ preferences?: Record } | 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
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({

)} -
- - {customizing && ( -
- { - setArtBg((v) => !v); - savePref({ plexInfoArtBg: !artBg }); - }} - /> - { - setShowCast((v) => !v); - savePref({ plexInfoCast: !showCast }); - }} - /> - {presentSources.map((src) => ( - toggleSource(src)} - /> - ))} -
- )} -
+ 0} + extra={presentSources.map((src) => ( + toggleSource(src)} + /> + ))} + /> {overlay && onClose && ( - ); -} diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index 7a45d6b..49f8a0d 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -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}}", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 7c895c6..aa75fed 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -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}}", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 64263f0..d8d6a1a 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -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}}", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 59de070..93bb997 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 => { 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) --- diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index eedfbe1..13f4e33 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -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 {children}; + return ( + + ); +} + // Paint the item's art as a faint FIXED backdrop on the page's
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"}`} > @@ -122,13 +149,14 @@ export function DetailCustomizeMenu({ {hasRelated && onToggleRelated && ( )} + {extra} )} ); } -function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { +export function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { return (