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

@ -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>
)}

View file

@ -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>
);
}

View file

@ -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}}",

View file

@ -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}}",

View file

@ -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}}",

View file

@ -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) ---

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 { 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}