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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue