From e9c15baae051423b7d2f21a67bce6765572c94ac Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 03:29:54 +0200
Subject: [PATCH 01/54] fix(feed): a channel filter's chips name their channels
from the first paint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After a reload the chips (filter panel and the row above the feed) read as
raw UC… ids until the subscription list landed. The names seen last time
are now kept per account (useChannelNames) and name the chips at once; the
list then replaces the copy, so a renamed or dropped channel heals. A
channel never named on this browser draws a placeholder bone while the
list loads; a failed list still falls back to the id.
---
frontend/src/components/ActiveFilterChips.tsx | 19 ++-
frontend/src/components/ChannelPicker.tsx | 17 +--
.../components/channelChipNames.dom.test.tsx | 117 ++++++++++++++++++
frontend/src/components/ui/Combobox.tsx | 14 ++-
frontend/src/components/ui/Skeleton.tsx | 23 +++-
frontend/src/lib/storage.ts | 3 +
frontend/src/lib/useActiveFilters.ts | 22 ++--
frontend/src/lib/useChannelNames.ts | 44 +++++++
8 files changed, 227 insertions(+), 32 deletions(-)
create mode 100644 frontend/src/components/channelChipNames.dom.test.tsx
create mode 100644 frontend/src/lib/useChannelNames.ts
diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx
index 68baec50..17b6a3cd 100644
--- a/frontend/src/components/ActiveFilterChips.tsx
+++ b/frontend/src/components/ActiveFilterChips.tsx
@@ -9,6 +9,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
+import { ChipLabelPending } from "./ui/Skeleton";
import { SURFACE } from "./ui/surfaces";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
@@ -26,6 +27,16 @@ import { SURFACE } from "./ui/surfaces";
// the "fit some + N more" treatment used on wider lanes.
const NARROW_PX = 420;
+// A chip's text: its name, or a placeholder while the name is on its way. The visible chip, the
+// overflow popover's row and the hidden measuring mirror all draw it, so the mirror measures what shows.
+function ChipText({ f, className }: { f: ActiveFilter; className: string }) {
+ return f.pending ? (
+
+ ) : (
+ {f.label}
+ );
+}
+
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
const { t } = useTranslation();
return (
@@ -37,7 +48,7 @@ function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
onClick={f.remove}
removeLabel={t("feed.chips.remove", { name: f.label })}
>
- {f.label}
+
);
}
@@ -93,7 +104,7 @@ function OverflowPopover({
label={f.label}
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-fg transition"
>
- {f.label}
+
))}
@@ -134,7 +145,7 @@ export default function ActiveFilterChips({
const n = filters.length;
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
- const chipsKey = filters.map((f) => f.key + f.label).join("|");
+ const chipsKey = filters.map((f) => f.key + f.label + (f.pending ? "…" : "")).join("|");
// The shared measurement core hands over the chip right edges + gap + pill width + lane width; this
// policy adds the feed row's own framing (the leading label's right edge, and the trailing "Clear
@@ -191,7 +202,7 @@ export default function ActiveFilterChips({
}}
className={chipClass("token", "md") + " pr-1.5"}
>
- {f.label}
+
))}
diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx
index 495a5161..619fb7fe 100644
--- a/frontend/src/components/ChannelPicker.tsx
+++ b/frontend/src/components/ChannelPicker.tsx
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
-import { useQuery } from "@tanstack/react-query";
-import { qk } from "../lib/queryKeys";
-import { api, type FeedFilters } from "../lib/api";
+import type { FeedFilters } from "../lib/api";
+import { useChannelNames } from "../lib/useChannelNames";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
@@ -19,18 +18,13 @@ export default function ChannelPicker({
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
- const channelsQuery = useQuery({
- queryKey: qk.channels(),
- queryFn: api.channels,
- staleTime: 5 * 60_000,
- });
+ const { query: channelsQuery, names, pending } = useChannelNames();
const all = channelsQuery.data ?? [];
// The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the
// options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later
- // narrowing ever drops it from the visible rows.
+ // narrowing ever drops it from the visible rows, and names a reloaded filter before the list lands.
const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id }));
- const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.id]));
return (
void channelsQuery.refetch()}
emptyText={t("sidebar.noChannelMatch")}
- selectedLabels={selectedLabels}
+ selectedLabels={names}
+ labelsPending={pending}
/>
);
}
diff --git a/frontend/src/components/channelChipNames.dom.test.tsx b/frontend/src/components/channelChipNames.dom.test.tsx
new file mode 100644
index 00000000..9dccbdaf
--- /dev/null
+++ b/frontend/src/components/channelChipNames.dom.test.tsx
@@ -0,0 +1,117 @@
+// @vitest-environment jsdom
+//
+// A channel filter's chips are named from the subscription list, which a reload has not fetched yet:
+// they used to read as raw UC… ids for a moment (the owner, 2026-10-03). The names seen last time now
+// name them until the list lands; a channel never named on this browser draws a placeholder instead.
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createElement, type ReactNode } from "react";
+import { api, type FeedFilters, type ManagedChannel } from "../lib/api";
+import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
+import { accountKey, LS } from "../lib/storage";
+import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
+import ActiveFilterChips from "./ActiveFilterChips";
+import ChannelPicker from "./ChannelPicker";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
+ };
+});
+
+const KNOWN = "UCknown0000000000000000";
+const NEW = "UCnever00000000000000000";
+const filters: FeedFilters = {
+ ...clearedFilters({ q: "" } as FeedFilters),
+ channelIds: [KNOWN, NEW],
+};
+const namesKey = () => accountKey(LS.channelNames, 5)!;
+
+let resolveChannels: (c: ManagedChannel[]) => void;
+let qc: QueryClient;
+const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client: qc }, children);
+
+beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
+ localStorage.setItem(namesKey(), JSON.stringify({ [KNOWN]: "Remembered name" }));
+ qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ vi.spyOn(api, "tags").mockResolvedValue([] as never);
+ vi.spyOn(api, "channels").mockReturnValue(
+ new Promise((r) => {
+ resolveChannels = r;
+ }),
+ );
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const land = async () => {
+ await act(async () => {
+ resolveChannels([
+ { id: KNOWN, title: "Renamed since" },
+ { id: NEW, title: "Brand new" },
+ ] as ManagedChannel[]);
+ });
+};
+
+describe("a channel filter's chip names", () => {
+ it("name a remembered channel before the list lands, and hold the rest as pending", async () => {
+ const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
+ const chip = (id: string) => result.current.find((f) => f.key === `channel:${id}`)!;
+ expect(chip(KNOWN)).toMatchObject({ label: "Remembered name", pending: false });
+ expect(chip(NEW)).toMatchObject({ label: NEW, pending: true });
+
+ await land();
+ await waitFor(() => expect(chip(NEW)).toMatchObject({ label: "Brand new", pending: false }));
+ // The list wins over the remembered copy, and replaces it for the next reload.
+ expect(chip(KNOWN).label).toBe("Renamed since");
+ expect(JSON.parse(localStorage.getItem(namesKey())!)).toEqual({
+ [KNOWN]: "Renamed since",
+ [NEW]: "Brand new",
+ });
+ });
+
+ it("draw a placeholder above the feed, not the raw id", () => {
+ const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
+ const { container } = render(
+ {}} />,
+ );
+ expect(screen.getAllByText("Remembered name").length).toBeGreaterThan(0);
+ // The id is only a screen reader's (sr-only), beside a bone; nothing paints it.
+ for (const el of screen.getAllByText(NEW)) expect(el.className).toBe("sr-only");
+ expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
+ });
+
+ it("draw a placeholder in the filter panel's picker, then the name", async () => {
+ const { container } = render( {}} />, {
+ wrapper,
+ });
+ screen.getByText("Remembered name");
+ expect(screen.getByText(NEW).className).toBe("sr-only");
+ expect(container.querySelectorAll(".animate-pulse")).toHaveLength(1);
+
+ await land();
+ await waitFor(() => screen.getByText("Brand new"));
+ expect(screen.queryByText(NEW)).toBeNull();
+ expect(container.querySelectorAll(".animate-pulse")).toHaveLength(0);
+ });
+
+ it("fall back to the id when the list fails, so each chip stays told apart", async () => {
+ localStorage.removeItem(namesKey());
+ vi.spyOn(api, "channels").mockRejectedValue(new Error("down"));
+ const { result } = renderHook(() => useActiveFilters(filters, () => {}), { wrapper });
+ await waitFor(() =>
+ expect(result.current.find((f) => f.key === `channel:${NEW}`)).toMatchObject({
+ label: NEW,
+ pending: false,
+ }),
+ );
+ });
+});
diff --git a/frontend/src/components/ui/Combobox.tsx b/frontend/src/components/ui/Combobox.tsx
index a7a16ef1..8019acbf 100644
--- a/frontend/src/components/ui/Combobox.tsx
+++ b/frontend/src/components/ui/Combobox.tsx
@@ -11,6 +11,7 @@ import { InnerInput } from "./Input";
import { DismissibleChip } from "./Chip";
import { fieldClass } from "./fieldStyles";
import { SURFACE } from "./surfaces";
+import { ChipLabelPending } from "./Skeleton";
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
// removable chips that live INSIDE a text field you type into, and a checklist of options opens below.
@@ -72,6 +73,9 @@ export interface ComboboxProps {
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
selectedLabels?: Record;
+ /** The labels are still on their way: a selected value with none yet draws a placeholder chip
+ * instead of its raw value (a channel filter's UC… id after a reload). */
+ labelsPending?: boolean;
}
export default function Combobox({
@@ -93,6 +97,7 @@ export default function Combobox({
maxVisible = 50,
renderOption,
selectedLabels,
+ labelsPending = false,
}: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -113,7 +118,8 @@ export default function Combobox({
useEffect(() => {
for (const o of options) if (o.label != null) labelCache.current[o.value] = o.label;
}, [options]);
- const labelFor = (v: string) => selectedLabels?.[v] ?? labelCache.current[v] ?? v;
+ const knownLabel = (v: string) => selectedLabels?.[v] ?? labelCache.current[v];
+ const labelFor = (v: string) => knownLabel(v) ?? v;
const setOpenAnd = (next: boolean) => {
if (next && disabled) return;
@@ -210,7 +216,11 @@ export default function Combobox({
onDismiss={() => !disabled && onChange(selected.filter((x) => x !== v))}
dismissLabel={t("common.removeNamed", { name: labelFor(v) })}
>
- {labelFor(v)}
+ {labelsPending && knownLabel(v) === undefined ? (
+
+ ) : (
+ labelFor(v)
+ )}
))}
);
}
+/** A chip whose name is still on its way: a name-sized bone, not the raw value (a channel's UC… id).
+ * A screen reader still gets the value, so two such chips stay told apart. */
+export function ChipLabelPending({ value }: { value: string }) {
+ return (
+ <>
+
+ {value}
+ >
+ );
+}
+
export type SkeletonShape = "grid" | "poster" | "list" | "table" | "text";
const COUNT: Record = { grid: 8, poster: 12, list: 6, table: 8, text: 5 };
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
index 1a2feee7..82fe7d7d 100644
--- a/frontend/src/lib/storage.ts
+++ b/frontend/src/lib/storage.ts
@@ -41,6 +41,9 @@ export const LS = {
channelCardSize: "siftlode.channelCardSize",
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
channelsTable: "siftlode.channelsTable",
+ // Channel id → name as last seen (useChannelNames): names a channel filter's chips after a reload,
+ // before the subscription list has landed. A cache, rewritten from the list on every load.
+ channelNames: "siftlode.channelNames",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
// statsTab below.
diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts
index 08fe89c2..44a5520b 100644
--- a/frontend/src/lib/useActiveFilters.ts
+++ b/frontend/src/lib/useActiveFilters.ts
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
-import { useQuery } from "@tanstack/react-query";
-import { qk } from "./queryKeys";
-import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
+import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
+import { useChannelNames } from "./useChannelNames";
import { parseSort } from "./feedSort";
import { useTags } from "./useTags";
@@ -9,6 +8,8 @@ import { useTags } from "./useTags";
export interface ActiveFilter {
key: string;
label: string;
+ /** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
+ pending?: boolean;
remove: () => void;
}
@@ -49,12 +50,7 @@ export function useActiveFilters(
): ActiveFilter[] {
const { t } = useTranslation();
const tagsQuery = useTags();
- const channelsQuery = useQuery({
- queryKey: qk.channels(),
- queryFn: api.channels,
- enabled: filters.channelIds.length > 0,
- staleTime: 5 * 60_000,
- });
+ const channelNames = useChannelNames(filters.channelIds.length > 0);
const out: ActiveFilter[] = [];
@@ -113,9 +109,11 @@ export function useActiveFilters(
for (const id of filters.channelIds) {
out.push({
key: `channel:${id}`,
- // Fall back to the id, not a generic label: several chips reading "This channel" while
- // the name cache loads would be indistinguishable, and each ✕ drops a different one.
- label: channelsQuery.data?.find((c) => c.id === id)?.title ?? id,
+ // Fall back to the id, not a generic label: several chips reading "This channel" would be
+ // indistinguishable, and each ✕ drops a different one. While the list loads, a channel this
+ // browser has never named draws a placeholder instead (the id stays its accessible name).
+ label: channelNames.names[id] ?? id,
+ pending: channelNames.names[id] === undefined && channelNames.pending,
remove: () =>
setFilters({ ...filters, channelIds: filters.channelIds.filter((x) => x !== id) }),
});
diff --git a/frontend/src/lib/useChannelNames.ts b/frontend/src/lib/useChannelNames.ts
new file mode 100644
index 00000000..7cbbd18d
--- /dev/null
+++ b/frontend/src/lib/useChannelNames.ts
@@ -0,0 +1,44 @@
+import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { qk } from "./queryKeys";
+import { api, type ManagedChannel } from "./api";
+import { LS, readAccount, writeAccount } from "./storage";
+
+type Names = Record;
+
+const namesOf = (channels: ManagedChannel[]): Names =>
+ Object.fromEntries(channels.map((c) => [c.id, c.title ?? c.id]));
+
+const readNames = (): Names => {
+ const raw = readAccount(LS.channelNames, {});
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Names) : {};
+};
+
+/**
+ * Channel id → name, for the chips that name a channel filter (the filter panel's picker and the chip
+ * row above the feed). The names come from the subscription list, which a reload has not fetched yet:
+ * for a moment the chips read as raw UC… ids (the owner, 2026-10-03). The names seen last time are
+ * kept per account and name the chips until the list lands; the list then wins and replaces the copy,
+ * so a renamed or dropped channel heals on the next load.
+ *
+ * `pending` is true while the list loads: a channel this browser has never named then has no name to
+ * show, and the chip draws a placeholder rather than its id. A failed list leaves the id (the chips
+ * stay told apart, and each ✕ drops a different one).
+ */
+export function useChannelNames(enabled = true) {
+ const query = useQuery({
+ queryKey: qk.channels(),
+ queryFn: api.channels,
+ enabled,
+ staleTime: 5 * 60_000,
+ });
+ const [remembered] = useState(readNames);
+ useEffect(() => {
+ if (query.data) writeAccount(LS.channelNames, namesOf(query.data));
+ }, [query.data]);
+ return {
+ query,
+ names: query.data ? namesOf(query.data) : remembered,
+ pending: enabled && query.isPending,
+ };
+}
From 3e96211db08facfff713e35d3f2d87d7cebf8b2b Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 04:38:50 +0200
Subject: [PATCH 02/54] fix(plex): the backdrop crossfade toggle wins over
reduced motion once set
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
prefers-reduced-motion hard-disabled the rotation whatever the toggle said,
and Windows reports it whenever its animation effects are off — so the
owner never saw the crossfade. Untouched, the setting now follows the
system (and the toggle shows that state); a pick made on the toggle is
stored and wins on every device.
---
frontend/src/lib/plexBackdrop.dom.test.tsx | 61 +++++++++++++++++++---
frontend/src/lib/plexDetail.ts | 41 ++++++++++-----
2 files changed, 81 insertions(+), 21 deletions(-)
diff --git a/frontend/src/lib/plexBackdrop.dom.test.tsx b/frontend/src/lib/plexBackdrop.dom.test.tsx
index c12e0826..e24c61de 100644
--- a/frontend/src/lib/plexBackdrop.dom.test.tsx
+++ b/frontend/src/lib/plexBackdrop.dom.test.tsx
@@ -2,12 +2,17 @@
//
// E5 S8: the crossfading art backdrop hook. Covers the pieces that don't need real layout — that a
// pool paints #root + isolates it (so the z-index:-1 overlay can paint above #root's own background),
-// creates exactly one overlay layer when it may animate, and HARD-OFFS the rotation (no overlay, first
-// image only) under prefers-reduced-motion, a single image, or when disabled. matchMedia is stubbed
-// per-test; the hook reads it at effect time.
+// creates exactly one overlay layer when it may animate, and stays static (no overlay, first image
+// only) for a single image, the crossfade off, or when disabled. Then the crossfade setting itself:
+// untouched it follows prefers-reduced-motion, and the reader's own pick wins over it (the owner, whose
+// Windows animation effects are off, never saw the crossfade — 2026-10-03). matchMedia is stubbed per test.
import { describe, it, expect, afterEach, vi } from "vitest";
-import { render, cleanup } from "@testing-library/react";
-import { useCrossfadeBackdrop } from "./plexDetail";
+import { render, cleanup, renderHook, act } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createElement, type ReactNode } from "react";
+import { api } from "./api";
+import { qk } from "./queryKeys";
+import { useCrossfadeBackdrop, useDetailPrefs } from "./plexDetail";
function mockReducedMotion(reduce: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
@@ -64,12 +69,11 @@ describe("useCrossfadeBackdrop", () => {
expect(overlays().length).toBe(1);
});
- it("hard-offs the rotation under prefers-reduced-motion (static, no overlay)", () => {
+ it("animates under prefers-reduced-motion once the reader's setting says so", () => {
withRoot();
mockReducedMotion(true);
render( );
- expect(root().style.backgroundImage).toContain('url("/a.jpg")'); // first image still painted
- expect(overlays().length).toBe(0); // but no crossfade layer
+ expect(overlays().length).toBe(1);
});
it("stays static for a single image (no overlay)", () => {
@@ -101,3 +105,44 @@ describe("useCrossfadeBackdrop", () => {
expect(root().style.isolation).toBe("");
});
});
+
+describe("the crossfade setting", () => {
+ const prefsWith = (preferences: Record) => {
+ const qc = new QueryClient();
+ qc.setQueryData(qk.me(), { preferences });
+ return ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client: qc }, children);
+ };
+
+ it.each([
+ [false, true],
+ [true, false],
+ ])("untouched, follows the system (reduced motion %s → crossfade %s)", (reduce, on) => {
+ mockReducedMotion(reduce);
+ const { result } = renderHook(() => useDetailPrefs(), { wrapper: prefsWith({}) });
+ expect(result.current.crossfade).toBe(on);
+ });
+
+ it("keeps the reader's own pick over the system", () => {
+ mockReducedMotion(true);
+ const on = renderHook(() => useDetailPrefs(), {
+ wrapper: prefsWith({ plexInfoArtCrossfade: true }),
+ });
+ expect(on.result.current.crossfade).toBe(true);
+ mockReducedMotion(false);
+ const off = renderHook(() => useDetailPrefs(), {
+ wrapper: prefsWith({ plexInfoArtCrossfade: false }),
+ });
+ expect(off.result.current.crossfade).toBe(false);
+ });
+
+ it("turns on from the state in force, and stores that as the reader's pick", () => {
+ mockReducedMotion(true);
+ const save = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
+ const { result } = renderHook(() => useDetailPrefs(), { wrapper: prefsWith({}) });
+ act(() => result.current.toggleCrossfade());
+ expect(result.current.crossfade).toBe(true);
+ expect(save).toHaveBeenCalledWith({ plexInfoArtCrossfade: true });
+ save.mockRestore();
+ });
+});
diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts
index 3834d464..ece8475b 100644
--- a/frontend/src/lib/plexDetail.ts
+++ b/frontend/src/lib/plexDetail.ts
@@ -1,4 +1,4 @@
-import { useLayoutEffect } from "react";
+import { useLayoutEffect, useSyncExternalStore } from "react";
import { usePref, useSavePref } from "./usePref";
import type { TFunction } from "i18next";
import type { PlexCard, PlexTmdb } from "./api";
@@ -7,6 +7,18 @@ import type { PlexCard, PlexTmdb } from "./api";
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
// plexDetailUi exports only components (Fast Refresh).
+const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
+const reducedMotion = () =>
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia(REDUCED_MOTION).matches;
+const onReducedMotionChange = (cb: () => void) => {
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
+ const m = window.matchMedia(REDUCED_MOTION);
+ m.addEventListener("change", cb);
+ return () => m.removeEventListener("change", cb);
+};
+
export function useDetailPrefs() {
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
@@ -14,9 +26,16 @@ export function useDetailPrefs() {
const [artBg, setArtBg] = usePref("plexInfoArtBg", true);
const [showCast, setShowCast] = usePref("plexInfoCast", true);
const [showRelated, setShowRelated] = usePref("plexInfoRelated", true);
- // E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Independent
- // of prefers-reduced-motion, which hard-disables the rotation regardless of this toggle.
- const [crossfade, setCrossfade] = usePref("plexInfoArtCrossfade", true);
+ // E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Untouched,
+ // it follows the system: off where the reader asked for less motion (prefers-reduced-motion — which
+ // Windows also reports whenever its animation effects are off). Once the reader turns it on or off
+ // here, that pick wins on every device: the owner never saw the crossfade, because the system signal
+ // used to switch it off whatever the toggle said (2026-10-03). The toggle shows the state in force.
+ const reduce = useSyncExternalStore(onReducedMotionChange, reducedMotion, () => false);
+ const [crossfadePick, setCrossfade] = usePref("plexInfoArtCrossfade", null, {
+ parse: (raw) => (typeof raw === "boolean" ? raw : null),
+ });
+ const crossfade = crossfadePick ?? !reduce;
const savePref = useSavePref();
return {
artBg,
@@ -103,8 +122,8 @@ export function tmdbBackdropUrl(path: string): string {
// content, so the dissolve is visible under the chrome. Once faded, the settled image is committed to
// #root and the overlay reset to transparent — a double buffer, so only ever ONE extra DOM node.
//
-// The rotation is HARD-OFF under prefers-reduced-motion (shows only the first image, no movement) and
-// when there are fewer than two images or it's disabled; `attachment:fixed` keeps it viewport-anchored
+// The rotation runs only when `animate` says so — the reader's crossfade setting, which already folds in
+// prefers-reduced-motion (useDetailPrefs) — and there are at least two images; `attachment:fixed` keeps it viewport-anchored
// while the content scrolls. Cleared on unmount / disable / empty pool.
const BACKDROP_FADE_MS = 1600;
const BACKDROP_INTERVAL_MS = 7000;
@@ -148,13 +167,9 @@ export function useCrossfadeBackdrop(
}
paint(pool[0]!);
- const reduce =
- typeof window !== "undefined" &&
- typeof window.matchMedia === "function" &&
- window.matchMedia("(prefers-reduced-motion: reduce)").matches;
- // Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise
- // #root keeps the painted backdrop after leaving the page (e.g. switching to another module).
- if (!animate || reduce || pool.length < 2) return clear;
+ // Static case (one image / crossfade off): still MUST clear on unmount — otherwise #root keeps the
+ // painted backdrop after leaving the page (e.g. switching to another module).
+ if (!animate || pool.length < 2) return clear;
const layer = document.createElement("div");
Object.assign(layer.style, {
From cdfe7a8986c68cea01d5bbddb49b00585d80b7f9 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 05:10:52 +0200
Subject: [PATCH 03/54] perf(channels): the row menu opens and closes without
re-rendering the table
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The React Compiler silently skipped Channels and DataTable (a ref written
during render; the refs lint is off), and even once compiled it dropped the
memo of the rows and columns (they reach filterRows/optionsFromColumns past
the page's hooks) and the columns read the open row. Every open, close and
reposition of the right-click menu rebuilt the whole table: on "All" rows
~0.9 s to open and ~0.6 s to close (dev). Now: effect events instead of the
render-written refs, explicit memos for rows, columns and their inputs, a
module-scope keyOf, and the open row reaches its "…" through a small store.
Measured on 397 rows (dev): ~75 ms to open, ~35 ms to close.
A contract test runs the real compiler over both files and fails on any
skipped function. The dropped memos have no automated guard: they need
the full page rendered with its providers; the measurement above is the
evidence.
---
frontend/package-lock.json | 1 +
frontend/package.json | 1 +
frontend/src/components/Channels.tsx | 288 ++++++++++++------
.../components/ChannelsProvider.dom.test.tsx | 16 +-
.../compilerCoverage.contract.test.ts | 66 ++++
frontend/src/components/ui/DataTable.tsx | 48 ++-
6 files changed, 295 insertions(+), 125 deletions(-)
create mode 100644 frontend/src/components/compilerCoverage.contract.test.ts
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3fa176cc..78063d2e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -23,6 +23,7 @@
"react-i18next": "^15.7.4"
},
"devDependencies": {
+ "@babel/core": "7.29.7",
"@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3",
diff --git a/frontend/package.json b/frontend/package.json
index fc8a554e..e9bd1f67 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -39,6 +39,7 @@
"react-i18next": "^15.7.4"
},
"devDependencies": {
+ "@babel/core": "7.29.7",
"@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3",
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index 59730f94..9048789f 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -1,4 +1,12 @@
-import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useEffectEvent,
+ useMemo,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -76,6 +84,57 @@ const LAYOUT_ICON: Record = {
cards: LayoutGrid,
};
+// The row menu's identity for a channel. Module scope, as useContextMenu asks: a fresh function per
+// render rebuilt its `buttonProps` on every render, and with them every column.
+const channelKey = (c: ManagedChannel) => c.id;
+
+// Which channel's row menu is open, for its "…" (`aria-expanded`, and staying shown while open). A
+// tiny store rather than a context: a context change walks the whole table to find its readers (~20
+// ms on 400 rows, each open and close), while a store calls its subscribers and re-renders only the
+// two buttons whose answer changed.
+function createOpenRowStore() {
+ let open: string | null = null;
+ const listeners = new Set<() => void>();
+ return {
+ get: () => open,
+ set: (next: string | null) => {
+ if (next === open) return;
+ open = next;
+ listeners.forEach((l) => l());
+ },
+ subscribe: (l: () => void) => {
+ listeners.add(l);
+ return () => void listeners.delete(l);
+ },
+ };
+}
+type OpenRowStore = ReturnType;
+
+function RowMoreButton({
+ id,
+ label,
+ menuProps,
+ register,
+ openRow,
+}: {
+ id: string;
+ label: string;
+ menuProps: Record;
+ register: (id: string, el: HTMLButtonElement | null) => void;
+ openRow: OpenRowStore;
+}) {
+ const mine = useSyncExternalStore(openRow.subscribe, () => openRow.get() === id);
+ return (
+ register(id, el)}
+ label={label}
+ expanded={mine}
+ menuProps={menuProps}
+ className={mine ? undefined : "reveal"}
+ />
+ );
+}
+
export default function Channels() {
const me = useMe();
const panel = useTabPanel();
@@ -113,11 +172,16 @@ export default function Channels() {
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
// callback) now that Channels reads the feed filters + navigation straight from their providers.
- const onFilterByTag = (tagId: number, name: string) => {
- setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
- setPage("feed");
- notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
- };
+ // useCallback (like `onView`): the column memo below names it, and the hook lint cannot see the
+ // compiler's memo of a plain closure.
+ const onFilterByTag = useCallback(
+ (tagId: number, name: string) => {
+ setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
+ setPage("feed");
+ notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
+ },
+ [setFeedFilters, feedFilters, setPage, t],
+ );
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const tagsQuery = useTags();
@@ -143,7 +207,9 @@ export default function Channels() {
qc.invalidateQueries({ queryKey: qk.feedSummary() });
};
- const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
+ // A memo for the same reason as the tag actions below: the column memo names it.
+ const allTags = tagsQuery.data;
+ const userTags = useMemo(() => (allTags ?? []).filter((t) => !t.system), [allTags]);
// The row's stepper updates priority optimistically in place and does NOT re-sort/refetch, so the
// list doesn't jump under a press-and-hold (the server list is priority-sorted; refetching would
@@ -181,35 +247,41 @@ export default function Channels() {
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
// attach/detach + create-and-attach flow is shared with the channel detail page.
- const { toggleTag, createAndAttachTag } = useChannelTagActions({
- optimisticToggle: (id, tagId, attach) =>
- qc.setQueryData(qk.channels(), (old) =>
- (old ?? []).map((ch) =>
- ch.id === id
- ? {
- ...ch,
- tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
- }
- : ch,
+ const tagActionOpts = useMemo[0]>(
+ () => ({
+ optimisticToggle: (id, tagId, attach) =>
+ qc.setQueryData(qk.channels(), (old) =>
+ (old ?? []).map((ch) =>
+ ch.id === id
+ ? {
+ ...ch,
+ tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
+ }
+ : ch,
+ ),
),
- ),
- // Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
- // from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
- // recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
- // the learned pattern (E8·S4).
- onSuccess: (id) => {
- qc.invalidateQueries({ queryKey: qk.channel(id) });
- qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
- },
- // A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
- // suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
- // failure the tag never actually attached — so the server still suggests it and the ghost chips
- // must come back rather than leaving an empty cell (honest states). The hook reports the failure.
- onError: () => {
- qc.invalidateQueries({ queryKey: qk.channels() });
- qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
- },
- });
+ // Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
+ // from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
+ // recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
+ // the learned pattern (E8·S4).
+ onSuccess: (id) => {
+ qc.invalidateQueries({ queryKey: qk.channel(id) });
+ qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
+ },
+ // A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
+ // suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
+ // failure the tag never actually attached — so the server still suggests it and the ghost chips
+ // must come back rather than leaving an empty cell (honest states). The hook reports the failure.
+ onError: () => {
+ qc.invalidateQueries({ queryKey: qk.channels() });
+ qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
+ },
+ }),
+ [qc],
+ );
+ // The options are a memo so the hook's toggleTag/createAndAttachTag stay the same functions: the
+ // column memo below names them (see `columns`).
+ const { toggleTag, createAndAttachTag } = useChannelTagActions(tagActionOpts);
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The
@@ -291,21 +363,19 @@ export default function Channels() {
];
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
- // payload, so it rides a ref. Naming it would re-seed the box on any render that changed it,
- // overwriting whatever the user had since typed.
+ // payload, so it is read through an effect event. Naming it would re-seed the box on any render that
+ // changed it, overwriting whatever the user had since typed.
//
// `consumeFocus` is what stops this page's OWN remounts from re-applying it: this effect runs on
// every mount, and this page unmounts on every navigation away — and on opening a channel page,
// which does not even change `page`. The provider then reports `focusName` as null, so a later
// visit leaves the box exactly as the user left it.
- const focusChannelNameRef = useRef(focusChannelName);
- focusChannelNameRef.current = focusChannelName;
- useEffect(() => {
- const name = focusChannelNameRef.current;
- if (!name) return;
- setSearch(name);
+ const applyFocus = useEffectEvent(() => {
+ if (!focusChannelName) return;
+ setSearch(focusChannelName);
consumeFocus();
- }, [focusChannelToken, setSearch, consumeFocus]);
+ });
+ useEffect(() => applyFocus(), [focusChannelToken]);
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
const resetRef = useRef(filtersResetToken);
@@ -315,34 +385,25 @@ export default function Channels() {
setSearch("");
}, [filtersResetToken, setSearch]);
- // Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
- // chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
- // column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's
- // applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards,
- // still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the
- // name search filters at this step; the remaining column funnels run in DataTable / filterRows below.
- const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
- const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
- const q = search.trim().toLowerCase();
- const visibleChannels = channels.filter(
- (c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
+ const onView = useCallback(
+ (c: ManagedChannel) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")),
+ [onViewChannel, t],
);
- // Sync-status pre-filter (top chips → `filters.sync`), applied before the table/cards see the rows.
- // (Inline, not memoised: `visibleChannels` is a fresh array each render, so a useMemo keyed on it
- // could never hit its cache — and applySyncFilter is a cheap single pass anyway.)
- const syncedChannels = applySyncFilter(visibleChannels, filters.sync);
- const s = statusQuery.data;
-
- const onView = (c: ManagedChannel) =>
- onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
// The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
// Keyed by id: a tag toggled in the menu, a priority picked, a poll — each rebuilds the row object
// under the open menu, and its "…" must still know the menu is its own.
- const rowMenu = useContextMenu({ keyOf: (c) => c.id });
+ const rowMenu = useContextMenu({ keyOf: channelKey });
const rowPin = useComfortPin(rowMenu.open);
+ // The columns read only the menu's STABLE handles. Which row's menu is open reaches each "…" through
+ // the open-row store instead: read in the columns, it rebuilt every column on each open, close and
+ // reposition, and so re-rendered the whole table — ~0.8 s to open the menu on 400 rows.
+ const { buttonProps: rowButtonProps, triggerProps: rowTriggerProps } = rowMenu;
+ const openRowId = rowMenu.open ? (rowMenu.target?.id ?? null) : null;
+ const [openRow] = useState(createOpenRowStore);
+ useEffect(() => openRow.set(openRowId), [openRow, openRowId]);
const channelActions = useChannelActions({
onView,
onOpenWizard,
@@ -361,41 +422,86 @@ export default function Channels() {
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
})
: [];
- // Distinct priority levels present in the catalog → the priority column's filter options.
- const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))]
- .sort((a, b) => b - a)
- .map((p) => ({ value: String(p), label: String(p) }));
- const columns = buildChannelColumns({
+ // Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
+ // chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
+ // column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's
+ // applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards,
+ // still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the
+ // name search filters at this step; the remaining column funnels run in DataTable / filterRows below.
+ //
+ // The table's rows and columns are explicit memos, not inline. Both later reach calls the React
+ // Compiler must assume may mutate them (`filterRows`, `sortRows`, `optionsFromColumns`) past this
+ // page's hooks, and a compiler memo cannot span a hook call — so inline, the compiler dropped their
+ // memo without a word, and every render (each open, close and reposition of the row menu) handed
+ // DataTable new rows and columns: ~0.9 s to open the menu on 400 rows. A useMemo result is frozen.
+ const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
+ const allChannels = channelsQuery.data;
+ const q = search.trim().toLowerCase();
+ const syncFilterValue = filters.sync;
+ const syncedChannels = useMemo(
+ () =>
+ // Sync-status pre-filter (top chips → `filters.sync`), applied before the table/cards see the rows.
+ applySyncFilter(
+ (allChannels ?? []).filter(
+ (c) =>
+ !c.hidden && (!q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)),
+ ),
+ syncFilterValue,
+ ),
+ [allChannels, q, syncFilterValue],
+ );
+ const s = statusQuery.data;
+
+ const columns = useMemo(() => {
+ const registerMore = (id: string, el: HTMLButtonElement | null) => {
+ if (el) moreRefs.current.set(id, el);
+ else moreRefs.current.delete(id);
+ };
+ return buildChannelColumns({
+ t,
+ lang,
+ // Distinct priority levels present in the catalog → the priority column's filter options.
+ prioOptions: [...new Set((allChannels ?? []).map((c) => c.priority))]
+ .sort((a, b) => b - a)
+ .map((p) => ({ value: String(p), label: String(p) })),
+ userTags,
+ bumpPriority,
+ setPriority,
+ more: (c) => (
+
+ ),
+ onView,
+ toggleTag,
+ createAndAttachTag,
+ onFilterByTag,
+ suggestionsFor,
+ onAcceptSuggestion: acceptSuggestion,
+ onDismissSuggestion: dismissSuggestion,
+ });
+ }, [
t,
- lang: i18n.language,
- prioOptions,
+ lang,
+ allChannels,
userTags,
bumpPriority,
setPriority,
- more: (c) => {
- const mine = rowMenu.open && rowMenu.target?.id === c.id;
- return (
- {
- if (el) moreRefs.current.set(c.id, el);
- else moreRefs.current.delete(c.id);
- }}
- label={t("channels.row.menu")}
- expanded={mine}
- menuProps={rowMenu.buttonProps(c)}
- className={mine ? undefined : "reveal"}
- />
- );
- },
+ rowButtonProps,
+ openRow,
onView,
toggleTag,
createAndAttachTag,
onFilterByTag,
suggestionsFor,
- onAcceptSuggestion: acceptSuggestion,
- onDismissSuggestion: dismissSuggestion,
- });
+ acceptSuggestion,
+ dismissSuggestion,
+ ]);
// Status chips sit in the table's controls row (next to paging) — compact and close to
// where they act.
@@ -748,7 +854,7 @@ export default function Channels() {
onFiltersChange={setFilters}
fixedLayout
emptyText={t("channels.empty")}
- rowProps={rowMenu.triggerProps}
+ rowProps={rowTriggerProps}
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
rowClassName={() => "group"}
keyboardNav
@@ -775,7 +881,7 @@ export default function Channels() {
aboutKey="about"
moreKey="actions"
emptyText={t("channels.empty")}
- rowProps={rowMenu.triggerProps}
+ rowProps={rowTriggerProps}
rowClassName={() => "group"}
/>
{/* Folded, the status / pager row leaves the band, as the table's does — and the
diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx
index d6521c39..c5b86cc9 100644
--- a/frontend/src/components/ChannelsProvider.dom.test.tsx
+++ b/frontend/src/components/ChannelsProvider.dom.test.tsx
@@ -9,7 +9,7 @@
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act, render as renderRoot } from "@testing-library/react";
-import { useEffect, useRef, type ReactNode } from "react";
+import { useEffect, useEffectEvent, type ReactNode } from "react";
vi.mock("./navigationContext", () => ({
useNavigation: () => ({ page: "channels" }),
@@ -135,18 +135,16 @@ describe("ChannelsProvider — S8 shareable URL", () => {
// has to live in the PROVIDER — inside the page it would reset with the page and re-apply an intent
// from earlier in the session over whatever the user has typed into the search box since.
describe("ChannelsProvider — the focus-a-channel intent", () => {
- /** Stands in for Channels.tsx:287-297 — the same effect, the same consume call. */
+ /** Stands in for Channels.tsx's `applyFocus` — the same effect event, the same consume call. */
function Manager() {
const { focusName, focusToken } = useChannels();
const { setSearch, consumeFocus } = useChannelsActions();
- const nameRef = useRef(focusName);
- nameRef.current = focusName;
- useEffect(() => {
- const name = nameRef.current;
- if (!name) return;
- setSearch(name);
+ const applyFocus = useEffectEvent(() => {
+ if (!focusName) return;
+ setSearch(focusName);
consumeFocus();
- }, [focusToken, setSearch, consumeFocus]);
+ });
+ useEffect(() => applyFocus(), [focusToken]);
return null;
}
diff --git a/frontend/src/components/compilerCoverage.contract.test.ts b/frontend/src/components/compilerCoverage.contract.test.ts
new file mode 100644
index 00000000..2cd61f43
--- /dev/null
+++ b/frontend/src/components/compilerCoverage.contract.test.ts
@@ -0,0 +1,66 @@
+import { readFileSync } from "node:fs";
+import { transformSync } from "@babel/core";
+import reactCompiler from "babel-plugin-react-compiler";
+import { describe, expect, it } from "vitest";
+
+// The React Compiler bails out of a component it cannot prove safe WITHOUT a word: the component
+// still works, it just re-renders everything under it on every render. Its main tripwire, a ref read
+// or written during render, is not linted here (`react-hooks/refs` is off, see eslint.config.js), so
+// nothing caught it. The channel manager is where it hurt (the owner, 2026-10-03): Channels and
+// DataTable both bailed out, every open, close and reposition of a row's right-click menu rebuilt
+// the rows and columns, and on "All" rows the menu took ~0.9 s to open and ~0.6 s to close.
+//
+// This runs the real compiler (the options reactPlugin.ts gives it) over the files that must stay
+// compiled and fails on any function it skips, with the compiler's own reason.
+const MUST_COMPILE = ["components/Channels.tsx", "components/ui/DataTable.tsx"];
+
+function bailouts(path: string): string[] {
+ const source = readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
+ const found: string[] = [];
+ transformSync(source, {
+ filename: path,
+ babelrc: false,
+ configFile: false,
+ parserOpts: { plugins: ["jsx", "typescript"] },
+ plugins: [
+ [
+ reactCompiler,
+ {
+ target: "19",
+ logger: {
+ logEvent: (
+ _file: string | null,
+ e: { kind: string; fnLoc?: unknown; detail?: unknown },
+ ) => {
+ if (
+ e.kind !== "CompileError" &&
+ e.kind !== "CompileSkip" &&
+ e.kind !== "PipelineError"
+ )
+ return;
+ const loc = e.fnLoc as { start?: { line?: number } } | undefined;
+ const d = e.detail as
+ | { options?: { reason?: string; details?: { message?: string }[] } }
+ | { reason?: string; details?: { message?: string }[] }
+ | undefined;
+ const o =
+ d && "options" in d
+ ? d.options
+ : (d as { reason?: string; details?: { message?: string }[] });
+ found.push(
+ `line ${loc?.start?.line}: ${o?.details?.[0]?.message ?? o?.reason ?? e.kind}`,
+ );
+ },
+ },
+ },
+ ],
+ ],
+ });
+ return found;
+}
+
+describe("the React Compiler", () => {
+ it.each(MUST_COMPILE)("compiles every function in %s", (path) => {
+ expect(bailouts(path)).toEqual([]);
+ });
+});
diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx
index 7a2eab8c..b65f8b19 100644
--- a/frontend/src/components/ui/DataTable.tsx
+++ b/frontend/src/components/ui/DataTable.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
+import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
import Pager from "./Pager";
@@ -201,24 +201,23 @@ export default function DataTable({
useListNav(bodyRef, { enabled: keyboardNav && wide });
useListNav(cardsRef, { enabled: keyboardNav && !wide });
- // Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
- // latest resolved map so the externalFilter/reset effects can derive from it without taking
- // `filters` as a dep (which would re-run them on every filter change).
- const filtersRef = useRef(filters);
- filtersRef.current = filters;
- // …and the same trick for the CALLBACK, one line further: a controlled caller almost always
- // passes an inline arrow, so `onFiltersChange` is a fresh function on every render. Reading it
- // through a ref is what lets `commitFilters` be stable, and a stable commitFilters is what lets
- // the two effects below NAME it in their dep lists instead of switching the check off.
- const onFiltersChangeRef = useRef(onFiltersChange);
- onFiltersChangeRef.current = onFiltersChange;
- const commitFilters = useCallback(
- (next: FilterMap) => {
- if (controlledFilters) onFiltersChangeRef.current!(next);
- else setInternalFilters(next);
- },
- [controlledFilters],
- );
+ // Commit a whole new filter map through the owner (controlled) or internal state.
+ const commitFilters = (next: FilterMap) => {
+ if (controlledFilters) onFiltersChange!(next);
+ else setInternalFilters(next);
+ };
+ // The externalFilter/reset effects below read the latest filters and callback through effect
+ // events, so they re-run only on their own trigger, not on every filter change or on the inline
+ // `onFiltersChange` a controlled caller passes each render. (They used to ride render-written refs,
+ // which made the React Compiler skip this whole component — and with it every row's memo.)
+ const applyExternalFilter = useEffectEvent((key: string, value: string | string[]) => {
+ commitFilters({ ...filters, [key]: value });
+ setPage(0);
+ });
+ const clearFilters = useEffectEvent(() => {
+ commitFilters({});
+ setPage(0);
+ });
// Persist page/size always, but only the sort/filter slices this table actually OWNS — a controlled
// caller persists those itself, so writing them here would just leave a stale, never-read duplicate.
@@ -242,9 +241,9 @@ export default function DataTable({
const externalValue = externalFilter?.value;
useEffect(() => {
if (externalKey === undefined || externalValue === undefined) return;
- commitFilters({ ...filtersRef.current, [externalKey]: externalValue });
- setPage(0);
- }, [externalKey, externalValue, commitFilters]);
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- a controlled filter commits through the PARENT's setter, which may not run during this table's render; the effect fires once per new external value
+ applyExternalFilter(externalKey, externalValue);
+ }, [externalKey, externalValue]);
// Clear column filters when the reset token advances. The ref starts at 0 (not at the prop)
// so a navigation that bumped the token still clears even when it remounts this table fresh
@@ -254,9 +253,8 @@ export default function DataTable({
useEffect(() => {
if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return;
resetRef.current = resetFiltersToken;
- commitFilters({});
- setPage(0);
- }, [resetFiltersToken, commitFilters]);
+ clearFilters();
+ }, [resetFiltersToken]);
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort, lang);
From 3c16012524f6a28ed64eb7f53b275454d1dd1c8b Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 16:04:06 +0200
Subject: [PATCH 04/54] build(backend): close the lint gaps the E32 audit
measured
ruff gains RUF100, PLW1510 and flake8-bugbear, every hit fixed rather than
ignored. B008 stays on with FastAPI's Depends/Query declared immutable.
basedpyright moves to standard mode, pytest to --strict-markers, and a hand
run of pytest now requires the database unless SIFTLODE_TEST_REQUIRE_DB=0.
PLW1510 found a real bug: the three ffprobe probes in plex/stream.py never
looked at the exit status, so a probe that failed was read as the file's
truth (an HDR source sent down the HW-safe path, a multi-audio file collapsed
to one track, a subtitle offset taken from garbage) and the warning each
except branch promises never fired. They now run with check=True.
---
backend/.basedpyright/baseline.json | 18 -----------
backend/app/bodylimit.py | 2 +-
backend/app/downloads/edit.py | 4 +--
backend/app/downloads/runner.py | 4 +--
backend/app/feedquery.py | 6 ++--
backend/app/issues/gitea.py | 2 +-
backend/app/logs.py | 2 +-
backend/app/main.py | 2 +-
backend/app/plex/stream.py | 9 ++++++
backend/app/plex/subtitles.py | 3 +-
backend/app/plex/sync.py | 6 ++--
backend/app/progress.py | 6 ++--
backend/app/routes/channels.py | 6 ++--
backend/app/routes/discover.py | 2 +-
backend/app/routes/downloads.py | 10 +++----
backend/app/routes/feed.py | 4 +--
backend/app/routes/playlists.py | 14 ++++-----
backend/app/routes/plex/collections.py | 8 ++---
backend/app/routes/plex/discover.py | 16 +++++++---
backend/app/routes/saved_views.py | 4 +--
backend/app/routes/scheduler.py | 2 +-
backend/app/routes/search.py | 2 +-
backend/app/routes/sync.py | 2 +-
backend/app/routes/tags.py | 4 +--
backend/app/sync/runner.py | 2 +-
backend/app/titles.py | 2 --
backend/app/worker.py | 16 +++++-----
backend/app/youtube/client.py | 2 +-
backend/app/youtube/rss.py | 3 ++
backend/pyrightconfig.json | 2 +-
backend/pytest.ini | 6 ++--
backend/ruff.toml | 20 ++++++++++++-
backend/tests/conftest.py | 21 +++++++------
backend/tests/test_body_limit.py | 2 +-
backend/tests/test_logsink.py | 2 +-
backend/tests/test_plex_stream.py | 41 ++++++++++++++++++++++++++
scripts/e2e_seed.py | 10 +++----
37 files changed, 168 insertions(+), 99 deletions(-)
diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json
index a438d546..5ea56f47 100644
--- a/backend/.basedpyright/baseline.json
+++ b/backend/.basedpyright/baseline.json
@@ -1650,24 +1650,6 @@
"endColumn": 36,
"lineCount": 1
}
- },
- {
- "code": "reportArgumentType",
- "range": {
- "startColumn": 46,
- "endColumn": 52,
- "lineCount": 1
- }
- }
- ],
- "./app/youtube/rss.py": [
- {
- "code": "reportGeneralTypeIssues",
- "range": {
- "startColumn": 10,
- "endColumn": 15,
- "lineCount": 1
- }
}
]
}
diff --git a/backend/app/bodylimit.py b/backend/app/bodylimit.py
index d2847387..5b4dc3a5 100644
--- a/backend/app/bodylimit.py
+++ b/backend/app/bodylimit.py
@@ -219,7 +219,7 @@ class BodyLimitMiddleware:
try:
await self.app(scope, counting_receive, guarded_send)
- except Exception: # noqa: BLE001 — only swallowed when WE caused it, see below
+ except Exception: # only swallowed when WE caused it, see below
# ClientDisconnect (or whatever a handler raises on a truncated read) is the expected
# consequence of the disconnect we just faked, and the caller must get the 413 rather
# than a 500. Anything raised when we did NOT refuse is a real failure and is re-raised
diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py
index d4967169..24498872 100644
--- a/backend/app/downloads/edit.py
+++ b/backend/app/downloads/edit.py
@@ -400,7 +400,7 @@ def escalate_kill(proc: subprocess.Popen, terminate, kill, grace_s: float, label
try:
proc.wait(timeout=grace_s)
except subprocess.TimeoutExpired:
- # noqa TRY400: the exception IS the message here. A TimeoutExpired's traceback is this
+ # TRY400: the exception IS the message here. A TimeoutExpired's traceback is this
# `wait` call and nothing else — it would add three frames and no fact the line lacks.
log.error("%s pid %s survived SIGKILL", label, proc.pid) # noqa: TRY400
@@ -456,7 +456,7 @@ def run_ffmpeg(
try:
ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S)
except subprocess.TimeoutExpired: # pipes closed but the process lingers
- raise StatedFailure("ffmpeg did not exit after closing its output")
+ raise StatedFailure("ffmpeg did not exit after closing its output") from None
except BaseException:
# ONE exit path stops the process, so the invariant is unconditional: nothing leaves this
# function with ffmpeg still running. Not just our own EditAborted/stall — `on_progress`
diff --git a/backend/app/downloads/runner.py b/backend/app/downloads/runner.py
index ac87b6c1..b609e32e 100644
--- a/backend/app/downloads/runner.py
+++ b/backend/app/downloads/runner.py
@@ -57,7 +57,7 @@ def main() -> None:
logs.configure_logging("runner")
_deprioritize()
if len(sys.argv) != 2:
- # noqa T201: a usage line is for the person who typed the command. Logging is already
+ # T201: a usage line is for the person who typed the command. Logging is already
# configured here, and in a deployment it writes JSON records — the one reader of this
# line wants the bare sentence, not a record with a request id they do not have.
print( # noqa: T201
@@ -104,7 +104,7 @@ def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) ->
else:
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
- # noqa TRY400: the stack goes with it scrubbed (`scrubbed_stack`); `exc_info` is raw.
+ # TRY400: the stack goes with it scrubbed (`scrubbed_stack`); `exc_info` is raw.
log.error( # noqa: TRY400
"the download child for job %s failed",
job_id,
diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py
index f612b8ca..84cfe50f 100644
--- a/backend/app/feedquery.py
+++ b/backend/app/feedquery.py
@@ -438,7 +438,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
# Drop this category's own selections so its chips keep independent counts; a conjunctive
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
exclude = None if tag_modes.get(category, "or") == "and" else category
- for cid, ch_tags in channel_tags.items():
+ for ch_tags in channel_tags.values():
if not passes(ch_tags, exclude):
continue
for tid in ch_tags:
@@ -671,8 +671,8 @@ def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str, datetime |
raw = payload["k"]
if len(raw) != len(keys):
raise ValueError("key arity mismatch")
- vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw)]
- for k, v in zip(keys, vals):
+ vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw, strict=True)]
+ for k, v in zip(keys, vals, strict=True):
if v is None and not k["nullable"]:
raise ValueError("null value for a non-nullable key")
window = payload.get("w")
diff --git a/backend/app/issues/gitea.py b/backend/app/issues/gitea.py
index 6d462296..b492de71 100644
--- a/backend/app/issues/gitea.py
+++ b/backend/app/issues/gitea.py
@@ -239,7 +239,7 @@ def deliver(
# already open on the tracker and this is what would have recorded its link. We
# still must not write — another send owns the row and is opening its own issue —
# so say which issue was left behind, because nothing else will.
- # noqa TRY400: the traceback is one SendClaimLost carrying the session id and
+ # TRY400: the traceback is one SendClaimLost carrying the session id and
# nothing else; the issue number below is the whole news.
log.error( # noqa: TRY400
"an issue was created by a send that had already lost its claim",
diff --git a/backend/app/logs.py b/backend/app/logs.py
index 64746cf5..d25d5380 100644
--- a/backend/app/logs.py
+++ b/backend/app/logs.py
@@ -166,7 +166,7 @@ def bind(**fields: Any):
try:
yield
finally:
- for key, token in zip(keys, tokens):
+ for key, token in zip(keys, tokens, strict=True):
_CONTEXT_VARS[key].reset(token)
diff --git a/backend/app/main.py b/backend/app/main.py
index 8f52f23f..a9068e9d 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -77,7 +77,7 @@ async def lifespan(app: FastAPI):
# booting — an unreachable or not-yet-migrated database has to start into the env level.
try:
set_level(sysconfig.get_str(db, "log_level"))
- except Exception: # noqa: BLE001 — any DB/config failure keeps the bootstrap level
+ except Exception: # any DB/config failure keeps the bootstrap level
# With the traceback: this catch spans a connection, a query and a coercion at boot,
# and `%s` of the exception does not say which of them raised.
log.exception("could not read the stored log level; staying at %s", current_level())
diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py
index 656dabd9..b25d1e24 100644
--- a/backend/app/plex/stream.py
+++ b/backend/app/plex/stream.py
@@ -245,6 +245,9 @@ def _probe_video_kind(src: Path) -> str:
capture_output=True,
text=True,
timeout=10,
+ # A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch
+ # below), not an answer: whatever it printed before refusing is not the file's truth.
+ check=True,
)
except (subprocess.SubprocessError, OSError) as exc:
# The safe kind is a real answer, but it is the answer for an 8-bit SDR source — a blind
@@ -502,6 +505,9 @@ def _probe_audio_langs(src: Path) -> list[str]:
capture_output=True,
text=True,
timeout=10,
+ # A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch
+ # below), not an answer: whatever it printed before refusing is not the file's truth.
+ check=True,
)
return [ln.strip() for ln in out.stdout.splitlines()]
except (subprocess.SubprocessError, OSError) as exc:
@@ -615,6 +621,9 @@ def _probe_first_pts(seg: Path) -> float | None:
capture_output=True,
text=True,
timeout=10,
+ # A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch
+ # below), not an answer: whatever it printed before refusing is not the file's truth.
+ check=True,
)
for line in out.stdout.splitlines():
tok = line.strip().strip(",")
diff --git a/backend/app/plex/subtitles.py b/backend/app/plex/subtitles.py
index 22f2a3ce..29059f94 100644
--- a/backend/app/plex/subtitles.py
+++ b/backend/app/plex/subtitles.py
@@ -162,7 +162,8 @@ def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
"webvtt",
"pipe:1",
]
- r = subprocess.run(cmd, capture_output=True, timeout=EXTRACT_TIMEOUT_S)
+ # check=False: the exit status is read on the next line, with ffmpeg's own stderr as the reason.
+ r = subprocess.run(cmd, capture_output=True, timeout=EXTRACT_TIMEOUT_S, check=False)
if r.returncode != 0 or not r.stdout:
raise PlexError(
(r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed"
diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py
index 90f358b9..1dbd038e 100644
--- a/backend/app/plex/sync.py
+++ b/backend/app/plex/sync.py
@@ -273,7 +273,7 @@ def sync(db: Session) -> dict:
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
try:
stats["people_enriched"] = _enrich_people_batch(db, plex)
- except Exception: # noqa: BLE001 — best-effort; never fail the sync over a backfill
+ except Exception: # best-effort; never fail the sync over a backfill
# With the traceback: a blind catch over a batch that fetches AND writes can be
# a Plex shape change, a DB error or our own bug, and `%s` told them apart badly.
log.exception("the Plex people-enrich batch failed")
@@ -282,7 +282,7 @@ def sync(db: Session) -> dict:
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
try:
stats["media_probed"] = _probe_media_batch(db, plex)
- except Exception: # noqa: BLE001 — best-effort; never fail the sync over a backfill
+ except Exception: # best-effort; never fail the sync over a backfill
log.exception("the Plex media-probe batch failed")
# silent-ok: the return value IS the report. `{"skipped": …}` goes to the scheduler, which
# logs the job result, and to the admin route, which answers it to the caller.
@@ -296,7 +296,7 @@ def sync(db: Session) -> dict:
# appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata.
try:
stats["people_pruned"] = people.prune_orphan_people(db)
- except Exception: # noqa: BLE001 — best-effort housekeeping; never fail the sync over it
+ except Exception: # best-effort housekeeping; never fail the sync over it
log.exception("the Plex people prune failed")
log.info("Plex sync done: %s", stats)
return stats
diff --git a/backend/app/progress.py b/backend/app/progress.py
index b6e3f900..98126e7b 100644
--- a/backend/app/progress.py
+++ b/backend/app/progress.py
@@ -8,7 +8,7 @@ its own thread/context) never cross wires. When no sink is bound (e.g. a manual
"""
import contextvars
-from typing import Callable, Optional
+from typing import Callable, Literal, Optional
Sink = Callable[[int, Optional[int], Optional[str]], None]
@@ -48,7 +48,9 @@ class bind:
self._token = _sink.set(self._sink)
return self
- def __exit__(self, *exc) -> bool:
+ # Literal[False], not bool: this never swallows the job's exception, and saying so is what lets
+ # a type checker know a name assigned inside `with bind(...)` is bound after it.
+ def __exit__(self, *exc) -> Literal[False]:
if self._token is not None:
_sink.reset(self._token)
return False
diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py
index 3f63dfa9..31147bf5 100644
--- a/backend/app/routes/channels.py
+++ b/backend/app/routes/channels.py
@@ -436,7 +436,7 @@ def explore_channel(
channel = db.get(Channel, channel_id)
result = explore_ingest_page(db, yt, channel, page_token)
except YouTubeError as exc:
- raise youtube_refusal(exc)
+ raise youtube_refusal(exc) from exc
return {
"ok": True,
"channel_id": channel_id,
@@ -597,7 +597,7 @@ def subscribe(
if "subscriptionDuplicate" in exc.reason.split(","):
yt_sub_id = ""
else:
- raise youtube_refusal(exc)
+ raise youtube_refusal(exc) from exc
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
# render. Keep the client open (we're still inside the `with`); a metadata failure here
@@ -658,7 +658,7 @@ def unsubscribe(
except YouTubeError as exc:
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as
# a transient "connection lost" gateway blip.
- raise youtube_refusal(exc)
+ raise youtube_refusal(exc) from exc
db.delete(sub)
db.commit()
return {"ok": True, "unsubscribed": channel_id}
diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py
index 2de01fc8..0d61f99e 100644
--- a/backend/app/routes/discover.py
+++ b/backend/app/routes/discover.py
@@ -170,7 +170,7 @@ def add_pin(
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check
# returns above — re-raising handed the user a 500 they could do nothing with.
- raise not_found("item")
+ raise not_found("item") from None
return {"ok": True, "id": winner.id, "created": False}
return {"ok": True, "id": pin.id, "created": True}
diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py
index af75086e..d9a11554 100644
--- a/backend/app/routes/downloads.py
+++ b/backend/app/routes/downloads.py
@@ -322,7 +322,7 @@ def set_credential(
try:
cleaned = dlcreds.validate_cookies(body.cookies)
except dlcreds.InvalidCookies as e:
- raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)})
+ raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) from e
dlcreds.upsert_credential(db, user.id, host_key, cleaned)
audit.record(
db,
@@ -407,7 +407,7 @@ def enqueue_download(
try:
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
except quota.QuotaExceeded as e:
- raise HTTPException(status_code=422, detail=_quota_detail(e))
+ raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
return _serialize_job(db, job)
@@ -520,7 +520,7 @@ def upload_download(
display_name=upload.display_name,
)
except quota.QuotaExceeded as e:
- raise HTTPException(status_code=422, detail=_quota_detail(e))
+ raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
return _serialize_job(db, job)
@@ -547,9 +547,9 @@ def enqueue_edit(
try:
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
except quota.QuotaExceeded as e:
- raise HTTPException(status_code=422, detail=_quota_detail(e))
+ raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
except service.EditError as e:
- raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason})
+ raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) from e
return _serialize_job(db, job)
diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py
index 4e905e0c..46c62601 100644
--- a/backend/app/routes/feed.py
+++ b/backend/app/routes/feed.py
@@ -133,7 +133,7 @@ def get_feed(
# Expose each key column on the row so we can build the next cursor from the last item.
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
if vals is not None:
- for k, v in zip(keys, vals):
+ for k, v in zip(keys, vals, strict=True):
k["value"] = v
if ranked and window is not None:
rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1)
@@ -431,7 +431,7 @@ def get_video_detail(
):
items = yt.get_videos([video_id])
except YouTubeError as exc:
- raise youtube_refusal(exc)
+ raise youtube_refusal(exc) from exc
if not items:
raise not_found("video")
diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py
index b2e33b51..123ab40b 100644
--- a/backend/app/routes/playlists.py
+++ b/backend/app/routes/playlists.py
@@ -342,7 +342,7 @@ def sync_youtube(
return {"ok": True, **pull_user_playlists(db, user)}
except YouTubeError as exc:
db.rollback()
- raise youtube_refusal(exc)
+ raise youtube_refusal(exc) from exc
@router.post("/{playlist_id}/revert-youtube")
@@ -369,10 +369,10 @@ def revert_youtube(
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **repull_playlist(db, user, pl)}
except PlaylistGone:
- raise not_found("playlist")
+ raise not_found("playlist") from None
except YouTubeError as exc:
db.rollback()
- raise youtube_refusal(exc)
+ raise youtube_refusal(exc) from exc
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
@@ -398,7 +398,7 @@ def push_plan(
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
plan = plan_merge(db, user, pl)
except YouTubeError as exc:
- raise youtube_refusal(exc)
+ raise youtube_refusal(exc) from exc
plan["remaining_today"] = quota.remaining_today(db)
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
return plan
@@ -436,10 +436,10 @@ def push(
raise _refused(429, "quota_exceeded")
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
except PlaylistGone:
- raise not_found("playlist")
+ raise not_found("playlist") from None
except YouTubeError as exc:
db.rollback()
- raise youtube_refusal(exc)
+ raise youtube_refusal(exc) from exc
@router.get("/{playlist_id}")
@@ -539,7 +539,7 @@ def delete_playlist(
with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError as exc:
- raise youtube_refusal(exc)
+ raise youtube_refusal(exc) from exc
# The playlist first, as every item write: the cascade deletes the items before the list row.
lock_playlists(db, pl.id)
db.delete(pl) # items cascade
diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py
index 239c2ed3..0dfdde83 100644
--- a/backend/app/routes/plex/collections.py
+++ b/backend/app/routes/plex/collections.py
@@ -428,7 +428,7 @@ def create_collection(
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
- raise _write_or_pending_error(wrote, e)
+ raise _write_or_pending_error(wrote, e) from e
return _collection_card(col)
@@ -450,7 +450,7 @@ def collection_add_item(
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
- raise _write_or_pending_error(wrote, e)
+ raise _write_or_pending_error(wrote, e) from e
return _collection_card(col)
@@ -472,7 +472,7 @@ def collection_remove_item(
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
- raise _write_or_pending_error(wrote, e)
+ raise _write_or_pending_error(wrote, e) from e
return _collection_card(col)
@@ -502,7 +502,7 @@ def collection_rename(
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
- raise _write_or_pending_error(wrote, e)
+ raise _write_or_pending_error(wrote, e) from e
return _collection_card(col)
diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py
index 05d6936b..84c79c96 100644
--- a/backend/app/routes/plex/discover.py
+++ b/backend/app/routes/plex/discover.py
@@ -124,8 +124,14 @@ def _recently_added(
# share one), so the merge doesn't depend on object identity staying alive.
top_movies = [o for _, kind, o in top if kind == "movie"]
top_shows = [o for _, kind, o in top if kind == "show"]
- mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang))}
- scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang))}
+ mcards = {
+ m.rating_key: c
+ for m, c in zip(top_movies, _movie_cards(db, uid, top_movies, lang), strict=True)
+ }
+ scards = {
+ s.rating_key: c
+ for s, c in zip(top_shows, _show_cards(db, uid, top_shows, lang), strict=True)
+ }
return [mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] for _, kind, o in top]
@@ -351,10 +357,12 @@ def _because_you_watched(
ordered_shows = [shows[rk] for rk in rks if rk in shows]
# rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
mcards = {
- m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang))
+ m.rating_key: c
+ for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies, lang), strict=True)
}
scards = {
- s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang))
+ s.rating_key: c
+ for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows, lang), strict=True)
}
items: list[dict] = []
for rk in rks:
diff --git a/backend/app/routes/saved_views.py b/backend/app/routes/saved_views.py
index 6add2507..c400cd0a 100644
--- a/backend/app/routes/saved_views.py
+++ b/backend/app/routes/saved_views.py
@@ -87,7 +87,7 @@ def create_view(
db.commit()
except IntegrityError:
db.rollback()
- raise refused(409, "view_name_taken", name=name)
+ raise refused(409, "view_name_taken", name=name) from None
return _serialize(view)
@@ -135,7 +135,7 @@ def update_view(
db.commit()
except IntegrityError:
db.rollback()
- raise refused(409, "view_name_taken", name=view.name)
+ raise refused(409, "view_name_taken", name=view.name) from None
return _serialize(view)
diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py
index 2a84dddc..579a691f 100644
--- a/backend/app/routes/scheduler.py
+++ b/backend/app/routes/scheduler.py
@@ -74,7 +74,7 @@ def run_job_now(
try:
result = trigger_job(job_id, actor_id=user.id)
except KeyError:
- raise not_found("job")
+ raise not_found("job") from None
if result == "already_running":
raise refused(409, "job_running")
return {"ok": True, "id": job_id, "started": True}
diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py
index 31bc2fce..50d284f0 100644
--- a/backend/app/routes/search.py
+++ b/backend/app/routes/search.py
@@ -331,7 +331,7 @@ def search_youtube(
# bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1).
try:
collected.extend(_ingest_candidates(db, yt, user, candidates))
- except Exception as exc: # noqa: BLE001 — any ingest failure, not just YouTubeError
+ except Exception as exc: # any ingest failure, not just YouTubeError
db.rollback()
# The term is NOT in this line. A search string is exactly what the privacy
# policy keeps out of the log unless the user turned Teljes/TRACE on for
diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py
index 998d658b..e413c484 100644
--- a/backend/app/routes/sync.py
+++ b/backend/app/routes/sync.py
@@ -39,7 +39,7 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend
# The onboarding wizard's first call: an unguarded failure here was a raw 500 and the
# blocking "Server error" dialog, with nothing the user could act on (E9 F1).
db.rollback()
- raise youtube_refusal(exc)
+ raise youtube_refusal(exc) from exc
result["quota_used_estimate"] = m.used
result["quota_remaining_today"] = m.remaining
return {"ok": True, **result}
diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py
index 7e2c23c9..8228e912 100644
--- a/backend/app/routes/tags.py
+++ b/backend/app/routes/tags.py
@@ -75,7 +75,7 @@ def create_tag(
db.commit()
except IntegrityError:
db.rollback()
- raise refused(409, "tag_name_taken", name=name)
+ raise refused(409, "tag_name_taken", name=name) from None
return {
"ok": True,
"id": tag.id,
@@ -113,7 +113,7 @@ def update_tag(
db.commit()
except IntegrityError:
db.rollback()
- raise refused(409, "tag_name_taken", name=tag.name)
+ raise refused(409, "tag_name_taken", name=tag.name) from None
return {
"ok": True,
"id": tag.id,
diff --git a/backend/app/sync/runner.py b/backend/app/sync/runner.py
index b495fbdf..273fb607 100644
--- a/backend/app/sync/runner.py
+++ b/backend/app/sync/runner.py
@@ -267,7 +267,7 @@ def run_feed_fallback(db: Session, channel_ids: list[str]) -> dict[str, int]:
channel.feed_fallback_at = datetime.now(timezone.utc)
db.add(channel)
db.commit()
- except Exception: # noqa: BLE001 — one channel must not stop the rest
+ except Exception: # one channel must not stop the rest
db.rollback()
log.exception(
"Feed fallback could not store channel %s",
diff --git a/backend/app/titles.py b/backend/app/titles.py
index b1e8553f..3e3330cc 100644
--- a/backend/app/titles.py
+++ b/backend/app/titles.py
@@ -60,7 +60,6 @@ _SHORT_LOWER = {
"for",
"or",
"vs",
- "the",
"és",
"az",
"egy",
@@ -120,7 +119,6 @@ _ACRONYMS = {
"nfl",
"asmr",
"pov",
- "diy",
"wtf",
"lol",
"rtx",
diff --git a/backend/app/worker.py b/backend/app/worker.py
index c5bd2ced..e2006bc9 100644
--- a/backend/app/worker.py
+++ b/backend/app/worker.py
@@ -25,6 +25,7 @@ import threading
import time
from collections.abc import Callable
from datetime import datetime, timezone
+from functools import partial
from sqlalchemy import text
@@ -72,7 +73,7 @@ def _wait_for_schema(timeout: float = 300.0) -> bool:
with SessionLocal() as db:
db.execute(text("SELECT 1 FROM download_jobs LIMIT 1"))
return True
- except Exception: # noqa: BLE001 — table not migrated yet / DB not up yet
+ except Exception: # table not migrated yet / DB not up yet
if time.monotonic() > deadline:
# The traceback, not `str(exc)[:120]`: this is the last thing the worker says
# before it exits, and which driver call failed is the whole question.
@@ -366,7 +367,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
try:
proc = _spawn_download_child(job_id)
- except Exception: # noqa: BLE001 — spawn failed; don't leave the asset stuck 'downloading'
+ except Exception: # spawn failed; don't leave the asset stuck 'downloading'
log.exception("failed to spawn download child for job %s", job_id)
_reset_asset_pending(asset_id)
_requeue_if_running(job_id, None)
@@ -438,7 +439,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
cause="child_exit",
exit_code=proc.returncode,
)
- except Exception: # noqa: BLE001 — boot recovery is the backstop for a stuck 'running' job
+ except Exception: # boot recovery is the backstop for a stuck 'running' job
log.exception("post-exit reconciliation failed for job %s", job_id)
return
@@ -501,6 +502,7 @@ def _process_job(job_id: int) -> None:
then run in a supervised CHILD PROCESS (`_run_supervised`) so a wedged yt-dlp/ffmpeg can be
hard-killed — a worker THREAD cannot be killed, nor (when it spins in a C call) even
cooperatively cancelled, and one such thread starves the others via the GIL."""
+ diagnosis = None # set exactly when the asset has already failed (asset_status == "error")
with SessionLocal() as db:
job = db.get(DownloadJob, job_id)
if job is None:
@@ -528,7 +530,7 @@ def _process_job(job_id: int) -> None:
if asset_status == "ready":
_finish_from_cache(job_id, asset_id)
return
- if asset_status == "error":
+ if diagnosis is not None:
_fail_running(
job_id,
error=asset_error or "Download failed",
@@ -577,7 +579,7 @@ def _worker_loop(idx: int) -> None:
while not _stop.is_set():
try:
job_id = _claim_job()
- except Exception: # noqa: BLE001
+ except Exception:
log.exception("claim failed")
_stop.wait(settings.download_poll_seconds)
continue
@@ -597,12 +599,12 @@ def _worker_loop(idx: int) -> None:
job_id,
exc,
"crash",
- lambda: _fail_running(job_id, error="Worker error", error_code=None),
+ partial(_fail_running, job_id, error="Worker error", error_code=None),
)
# silent-ok: not silent — `_report_worker_error` logged the crash before this
# re-raise. Failing the job hit the database, and letting that out would end this
# worker thread for good; boot recovery settles a job left `running`.
- except Exception: # noqa: BLE001
+ except Exception:
log.exception(
"could not fail job %s after its crash",
job_id,
diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py
index 3439ca5b..8ecacb94 100644
--- a/backend/app/youtube/client.py
+++ b/backend/app/youtube/client.py
@@ -189,7 +189,7 @@ class YouTubeClient:
return cached
try:
refresh = decrypt(tok.refresh_token_enc)
- except Exception as exc: # noqa: BLE001 — Fernet InvalidToken (TOKEN_ENCRYPTION_KEY changed)
+ except Exception as exc: # Fernet InvalidToken (TOKEN_ENCRYPTION_KEY changed)
# A key mismatch must degrade like any other token problem (caller handles YouTubeError),
# not surface as a raw 500 on every scheduler job / connect check. (B-core-2)
raise YouTubeError("Stored OAuth token is unreadable — reconnect your account") from exc
diff --git a/backend/app/youtube/rss.py b/backend/app/youtube/rss.py
index 510bc13d..c8502f89 100644
--- a/backend/app/youtube/rss.py
+++ b/backend/app/youtube/rss.py
@@ -40,6 +40,7 @@ class RssError(Exception):
def _get_feed(channel_id: str) -> bytes:
"""The feed's body, after up to `1 + len(RETRY_DELAYS)` tries; the LAST failure is raised."""
url = RSS_URL.format(channel_id=channel_id)
+ error: RssError | None = None
for attempt in range(len(RETRY_DELAYS) + 1):
if attempt:
time.sleep(RETRY_DELAYS[attempt - 1])
@@ -60,6 +61,8 @@ def _get_feed(channel_id: str) -> bytes:
)
if resp.status_code not in _RETRYABLE_STATUS:
break
+ if error is None: # cannot happen: the range above is never empty, and every try records one
+ raise RssError(f"RSS fetch for {channel_id} made no attempt", "transport")
raise error
diff --git a/backend/pyrightconfig.json b/backend/pyrightconfig.json
index 6e32135f..e034ee46 100644
--- a/backend/pyrightconfig.json
+++ b/backend/pyrightconfig.json
@@ -25,7 +25,7 @@
{
"include": ["app", "alembic"],
"extraPaths": ["."],
- "typeCheckingMode": "basic",
+ "typeCheckingMode": "standard",
"pythonVersion": "3.12",
"pythonPlatform": "Linux"
}
diff --git a/backend/pytest.ini b/backend/pytest.ini
index 6692f5b6..f92aa790 100644
--- a/backend/pytest.ini
+++ b/backend/pytest.ini
@@ -2,7 +2,9 @@
# Run from backend/ (the image's /app). `pythonpath = .` puts the app package on sys.path so tests
# import it as `app.*`, matching runtime. Most of the suite is pure-logic; the DB-backed lane opts in
# via the `db` fixture and runs in the compose test image. `-rs` reports skip REASONS so a silently
-# skipped lane is visible (the compose lane sets SIFTLODE_TEST_REQUIRE_DB=1 to make skips a hard error).
+# skipped lane is visible (and conftest makes a missing DB/ffmpeg a hard error unless
+# SIFTLODE_TEST_REQUIRE_DB=0). `--strict-markers` (E32): a misspelt `@pytest.mark.skipiff` is an error,
+# not an unknown marker that quietly does nothing.
testpaths = tests
pythonpath = .
-addopts = -q -rs
+addopts = -q -rs --strict-markers
diff --git a/backend/ruff.toml b/backend/ruff.toml
index f69ce16c..7778e4ba 100644
--- a/backend/ruff.toml
+++ b/backend/ruff.toml
@@ -29,5 +29,23 @@ target-version = "py312"
# nobody to raise to, a best-effort backfill inside a sync) and always worth a sentence.
# The wider rule these two enforce lives in tests/test_swallowed_exceptions.py: a handler that
# neither logs nor re-raises says `# silent-ok: `. Ruff sees only the two narrow shapes.
-extend-select = ["LOG", "G", "T20", "TRY400", "TRY401", "S110", "S112", "BLE001"]
+#
+# E32 (gate integrity) added three more, each measured before it went on and with every hit fixed:
+# RUF100 — a `# noqa` that suppresses nothing. A stale one is a claim with no rule behind it, and
+# it stays armed for the day the line changes (24 were removed). A comment that merely
+# explains a noqa below it names the rule as `# TRY400: …`, never `# noqa TRY400`.
+# PLW1510 — `subprocess.run` without an explicit `check=`. Found the real E32 bug: three ffprobe
+# probes read the output of a probe that had FAILED and took it for the file's truth.
+# B — flake8-bugbear: loop-variable capture, `raise` without `from` inside `except`, a
+# `zip` that silently truncates (each one is `strict=True` now — the lengths are an
+# invariant, so a mismatch should be loud). B008 (a call in a default argument) stays ON:
+# FastAPI's `Depends(...)` / `Query(...)` ARE that shape by design (474 hits, all of
+# them), so those two are declared immutable below instead of switching the rule off —
+# any other call in a default still fails.
+extend-select = [
+ "LOG", "G", "T20", "TRY400", "TRY401", "S110", "S112", "BLE001", "RUF100", "PLW1510", "B",
+]
ignore = ["E402"]
+
+[lint.flake8-bugbear]
+extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"]
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index c25be444..3c25394f 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -25,7 +25,7 @@ from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event, text
from starlette.requests import HTTPConnection
-import app.models # noqa: F401 — register every model on Base.metadata
+import app.models # register every model on Base.metadata
import app.state as app_state
from app import security
from app.auth import current_user
@@ -37,9 +37,11 @@ from app.models import User
# Sentinel for "this dependency had no override before the fixture set one" (see override_dep).
_MISSING = object()
-# The compose test lane sets this so a would-be silent skip (no DB, or no ffmpeg) becomes a hard error
-# — the gate must not print green while the DB-backed / ffmpeg tests were quietly not run.
-_REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB") == "1"
+# A would-be silent skip (no DB, or no ffmpeg) is a hard error unless the run says otherwise — the
+# suite must not print green while the DB-backed / ffmpeg tests were quietly not run. ON by default
+# since E32: as an opt-in only the compose lane and CI set, a hand-run `pytest` skipped ~60 DB tests
+# and still passed. `SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only" run.
+_REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB", "1") != "0"
def _db_reachable() -> bool:
@@ -86,19 +88,20 @@ def _truncate_all():
def pytest_configure(config):
- """Fail the whole run — loudly, once — when SIFTLODE_TEST_REQUIRE_DB=1 but the environment can't
- actually run the full suite. Without this, an unreachable DATABASE_URL or a missing ffmpeg turns
+ """Fail the whole run — loudly, once — when the full suite is required (the default) but the
+ environment can't actually run it. Without this, an unreachable DATABASE_URL or a missing ffmpeg turns
~60 DB tests + the poster tests into silent skips and the gate still exits green."""
if not _REQUIRE_FULL:
return
if not _DB_REACHABLE:
raise pytest.UsageError(
- "SIFTLODE_TEST_REQUIRE_DB=1 but no Postgres is reachable at the test DATABASE_URL — the "
- "DB-backed lane would silently skip. Fix the DB wiring or unset the flag."
+ "no Postgres is reachable at the test DATABASE_URL — the DB-backed lane would silently "
+ "skip. Fix the DB wiring, or run the pure-logic tests only with SIFTLODE_TEST_REQUIRE_DB=0."
)
if shutil.which("ffmpeg") is None:
raise pytest.UsageError(
- "SIFTLODE_TEST_REQUIRE_DB=1 but ffmpeg is missing — the thumbnail/poster tests would skip."
+ "ffmpeg is missing — the thumbnail/poster tests would skip. Install it, or run the "
+ "pure-logic tests only with SIFTLODE_TEST_REQUIRE_DB=0."
)
diff --git a/backend/tests/test_body_limit.py b/backend/tests/test_body_limit.py
index d967643d..905537e7 100644
--- a/backend/tests/test_body_limit.py
+++ b/backend/tests/test_body_limit.py
@@ -321,7 +321,7 @@ def _body_reading_functions() -> dict[tuple[str, str], dict]:
continue
names = [name for _, name in calls]
limits = []
- for node, name in zip([n for n, _ in calls], names):
+ for node, name in zip([n for n, _ in calls], names, strict=True):
if name.startswith("request."):
limits.append("")
continue
diff --git a/backend/tests/test_logsink.py b/backend/tests/test_logsink.py
index 7b9eb7cf..f0965c9f 100644
--- a/backend/tests/test_logsink.py
+++ b/backend/tests/test_logsink.py
@@ -259,7 +259,7 @@ def test_the_writer_drains_what_was_queued_when_it_is_stopped(db):
log.addHandler(sink)
try:
for i in range(logsink.BATCH_MAX + 5):
- # noqa G004: the f-string is the POINT — a distinct rendered message per line is
+ # G004: the f-string is the POINT — a distinct rendered message per line is
# what keeps the flood limiter (which groups by template) out of this case.
log.warning(f"line {i}") # noqa: G004
finally:
diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py
index 143f3eb5..22859570 100644
--- a/backend/tests/test_plex_stream.py
+++ b/backend/tests/test_plex_stream.py
@@ -8,6 +8,7 @@ Since the sweep stopped being awaited at startup it also runs CONCURRENTLY with
second half of this file pins what keeps it off a LIVE session's segments."""
import os
+import sys
from pathlib import Path
import pytest
@@ -830,6 +831,46 @@ class TestProbeVideoKind:
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
+class TestProbeExitStatus:
+ """A probe whose ffprobe EXITS NON-ZERO is a failed probe, not an answer (E32). Each probe used to
+ read stdout without looking at the exit status, so a refusing ffprobe silently picked the fallback
+ — HW-safe, single audio, the requested offset — and skipped the warning its except branch promises.
+ Drives the REAL subprocess.run with the probe's own kwargs against a stand-in command that prints a
+ plausible answer and then fails, so the test fails whenever the exit status is ignored."""
+
+ @staticmethod
+ def _failing_ffprobe(monkeypatch, stdout: str):
+ real_run = stream.subprocess.run
+ script = f"import sys; sys.stdout.write({stdout!r}); sys.exit(1)"
+ monkeypatch.setattr(
+ stream.subprocess,
+ "run",
+ lambda _cmd, **kw: real_run([sys.executable, "-c", script], **kw),
+ )
+
+ @staticmethod
+ def _events(caplog) -> list[str]:
+ return [getattr(r, "event", "") for r in caplog.records]
+
+ def test_video_kind(self, monkeypatch, caplog):
+ self._failing_ffprobe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=smpte2084\n")
+ with caplog.at_level("INFO"):
+ assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
+ assert "plex.stream.probe_kind_failed" in self._events(caplog)
+
+ def test_audio_langs(self, monkeypatch, caplog):
+ self._failing_ffprobe(monkeypatch, "hun\neng\n")
+ with caplog.at_level("INFO"):
+ assert stream._probe_audio_langs(Path("/x")) == []
+ assert "plex.stream.probe_audio_failed" in self._events(caplog)
+
+ def test_first_pts(self, monkeypatch, caplog):
+ self._failing_ffprobe(monkeypatch, "12.5\n")
+ with caplog.at_level("INFO"):
+ assert stream._probe_first_pts(Path("/x")) is None
+ assert "plex.stream.first_pts_probe_failed" in self._events(caplog)
+
+
class TestOpenclPresent:
"""The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives
the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the
diff --git a/scripts/e2e_seed.py b/scripts/e2e_seed.py
index 975011e4..e1dd85d0 100644
--- a/scripts/e2e_seed.py
+++ b/scripts/e2e_seed.py
@@ -21,10 +21,10 @@ from datetime import datetime, timezone
sys.path.insert(0, "/app")
-from sqlalchemy import delete, func, select # noqa: E402
+from sqlalchemy import delete, func, select
-from app.db import SessionLocal # noqa: E402
-from app.models import ( # noqa: E402
+from app.db import SessionLocal
+from app.models import (
AppState,
Channel,
Playlist,
@@ -34,7 +34,7 @@ from app.models import ( # noqa: E402
Video,
VideoState,
)
-from app.security import hash_password # noqa: E402
+from app.security import hash_password
EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local")
PASSWORD = os.environ.get("E2E_PASSWORD", "")
@@ -171,7 +171,7 @@ def main() -> None:
saved = svid.id
db.commit()
- # noqa T201: this script's stdout IS its interface — the e2e runner parses this JSON.
+ # T201: this script's stdout IS its interface — the e2e runner parses this JSON.
print( # noqa: T201
json.dumps(
{
From 471846d7844278a774f791f053cbc50e633d5aa5 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 16:34:20 +0200
Subject: [PATCH 05/54] build(frontend): close the lint gaps the E32 audit
measured
eslint now also enforces:
- the React Compiler diagnostics that `recommended` leaves out (todo,
memo-dependencies and ten zero-hit tripwires);
- a description on every suppression, and no blanket disables;
- no focused or skipped tests (vitest and playwright);
- the typed pair no-floating-promises and no-deprecated.
Lint also covers e2e/ and the TypeScript build configs, which `eslint .`
had been skipping in silence.
Every hit is fixed or suppressed inline with a named reason:
- The 31 try statements the compiler cannot lower yet carry a suppression
until S3 rewrites them. Five other compiler skips were plain fixes:
a module counter bumped inside a hook, a ref read in a default
parameter, and a local named like the it renders.
- The real defects behind floating promises are fixed. ChatDock threw at
nobody when the device key store could not be opened. VideoEditor's
play() rejection went unhandled. loadYouTubeApi never rejected, so a
blocked IFrame API left the player on an empty stage forever; it now
fails on a script error or after 15 s, shows its own error with the
way out, and lets the next open try again.
- MutableRefObject, FormEvent and lucide's deprecated brand icon are
replaced. The YouTube glyph now lives in lib/brandIcons.
- The lint-probe harness lets its virtual file names into TypeScript's
default project, so the contract tests still run the real config.
---
frontend/eslint.config.js | 110 ++++++-
frontend/package-lock.json | 299 ++++++++++++++++++
frontend/package.json | 3 +
frontend/src/components/AddToPlaylist.tsx | 7 +-
frontend/src/components/BootGate.dom.test.tsx | 15 +-
.../ChannelPage.backfill.dom.test.tsx | 4 +-
.../components/ChannelsProvider.dom.test.tsx | 10 +-
frontend/src/components/ChatDock.tsx | 13 +-
frontend/src/components/ChatThread.tsx | 3 +-
frontend/src/components/DownloadDialog.tsx | 2 +-
frontend/src/components/Feed.tsx | 8 +-
frontend/src/components/Header.tsx | 4 +-
frontend/src/components/IssueComposer.tsx | 6 +-
frontend/src/components/KeyGate.tsx | 2 +
frontend/src/components/Messages.tsx | 3 +-
.../NavigationProvider.dom.test.tsx | 4 +-
frontend/src/components/OnboardingWizard.tsx | 7 +-
frontend/src/components/PlayerModal.tsx | 10 +-
frontend/src/components/Playlists.tsx | 15 +-
frontend/src/components/PlaylistsRail.tsx | 6 +-
frontend/src/components/PlexBrowse.tsx | 4 +-
.../src/components/PlexCollectionEditor.tsx | 4 +
.../src/components/PlexCollectionView.tsx | 6 +-
.../PlexPlayer.controls.dom.test.tsx | 44 ++-
.../PlexPlayer.session.dom.test.tsx | 4 +-
frontend/src/components/PlexPlayer.tsx | 10 +-
frontend/src/components/PlexPlaylistAdd.tsx | 4 +-
frontend/src/components/PlexPlaylistView.tsx | 2 +-
frontend/src/components/PlexSidebar.tsx | 3 +-
.../src/components/PrefsProvider.dom.test.tsx | 5 +-
frontend/src/components/ProfileEditor.tsx | 2 +-
.../SavedViewsProvider.dom.test.tsx | 4 +-
.../src/components/SavedViewsProvider.tsx | 3 +-
.../components/Scheduler.menu.dom.test.tsx | 4 +-
frontend/src/components/SettingsPanel.tsx | 4 +-
frontend/src/components/SetupWizard.tsx | 1 +
frontend/src/components/ShareDialog.tsx | 2 +-
frontend/src/components/VideoEditor.tsx | 5 +-
frontend/src/components/WatchPage.tsx | 2 +
frontend/src/components/Welcome.tsx | 6 +-
.../src/components/accountPanel.dom.test.tsx | 4 +-
frontend/src/components/legal/LegalLayout.tsx | 2 +-
.../src/components/plexCards.dom.test.tsx | 12 +-
frontend/src/components/plexViews.tsx | 10 +-
.../src/components/ui/AnchoredPopover.tsx | 2 +-
frontend/src/components/ui/Toaster.tsx | 2 +-
frontend/src/components/ui/overlayOrder.ts | 4 +-
frontend/src/components/ui/sortableA11y.ts | 2 +-
frontend/src/components/useDebugRecording.ts | 1 +
.../src/components/useDownloadActions.tsx | 1 +
.../src/components/usePlaylistActions.tsx | 7 +-
.../usePlexPlaylistEdits.dom.test.tsx | 6 +-
.../src/components/usePlexPlaylistEdits.ts | 1 +
frontend/src/design-lab/batchB.tsx | 4 +-
frontend/src/i18n/locales/en/player.json | 1 +
frontend/src/i18n/locales/hu/player.json | 1 +
frontend/src/lib/brandIcons.ts | 16 +
frontend/src/lib/clipboard.ts | 2 +
frontend/src/lib/history.ts | 9 +-
frontend/src/lib/lintProbe.testkit.ts | 26 +-
frontend/src/lib/mediaLog.ts | 8 +
frontend/src/lib/modules.ts | 4 +-
.../lib/playerModalHooks.apiload.dom.test.tsx | 84 +++++
.../playerModalHooks.progress.dom.test.tsx | 4 +-
frontend/src/lib/playerModalHooks.ts | 166 +++++-----
.../lib/plexPlayerHooks.controls.dom.test.tsx | 39 ++-
.../lib/plexPlayerHooks.subtitle.dom.test.tsx | 6 +-
frontend/src/lib/plexPlayerHooks.ts | 51 +--
frontend/src/lib/useDebounced.dom.test.ts | 8 +-
frontend/src/lib/usePref.dom.test.ts | 4 +-
frontend/src/lib/usePref.ts | 4 +-
frontend/tsconfig.node.json | 13 +-
72 files changed, 938 insertions(+), 216 deletions(-)
create mode 100644 frontend/src/lib/brandIcons.ts
create mode 100644 frontend/src/lib/playerModalHooks.apiload.dom.test.tsx
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 3d1b998a..cf5cc369 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -4,6 +4,9 @@ import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import prettier from "eslint-config-prettier";
import globals from "globals";
+import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
+import vitest from "@vitest/eslint-plugin";
+import playwright from "eslint-plugin-playwright";
// Flat config (ESLint 9). Deliberately NARROW: the high-value rule here is
// react-hooks/exhaustive-deps — 33 suppressions across the app exist for it, and until this
@@ -14,6 +17,11 @@ import globals from "globals";
//
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
+//
+// E32 (gate integrity): every switched-off or narrowed rule here is listed, with its reason and its
+// measured count, in docs/gates.md, and lib/gates.contract.test.ts fails when one is not. A rule
+// with a single real hit is never switched off — its sites are fixed, or each suppressed inline with
+// a `-- ` description (eslint-comments/require-description makes that mandatory).
// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket
@@ -472,25 +480,83 @@ const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
const SPLIT_CLASS_MESSAGE =
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
+// Applies to EVERY linted file: a suppression says which rule and why. A bare
+// `eslint-disable-next-line rule` named nothing in four places the E32 audit found, against the
+// CLAUDE.md rule that each one names its invariant; a blanket `eslint-disable` names no rule at all.
+const suppressionRules = {
+ plugins: { "@eslint-community/eslint-comments": eslintComments },
+ linterOptions: { reportUnusedDisableDirectives: "error" },
+ rules: {
+ "@eslint-community/eslint-comments/require-description": ["error", { ignore: [] }],
+ "@eslint-community/eslint-comments/no-unlimited-disable": "error",
+ },
+};
+
+// The two type-aware rules (E32 D3: a curated pair, not strictTypeChecked — ~2000 hits there, none
+// real in the sample). A floating promise is a rejection nobody handles: the audit found two real
+// ones (ChatDock's device key store, VideoEditor's play()). `void` marks a deliberate fire-and-forget
+// whose callee reports its own failure. React Query's invalidate/refetch family never rejects
+// unless asked to (`throwOnError`), so those calls are known-safe by name.
+const typedRules = {
+ "@typescript-eslint/no-floating-promises": [
+ "error",
+ {
+ allowForKnownSafeCalls: [
+ {
+ from: "package",
+ package: "@tanstack/query-core",
+ name: [
+ "invalidateQueries",
+ "refetchQueries",
+ "cancelQueries",
+ "resetQueries",
+ "prefetchQuery",
+ "prefetchInfiniteQuery",
+ ],
+ },
+ ],
+ },
+ ],
+ "@typescript-eslint/no-deprecated": "error",
+};
+
export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
+ suppressionRules,
{
files: ["src/**/*.{ts,tsx}"],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser },
+ parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
local: localRules,
},
- linterOptions: {
- reportUnusedDisableDirectives: "error",
- },
rules: {
...reactHooks.configs.recommended.rules,
+ ...typedRules,
+ // The React Compiler's own diagnostics that `recommended` leaves out (E32). Each is a function
+ // the compiler SKIPS, silently — `panicThreshold` is "none" and there is no logger, so a lint
+ // error is the only place the skip is said out loud. `todo` is the compiler's "cannot lower
+ // this shape yet" (31 try statements, each suppressed inline until E32 S3 rewrites them); the
+ // rest had 0 hits when switched on and are tripwires. The full per-function picture is
+ // components/compilerCoverage.contract.test.ts.
+ "react-hooks/todo": "error",
+ "react-hooks/memo-dependencies": "error",
+ "react-hooks/rule-suppression": "error",
+ "react-hooks/invariant": "error",
+ "react-hooks/syntax": "error",
+ "react-hooks/void-use-memo": "error",
+ "react-hooks/component-hook-factories": "error",
+ "react-hooks/capitalized-calls": "error",
+ "react-hooks/hooks": "error",
+ "react-hooks/memoized-effect-dependencies": "error",
+ "react-hooks/exhaustive-effect-dependencies": "error",
+ "react-hooks/no-deriving-state-in-effects": "error",
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings, and they were
// triaged rather than bulk-suppressed, because the two answers are different:
@@ -709,7 +775,12 @@ export default tseslint.config(
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
// …and render throwaway markup whose labels no user ever hears.
files: ["src/**/*.test.{ts,tsx}"],
+ plugins: { vitest },
rules: {
+ // A focused test silently skips the rest of the suite; a skipped one is a test nobody runs
+ // (E32 — vitest's allowOnly is on outside CI, so nothing else catches a stray `.only` locally).
+ "vitest/no-focused-tests": "error",
+ "vitest/no-disabled-tests": "error",
"no-restricted-globals": "off",
"no-restricted-properties": "off",
"local/literal-a11y-text": "off",
@@ -720,5 +791,38 @@ export default tseslint.config(
"local/glyph-link": "off",
},
},
+ {
+ // Outside src (E32): the e2e suite and the TypeScript build-tool configs went unlinted — the
+ // flat config named only src/**, so `eslint .` skipped them without a word. The same base and
+ // typed rules as the app; Node globals, since none of it runs in the browser.
+ files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"],
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ languageOptions: {
+ ecmaVersion: 2022,
+ globals: { ...globals.node },
+ parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
+ },
+ rules: {
+ ...typedRules,
+ // tsc owns these here too (both tsconfig.node.json and e2e/tsconfig.json are strict +
+ // noUnusedLocals/Parameters).
+ "@typescript-eslint/no-unused-vars": "off",
+ "no-undef": "off",
+ },
+ },
+ {
+ files: ["e2e/**/*.ts"],
+ plugins: { playwright },
+ rules: {
+ "playwright/no-focused-test": "error",
+ "playwright/no-skipped-test": "error",
+ },
+ },
+ {
+ // The two plain-JS configs: this file and postcss's.
+ files: ["*.config.js"],
+ extends: [js.configs.recommended],
+ languageOptions: { ecmaVersion: 2022, sourceType: "module", globals: { ...globals.node } },
+ },
prettier,
);
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 78063d2e..a049ed8d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -24,6 +24,7 @@
},
"devDependencies": {
"@babel/core": "7.29.7",
+ "@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
"@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3",
@@ -32,9 +33,11 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
+ "@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
+ "eslint-plugin-playwright": "2.12.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
@@ -970,6 +973,36 @@
"node": ">=12"
}
},
+ "node_modules/@eslint-community/eslint-plugin-eslint-comments": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.8.1.tgz",
+ "integrity": "sha512-vafrdi15JHBFh1qWOAucyJwlEEuhdoMof28jOdKdgnwZ7bXwd6v4wzHYSmAVLwnx0yc7bqAtfJ0EZQV1WAzV/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0",
+ "ignore": "^7.0.5"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-plugin-eslint-comments/node_modules/ignore": {
+ "version": "7.0.12",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz",
+ "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -3236,6 +3269,243 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/eslint-plugin": {
+ "version": "1.6.27",
+ "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.27.tgz",
+ "integrity": "sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "^8.58.0",
+ "@typescript-eslint/utils": "^8.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/eslint-plugin": "*",
+ "eslint": ">=8.57.0",
+ "typescript": ">=5.0.0",
+ "vitest": "*"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/eslint-plugin": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ },
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz",
+ "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.71.0",
+ "@typescript-eslint/types": "^8.71.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz",
+ "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz",
+ "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz",
+ "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz",
+ "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.71.0",
+ "@typescript-eslint/tsconfig-utils": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz",
+ "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz",
+ "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.71.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@vitest/eslint-plugin/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
@@ -4087,6 +4357,35 @@
"eslint": ">=7.0.0"
}
},
+ "node_modules/eslint-plugin-playwright": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.12.0.tgz",
+ "integrity": "sha512-7HpSKR/D2oCH4RKRauFskcrnDrHvLQKopW7Nen8lqrFZtx/SB+FgqVaH+zyQd32noZSZy9Lrsa+DsYrVUpn6hg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "globals": "^17.3.0"
+ },
+ "engines": {
+ "node": ">=16.9.0"
+ },
+ "peerDependencies": {
+ "eslint": ">=8.40.0"
+ }
+ },
+ "node_modules/eslint-plugin-playwright/node_modules/globals": {
+ "version": "17.13.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
+ "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/eslint-plugin-react-hooks": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index e9bd1f67..64620f6a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -40,6 +40,7 @@
},
"devDependencies": {
"@babel/core": "7.29.7",
+ "@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
"@eslint/js": "9.39.1",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.3.3",
@@ -48,9 +49,11 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
+ "@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
+ "eslint-plugin-playwright": "2.12.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx
index 6ece7882..d4179213 100644
--- a/frontend/src/components/AddToPlaylist.tsx
+++ b/frontend/src/components/AddToPlaylist.tsx
@@ -2,7 +2,8 @@ import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
-import { ListPlus, Plus, Youtube } from "lucide-react";
+import { ListPlus, Plus } from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { MenuCheck } from "./ui/MenuCheck";
import { api, type Playlist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
@@ -117,6 +118,7 @@ function MembershipPanel({
async function toggle(pl: Playlist) {
if (busy) return;
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
else await api.addToPlaylist(pl.id, videoId);
@@ -132,6 +134,7 @@ function MembershipPanel({
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const pl = await api.createPlaylist(name);
await api.addToPlaylist(pl.id, videoId);
@@ -183,7 +186,7 @@ function MembershipPanel({
{playlistName(pl, t)}
{(pl.source === "youtube" || pl.yt_playlist_id) && (
-
+
)}
))}
diff --git a/frontend/src/components/BootGate.dom.test.tsx b/frontend/src/components/BootGate.dom.test.tsx
index a81f4c74..e436e12c 100644
--- a/frontend/src/components/BootGate.dom.test.tsx
+++ b/frontend/src/components/BootGate.dom.test.tsx
@@ -23,13 +23,14 @@ import i18n from "../i18n";
const renders: { page: string; lang: string }[] = [];
let mounts = 0;
+const countMount = () => {
+ mounts++; // outside the component: the compiler cannot lower `++` on a module variable in one
+};
function Probe() {
const { page } = useNavigation();
const { i18n: inst } = useTranslation();
renders.push({ page, lang: inst.language });
- useEffect(() => {
- mounts++;
- }, []);
+ useEffect(countMount, []);
return {page}
;
}
@@ -226,7 +227,9 @@ describe("BootGate", () => {
const qc = mount();
await screen.findByTestId("app");
// Anything that re-renders the gate must leave it alone: a pref save, a background refetch.
- act(() => qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } })));
+ act(() => {
+ qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } }));
+ });
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
@@ -241,7 +244,9 @@ describe("BootGate", () => {
vi.stubGlobal("location", { ...window.location, reload });
// What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account.
- act(() => qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } })));
+ act(() => {
+ qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } }));
+ });
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
diff --git a/frontend/src/components/ChannelPage.backfill.dom.test.tsx b/frontend/src/components/ChannelPage.backfill.dom.test.tsx
index 673a714d..8f060b47 100644
--- a/frontend/src/components/ChannelPage.backfill.dom.test.tsx
+++ b/frontend/src/components/ChannelPage.backfill.dom.test.tsx
@@ -82,7 +82,9 @@ describe("ChannelPage — the grid follows a running backfill", () => {
expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false);
// The next poll tick: the background job has stored 60 more.
- act(() => qc.setQueryData(qk.channel(CH), reading(79)));
+ act(() => {
+ qc.setQueryData(qk.channel(CH), reading(79));
+ });
await waitFor(() => expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(true));
});
diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx
index c5b86cc9..cdd36b56 100644
--- a/frontend/src/components/ChannelsProvider.dom.test.tsx
+++ b/frontend/src/components/ChannelsProvider.dom.test.tsx
@@ -60,9 +60,9 @@ describe("ChannelsProvider tab history", () => {
expect(result.current.view).toBe("blocked");
// Browser Back to an entry stamped with a different tab.
- act(() =>
- window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })),
- );
+ act(() => {
+ window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } }));
+ });
expect(result.current.view).toBe("discovery");
expect(localStorage.getItem(VIEW_KEY)).toBe("discovery");
@@ -71,7 +71,9 @@ describe("ChannelsProvider tab history", () => {
it("ignores a popstate for an entry outside the manager (no _chTab)", () => {
const { result } = render();
act(() => result.current.setView("hidden"));
- act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } })));
+ act(() => {
+ window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } }));
+ });
expect(result.current.view).toBe("hidden"); // unchanged
});
});
diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx
index 957ffb9e..db984d3d 100644
--- a/frontend/src/components/ChatDock.tsx
+++ b/frontend/src/components/ChatDock.tsx
@@ -13,6 +13,7 @@ import {
} from "../lib/chatDock";
import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
+import { errorAttrs, log } from "../lib/log";
import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay";
@@ -30,7 +31,17 @@ export default function ChatDock({ meId }: { meId: number }) {
// Restore the per-device unlocked key and the persisted dock windows once, app-wide.
useEffect(() => {
initDock(meId);
- e2ee.loadFromDevice(meId);
+ // The device key store can be unopenable (storage disabled, some private windows): messages then
+ // stay locked behind the passphrase, as on a new device — said in the log, not thrown at nobody.
+ e2ee
+ .loadFromDevice(meId)
+ .catch((err: unknown) =>
+ log.warn(
+ "ui.e2ee_device_key_unreadable",
+ "the device key store could not be read",
+ errorAttrs(err),
+ ),
+ );
}, [meId]);
// Live delivery: refresh the conversation list, unread badge, and the affected thread; and
diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx
index f6249c5e..6cdd4ee5 100644
--- a/frontend/src/components/ChatThread.tsx
+++ b/frontend/src/components/ChatThread.tsx
@@ -54,7 +54,8 @@ export default function ChatThread({
useEffect(() => {
if (isSystem || !ready) return;
let cancelled = false;
- (async () => {
+ // Every await inside is caught per message (a failure reads "can't decrypt"), so this never rejects.
+ void (async () => {
const out: Record = {};
// Fetch the partner key lazily inside the try (only when there's a ciphertext message) and
// once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and
diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx
index 8594101e..eab36ba5 100644
--- a/frontend/src/components/DownloadDialog.tsx
+++ b/frontend/src/components/DownloadDialog.tsx
@@ -104,7 +104,7 @@ export default function DownloadDialog({
{
setManage(false);
- profilesQ.refetch();
+ void profilesQ.refetch();
}}
/>
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index bd10d7e1..aee20101 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -19,9 +19,9 @@ import {
RefreshCw,
Rows3,
Trash2,
- Youtube,
type LucideIcon,
} from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors";
@@ -352,7 +352,7 @@ export default function Feed({
// The toast's Undo action re-enters onState, which is a forward reference to the binding still
// being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
const onStateRef = useRef(onState);
- // eslint-disable-next-line react-hooks/immutability
+ // eslint-disable-next-line react-hooks/immutability -- a latest-value ref for a forward reference: the Undo re-enters onState before its binding exists
onStateRef.current = onState;
const onResetState = useCallback(
@@ -473,7 +473,7 @@ export default function Feed({
{t("feed.yt.back")}
-
+
{t("feed.yt.resultsFor", { query: ytSearch })}
@@ -882,7 +882,7 @@ export default function Feed({
{filters.q.trim() && !isDemo && (
}
+ icon={ }
onClick={() => onYtSearch(filters.q.trim())}
>
{t("feed.yt.searchFor", { query: filters.q.trim() })}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 0c7e2016..8729df4a 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -1,6 +1,6 @@
import { forwardRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
-import { Youtube } from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
@@ -61,7 +61,7 @@ const Header = forwardRef<
onGo={() => onYtSearch(trimmed)}
goLabel={t("header.searchYoutube")}
goTitle={t("header.searchYoutubeTip")}
- goIcon={ }
+ goIcon={ }
goDisabled={!trimmed || me.is_demo}
/>
);
diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx
index 09b3f1d0..80d669da 100644
--- a/frontend/src/components/IssueComposer.tsx
+++ b/frontend/src/components/IssueComposer.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState, type DragEvent, type MutableRefObject } from "react";
+import { useEffect, useMemo, useRef, useState, type DragEvent, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
@@ -606,7 +606,7 @@ function Attachments({
adding: boolean;
onAdd: (files: Iterable) => void;
onReview: (source: Source) => void;
- reviewed: MutableRefObject;
+ reviewed: RefObject;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -615,7 +615,7 @@ function Attachments({
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
const pick = useRef(null);
const reviewButtons = useRef>>({});
- // eslint-disable-next-line react-hooks/immutability
+ // eslint-disable-next-line react-hooks/immutability -- the effect that consumes the caller's one-shot focus token (see the write below)
useEffect(() => {
const source = reviewed.current;
if (!source) return;
diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx
index d04b17dc..af4a5258 100644
--- a/frontend/src/components/KeyGate.tsx
+++ b/frontend/src/components/KeyGate.tsx
@@ -44,6 +44,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
if (pass.length < 6) return setErr(t("messages.passTooShort"));
if (!configured && pass !== confirm) return setErr(t("messages.passMismatch"));
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (configured) {
await e2ee.unlock(meId, pass, keyQ.data!);
@@ -76,6 +77,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
setErr(null);
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.resetMessageKey(hasPassword ? curPass : undefined);
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx
index 1c1f0742..54ee85a0 100644
--- a/frontend/src/components/Messages.tsx
+++ b/frontend/src/components/Messages.tsx
@@ -100,7 +100,8 @@ function ConversationList({
useEffect(() => {
if (!ready) return;
let cancelled = false;
- (async () => {
+ // Each decrypt is caught (a failure previews as "⚠"), so this never rejects.
+ void (async () => {
const out: Record = {};
for (const c of conversationsRef.current?.items ?? []) {
const m = c.last_message;
diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx
index 764709d6..d7f36e40 100644
--- a/frontend/src/components/NavigationProvider.dom.test.tsx
+++ b/frontend/src/components/NavigationProvider.dom.test.tsx
@@ -84,7 +84,9 @@ describe("NavigationProvider · the tab title", () => {
await settle();
expect(document.title).toBe("Siftlode");
- act(() => qc.setQueryData(qk.me(), { id: 1 }));
+ act(() => {
+ qc.setQueryData(qk.me(), { id: 1 });
+ });
await settle();
expect(document.title).not.toBe("Siftlode");
expect(document.title.endsWith(" · Siftlode")).toBe(true);
diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx
index 5fb27379..121585ae 100644
--- a/frontend/src/components/OnboardingWizard.tsx
+++ b/frontend/src/components/OnboardingWizard.tsx
@@ -2,7 +2,8 @@ import { useEffect, useId, useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
-import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
+import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X } from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding";
import { Button } from "./ui/Button";
@@ -186,7 +187,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
{step === "read" && (
<>
-
+
{t("onboarding.read.title")}
@@ -199,7 +200,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
-
} onClick={() => beginGrant("read")}>
+
} onClick={() => beginGrant("read")}>
{t("onboarding.read.connect")}
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index c816dbac..2d242a68 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -70,6 +70,7 @@ import {
useYtBigView,
useYtProgress,
useYtVolume,
+ YT_API_LOAD_FAILED,
} from "../lib/playerModalHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
@@ -372,6 +373,7 @@ export default function PlayerModal({
const victim = active;
removingRef.current = true;
let done = false;
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try without catch yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
done = await onRemoveFromList(victim);
} finally {
@@ -831,9 +833,11 @@ export default function PlayerModal({
{t("player.unavailableTitle")}
- {playerError === 101 || playerError === 150
- ? t("player.embedDisabledBody")
- : t("player.unavailableBody")}
+ {playerError === YT_API_LOAD_FAILED
+ ? t("player.apiLoadFailedBody")
+ : playerError === 101 || playerError === 150
+ ? t("player.embedDisabledBody")
+ : t("player.unavailableBody")}
{
+ async function removeItem(video: Video, listIdArg?: number | null): Promise {
+ // Defaulted here, not as `listId = selectedRef.current` in the signature: the React Compiler
+ // cannot reorder a ref read in a default parameter and skipped the component for it.
+ const listId = listIdArg === undefined ? selectedRef.current : listIdArg;
if (listId == null) return false;
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
// be removing from the list it was launched from while another one is selected.
@@ -685,7 +686,7 @@ export default function Playlists() {
label: plName(pl),
icon:
pl.source === "youtube" || pl.yt_playlist_id ? (
-
+
) : (
),
@@ -866,7 +867,7 @@ export default function Playlists() {
onKeyDown={(e) => {
if (e.key === "Enter") {
renameFocus.arm(e);
- saveRename();
+ void saveRename(); // plActions.rename reports its own failure
}
if (e.key === "Escape") {
renameFocus.arm(e);
diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx
index ccc49e7f..10d11d17 100644
--- a/frontend/src/components/PlaylistsRail.tsx
+++ b/frontend/src/components/PlaylistsRail.tsx
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
-import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
+import { ListVideo, Pin, Plus, RefreshCw } from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { api, type Playlist } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatRuntime } from "../lib/format";
@@ -155,6 +156,7 @@ export default function PlaylistsRail({
async function syncYoutube() {
if (syncing) return;
setSyncing(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const r = await api.syncYoutubePlaylists();
qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -360,7 +362,7 @@ export default function PlaylistsRail({
{plName(pl)}
{(pl.source === "youtube" || pl.yt_playlist_id) && (
-
)}
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index 1259f6b8..d85aa97b 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -201,7 +201,7 @@ export default function PlexBrowse() {
// needs — the navigation handlers record where the page was, and the restore layout effect above
// reads it on the way back. React's own guarantee (a ref write is never part of rendering) is what
// makes it safe, and nothing here reads the value during render.
- // eslint-disable-next-line react-hooks/immutability
+ // eslint-disable-next-line react-hooks/immutability -- a scroll memory: handlers record, the restore layout effect reads, nothing reads it in render
const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
// One menu for the titles grid and one for the search's episodes (see ui/ContextMenu).
const menu = useContextMenu
();
@@ -289,7 +289,7 @@ export default function PlexBrowse() {
// belong: `setFilters` belongs to PlexProvider, and React only permits a render-phase setState
// on the component's OWN state. The cascade the rule warns about is bounded to one extra pass
// — the guard above is false immediately afterwards.
- // eslint-disable-next-line react-hooks/set-state-in-effect
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters is PlexProvider's state, so it cannot reset during render; the cascade is one bounded pass
setCommittedLang(lang);
if (filters.genres.length) setFilters({ ...filters, genres: [] });
}
diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx
index 0fa001e2..9eaf4f72 100644
--- a/frontend/src/components/PlexCollectionEditor.tsx
+++ b/frontend/src/components/PlexCollectionEditor.tsx
@@ -71,6 +71,7 @@ export default function PlexCollectionEditor({
if (busy) return;
setBusy(id);
const inIt = members.has(id);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (inIt) await api.plexCollectionRemoveItem(id, item.id);
else await api.plexCollectionAddItem(id, item.id);
@@ -94,6 +95,7 @@ export default function PlexCollectionEditor({
async function markEditable(id: string) {
if (busy) return;
setBusy(id);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexSetCollectionEditable(id, true);
refresh();
@@ -108,6 +110,7 @@ export default function PlexCollectionEditor({
const title = newName.trim();
if (!title || busy) return;
setBusy("__new__");
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const col = await api.plexCreateCollection(library, title, item.id);
setNewName("");
@@ -126,6 +129,7 @@ export default function PlexCollectionEditor({
if (!(await confirm({ message: t("plex.collEditor.deleteConfirm", { title }), danger: true })))
return;
setBusy(id);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexDeleteCollection(id);
setMembers((prev) => {
diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx
index eef531e0..af223c1e 100644
--- a/frontend/src/components/PlexCollectionView.tsx
+++ b/frontend/src/components/PlexCollectionView.tsx
@@ -148,6 +148,7 @@ export default function PlexCollectionView({
async function playAll(shuffle: boolean) {
setQueueBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
let queue = await buildQueue();
if (shuffle) queue = shuffled(queue);
@@ -206,6 +207,7 @@ export default function PlexCollectionView({
return;
}
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexRenameCollection(collectionId, title);
invalidate();
@@ -238,6 +240,7 @@ export default function PlexCollectionView({
if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
return;
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexCollectionRemoveItem(collectionId, card.id);
invalidate();
@@ -250,6 +253,7 @@ export default function PlexCollectionView({
async function enableEdit() {
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexSetCollectionEditable(collectionId, true);
invalidate();
@@ -302,7 +306,7 @@ export default function PlexCollectionView({
onKeyDown={(e) => {
if (e.key === "Enter") {
renameFocus.arm(e);
- doRename();
+ void doRename(); // reports its own failure
}
if (e.key === "Escape") {
renameFocus.arm(e);
diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx
index a56bd335..43a60a9d 100644
--- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx
+++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx
@@ -219,7 +219,9 @@ describe("a tap on the picture (C3)", () => {
video()._paused = true;
act(() => void video().play());
expect(bandHidden()).toBe(false);
- act(() => vi.advanceTimersByTime(3000));
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
expect(bandHidden()).toBe(true);
});
});
@@ -247,10 +249,14 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" }); // the keyboard is in use: focus shows
act(() => vol.focus());
fireEvent.keyDown(vol, { key: "ArrowUp" });
- act(() => vi.advanceTimersByTime(5000));
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
expect(bandHidden()).toBe(false);
act(() => vol.blur());
- act(() => vi.advanceTimersByTime(3000));
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
expect(bandHidden()).toBe(true);
});
@@ -264,9 +270,13 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => vol.focus());
fireEvent.keyDown(vol, { key: "ArrowUp" });
- act(() => vi.advanceTimersByTime(9000));
+ act(() => {
+ vi.advanceTimersByTime(9000);
+ });
expect(bandHidden()).toBe(false);
- act(() => vi.advanceTimersByTime(1500));
+ act(() => {
+ vi.advanceTimersByTime(1500);
+ });
expect(bandHidden()).toBe(true);
expect(document.activeElement).toBe(vol);
fireEvent.keyDown(vol, { key: "ArrowUp" });
@@ -283,7 +293,9 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => mute.focus());
fireEvent.keyDown(mute, { key: "ArrowUp" });
- act(() => vi.advanceTimersByTime(10_500));
+ act(() => {
+ vi.advanceTimersByTime(10_500);
+ });
expect(bandHidden()).toBe(true);
const pauses = pause.mock.calls.length;
// prevented: the unseen Mute is not pressed on keyup
@@ -304,7 +316,9 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => mute.focus());
fireEvent.keyDown(mute, { key: "ArrowUp" });
- act(() => vi.advanceTimersByTime(10_500));
+ act(() => {
+ vi.advanceTimersByTime(10_500);
+ });
expect(bandHidden()).toBe(true);
const pauses = pause.mock.calls.length;
expect(fireEvent.keyDown(mute, { key: "Enter" })).toBe(false);
@@ -325,7 +339,9 @@ describe("keyboard focus in the bands", () => {
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => mute.focus());
fireEvent.keyDown(mute, { key: "ArrowUp" });
- act(() => vi.advanceTimersByTime(10_500));
+ act(() => {
+ vi.advanceTimersByTime(10_500);
+ });
expect(bandHidden()).toBe(true);
expect(fireEvent.keyDown(mute, { key })).toBe(false);
const toggles = pause.mock.calls.length + play.mock.calls.length;
@@ -352,7 +368,9 @@ describe("keyboard focus in the bands", () => {
const skip = screen.getByRole("button", { name: /plex\.player\.skipIntro/ });
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => skip.focus());
- act(() => vi.advanceTimersByTime(10_500));
+ act(() => {
+ vi.advanceTimersByTime(10_500);
+ });
expect(bandHidden()).toBe(true);
const pauses = pause.mock.calls.length;
// not prevented: the button's own activation runs, and Space does not pause the film instead
@@ -377,7 +395,9 @@ describe("keyboard focus in the bands", () => {
return sel === ":focus-visible" ? false : matches.call(this, sel);
});
act(() => btn.focus());
- act(() => vi.advanceTimersByTime(3000));
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
expect(bandHidden()).toBe(true);
act(() => btn.blur()); // the tap on the picture takes the focus first…
pressPicture("touch"); // …then toggles
@@ -428,7 +448,9 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
pressPicture("touch", 450); // reveal
expect(bandHidden()).toBe(false);
expect(band()).toContain("pointer-events-none");
- act(() => vi.advanceTimersByTime(300));
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
expect(band()).not.toContain("pointer-events-none");
});
diff --git a/frontend/src/components/PlexPlayer.session.dom.test.tsx b/frontend/src/components/PlexPlayer.session.dom.test.tsx
index 650282ed..cbb3d66e 100644
--- a/frontend/src/components/PlexPlayer.session.dom.test.tsx
+++ b/frontend/src/components/PlexPlayer.session.dom.test.tsx
@@ -270,7 +270,9 @@ describe("the subtitle poll and notice", () => {
it("shows a toast raised while it is open inside itself, not under its scrim", async () => {
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
await screen.findByText("Title A");
- act(() => notify({ level: "error", message: "Couldn't mark it watched." }));
+ act(() => {
+ notify({ level: "error", message: "Couldn't mark it watched." });
+ });
const toast = await screen.findByText("Couldn't mark it watched.");
let host: Element | null = video();
while (host && !host.contains(toast)) host = host.parentElement;
diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx
index 837d2563..6d34f24f 100644
--- a/frontend/src/components/PlexPlayer.tsx
+++ b/frontend/src/components/PlexPlayer.tsx
@@ -27,7 +27,7 @@ import {
Volume2,
VolumeX,
} from "lucide-react";
-import { api, HttpError, type PlexTrackNaming } from "../lib/api";
+import { api, HttpError, type PlexPlaySession, type PlexTrackNaming } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import {
audioOrdForLang,
@@ -418,7 +418,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// "Preparing stream…" for a not-yet-known session while the plexSession request is in flight —
// it's re-set honestly from the response below.
setSessionIsTranscode(false);
- let sess;
+ // Initialised, not a bare `let sess;`: the compiler's analysis read an unassigned local as a
+ // value from OUTSIDE this callback and asked for `sess.hw` as a dependency (memo-dependencies).
+ let sess: PlexPlaySession | null = null;
try {
// Multi-audio → request the multi-rendition session (no single `audio` ordinal); the track is
// selected client-side after the manifest loads.
@@ -724,7 +726,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// sessGenRef is a LIVE counter: bumping it is how the unmount tells every in-flight session
// that its response no longer belongs to anything, and a value captured when the effect ran
// would invalidate a generation that has since been superseded instead of the current one.
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- sessGenRef is a LIVE counter: unmount must bump the current generation, not a copy
sessGenRef.current++;
if (hlsRef.current) {
hlsRef.current.destroy();
@@ -837,7 +839,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
video.currentTime = rel; // native seek within the loaded region keeps playing on its own
if (play) video.play().catch(() => {});
} else {
- loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
+ void loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
}
},
[loadSession],
diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx
index 673c10dd..dfedfa46 100644
--- a/frontend/src/components/PlexPlaylistAdd.tsx
+++ b/frontend/src/components/PlexPlaylistAdd.tsx
@@ -50,7 +50,7 @@ export default function PlexPlaylistAdd({
: api.plexPlaylists(target.ratingKey),
});
const playlists = listQ.data?.playlists ?? [];
- const refresh = () => qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
+ const refresh = () => void qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
// How many of the target a playlist currently holds (0..size).
function inCount(p: PlexPlaylist): number {
@@ -68,6 +68,7 @@ export default function PlexPlaylistAdd({
if (busy) return;
setBusy(true);
const allIn = inCount(p) >= size;
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (isGroup) {
if (allIn) await api.plexPlaylistRemoveBulk(p.id, target.ratingKeys);
@@ -92,6 +93,7 @@ export default function PlexPlaylistAdd({
const title = newName.trim();
if (!title || busy) return;
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (isGroup) {
const pl = await api.plexCreatePlaylist(title);
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx
index d5fee5a5..e8af6374 100644
--- a/frontend/src/components/PlexPlaylistView.tsx
+++ b/frontend/src/components/PlexPlaylistView.tsx
@@ -742,7 +742,7 @@ export default function PlexPlaylistView({
onKeyDown={(e) => {
if (e.key === "Enter") {
renameFocus.arm(e);
- rename();
+ void rename(); // reports its own failure
}
// Unmounting the field fires no blur, so Escape really does cancel.
if (e.key === "Escape") {
diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index 167fa7ab..a0702107 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -401,7 +401,8 @@ export function PlaylistPicker() {
onChange={(next) => {
const v = next[0];
if (!v) return;
- if (v === CREATE_PLAYLIST) createPlaylist(q);
+ if (v === CREATE_PLAYLIST)
+ void createPlaylist(q); // reports its own failure
else onOpenPlaylist(Number(v.slice(3)));
}}
/>
diff --git a/frontend/src/components/PrefsProvider.dom.test.tsx b/frontend/src/components/PrefsProvider.dom.test.tsx
index 283e84ca..7dd9a8d5 100644
--- a/frontend/src/components/PrefsProvider.dom.test.tsx
+++ b/frontend/src/components/PrefsProvider.dom.test.tsx
@@ -49,7 +49,10 @@ describe("PrefsProvider seed", () => {
);
expect(seen[0]).toEqual({ perf: true, hints: false });
- act(() => vi.advanceTimersByTime(1000)); // past the auto-save debounce
+ // past the auto-save debounce
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
expect(save).not.toHaveBeenCalled();
});
});
diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx
index af963438..d84454a5 100644
--- a/frontend/src/components/ProfileEditor.tsx
+++ b/frontend/src/components/ProfileEditor.tsx
@@ -60,7 +60,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const [spec, setSpec] = useState(BLANK);
const [formOpen, setFormOpen] = useState(false);
- const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
+ const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
const startNew = () => {
setEditing(null);
setName("");
diff --git a/frontend/src/components/SavedViewsProvider.dom.test.tsx b/frontend/src/components/SavedViewsProvider.dom.test.tsx
index 6da9165f..ae68873b 100644
--- a/frontend/src/components/SavedViewsProvider.dom.test.tsx
+++ b/frontend/src/components/SavedViewsProvider.dom.test.tsx
@@ -201,7 +201,9 @@ describe("SavedViewsProvider — signed-out tab", () => {
});
expect(spy).not.toHaveBeenCalled();
// …and it asks as soon as an account signs in.
- act(() => qc.setQueryData(qk.me(), { id: ID, preferences: {} }));
+ act(() => {
+ qc.setQueryData(qk.me(), { id: ID, preferences: {} });
+ });
await waitFor(() => expect(spy).toHaveBeenCalledWith("feed"));
});
});
diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx
index b7a21c80..280d8c93 100644
--- a/frontend/src/components/SavedViewsProvider.tsx
+++ b/frontend/src/components/SavedViewsProvider.tsx
@@ -188,8 +188,9 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
const displayView = activeView ?? stickyView;
const isDirty = displayView != null && activeView == null;
- const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews(source) });
+ const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
async function runViewMutation(fn: () => Promise, failMsg: string): Promise {
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await fn();
return true;
diff --git a/frontend/src/components/Scheduler.menu.dom.test.tsx b/frontend/src/components/Scheduler.menu.dom.test.tsx
index cb8e0f26..d9f5fafd 100644
--- a/frontend/src/components/Scheduler.menu.dom.test.tsx
+++ b/frontend/src/components/Scheduler.menu.dom.test.tsx
@@ -74,7 +74,9 @@ afterEach(() => {
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu() {
- act(() => fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" })));
+ act(() => {
+ fireEvent.click(screen.getByRole("button", { name: "scheduler.menu" }));
+ });
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index 0feaa259..c1ae7e9d 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -367,6 +367,7 @@ function SignInMethods({ me }: { me: Me }) {
)
return;
setSessBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.logoutOthers();
notify({ level: "success", message: t("settings.account.sessions.done") });
@@ -377,10 +378,11 @@ function SignInMethods({ me }: { me: Me }) {
}
};
- const submit = async (e: React.FormEvent) => {
+ const submit = async (e: React.SubmitEvent) => {
e.preventDefault();
setErr(null);
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.setPassword(next, me.has_password ? current : undefined);
setOpen(false);
diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx
index 93b9190d..e2533c82 100644
--- a/frontend/src/components/SetupWizard.tsx
+++ b/frontend/src/components/SetupWizard.tsx
@@ -84,6 +84,7 @@ export default function SetupWizard() {
setAttempt((n) => n + 1);
setError("");
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (step === "admin") {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx
index 963391fa..ef003ac4 100644
--- a/frontend/src/components/ShareDialog.tsx
+++ b/frontend/src/components/ShareDialog.tsx
@@ -240,7 +240,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
const [allowDownload, setAllowDownload] = useState(true);
const [expiryDays, setExpiryDays] = useState(null);
const [password, setPassword] = useState("");
- const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
+ const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
const create = useMutation({
mutationFn: () =>
diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx
index ae25a61c..a906cfb9 100644
--- a/frontend/src/components/VideoEditor.tsx
+++ b/frontend/src/components/VideoEditor.tsx
@@ -141,7 +141,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const playPause = () => {
const v = videoRef.current;
if (!v) return;
- v.paused ? v.play() : v.pause();
+ // play() rejects when a pause or a source change interrupts it; the button state follows the
+ // element's own events, so there is nothing to undo.
+ if (v.paused) v.play().catch(() => {});
+ else v.pause();
};
// --- cut-list mutations ---
diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx
index 47e87f30..d877f00e 100644
--- a/frontend/src/components/WatchPage.tsx
+++ b/frontend/src/components/WatchPage.tsx
@@ -200,6 +200,7 @@ export default function WatchPage() {
const unlock = async () => {
setUnlocking(true);
setError(null);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
method: "POST",
@@ -219,6 +220,7 @@ export default function WatchPage() {
setStatus("gone");
return;
}
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower a throw inside try/catch yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
if (!r.ok) throw new Error(`HTTP ${r.status}`);
setMeta(await r.json());
setStatus("ready");
diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx
index 82615fb9..a195c662 100644
--- a/frontend/src/components/Welcome.tsx
+++ b/frontend/src/components/Welcome.tsx
@@ -478,7 +478,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
setMode("forgot");
};
- async function onSubmit(e: React.FormEvent) {
+ async function onSubmit(e: React.SubmitEvent) {
e.preventDefault();
setAttempt((n) => n + 1);
reset();
@@ -495,6 +495,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
return;
}
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (mode === "signin") {
await api.passwordLogin(mail, password);
@@ -538,7 +539,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
}
}
- async function onDemo(e: React.FormEvent) {
+ async function onDemo(e: React.SubmitEvent) {
e.preventDefault();
setDemoError("");
const mail = demoEmail.trim().toLowerCase();
@@ -547,6 +548,7 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
return;
}
setDemoBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const r = await api.demoLogin(mail);
if (r.authenticated) window.location.reload();
diff --git a/frontend/src/components/accountPanel.dom.test.tsx b/frontend/src/components/accountPanel.dom.test.tsx
index 49191ce8..a7b286f3 100644
--- a/frontend/src/components/accountPanel.dom.test.tsx
+++ b/frontend/src/components/accountPanel.dom.test.tsx
@@ -67,8 +67,8 @@ function mount(u: typeof user = user) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- {}} />
+ {/* a partial user fixture: `never` stands in for the full type without an `any` */}
+ {}} />
,
);
}
diff --git a/frontend/src/components/legal/LegalLayout.tsx b/frontend/src/components/legal/LegalLayout.tsx
index 60e6f2d2..a4dd89bb 100644
--- a/frontend/src/components/legal/LegalLayout.tsx
+++ b/frontend/src/components/legal/LegalLayout.tsx
@@ -35,7 +35,7 @@ function useOperatorContact(): string | null {
const [contact, setContact] = useState(null);
useEffect(() => {
let alive = true;
- fetchOperatorContact().then((c) => alive && setContact(c));
+ void fetchOperatorContact().then((c) => alive && setContact(c)); // never rejects: null on failure
return () => {
alive = false;
};
diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/components/plexCards.dom.test.tsx
index e155f584..defc33f5 100644
--- a/frontend/src/components/plexCards.dom.test.tsx
+++ b/frontend/src/components/plexCards.dom.test.tsx
@@ -112,9 +112,13 @@ describe("PlexPosterCard preview", () => {
vi.useFakeTimers();
render( );
fireEvent.mouseEnter(root());
- act(() => vi.advanceTimersByTime(600));
+ act(() => {
+ vi.advanceTimersByTime(600);
+ });
expect(screen.queryByText("A quiet synopsis.")).toBeNull(); // a sweep opens nothing
- act(() => vi.advanceTimersByTime(400));
+ act(() => {
+ vi.advanceTimersByTime(400);
+ });
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's
});
@@ -123,7 +127,9 @@ describe("PlexPosterCard preview", () => {
vi.useFakeTimers();
render( );
fireEvent.focus(screen.getByRole("button", { name: "A long title" }));
- act(() => vi.advanceTimersByTime(2000));
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
expect(screen.queryByText("A quiet synopsis.")).toBeNull();
});
});
diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx
index 14542f66..97d94640 100644
--- a/frontend/src/components/plexViews.tsx
+++ b/frontend/src/components/plexViews.tsx
@@ -225,7 +225,9 @@ export function PlexPersonView({
menuOpen: menu.open && menu.target === c,
});
- const section = (titleKey: string, cards: PlexCard[]) =>
+ // Not `section`: a local named like the it renders confused the React Compiler's scope
+ // analysis ("[hoisting] expected identifier to be defined") and it skipped the component.
+ const renderSection = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? (
{t(titleKey, { count: cards.length })}
@@ -385,8 +387,8 @@ export function PlexPersonView({
{t("plex.person.empty")}
) : (
<>
- {section("plex.person.movies", d.movies)}
- {section("plex.person.shows", d.shows)}
+ {renderSection("plex.person.movies", d.movies)}
+ {renderSection("plex.person.shows", d.shows)}
>
)}
@@ -464,6 +466,7 @@ export function PlexShowView({
);
async function markAll(w: boolean) {
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexShowState(showId, w);
} catch (e) {
@@ -797,6 +800,7 @@ export function PlexSeasonView({
);
async function markAll(w: boolean) {
setBusy(true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.plexSeasonState(seasonId, w);
} catch (e) {
diff --git a/frontend/src/components/ui/AnchoredPopover.tsx b/frontend/src/components/ui/AnchoredPopover.tsx
index 64df5d92..3de99c6c 100644
--- a/frontend/src/components/ui/AnchoredPopover.tsx
+++ b/frontend/src/components/ui/AnchoredPopover.tsx
@@ -231,7 +231,7 @@ interface PopoverSurfaceProps {
* take its name from. */
ariaLabel?: string;
focusManaged?: boolean;
- initialFocus?: number | React.MutableRefObject;
+ initialFocus?: number | React.RefObject;
/** The surface never receives pointer events — for a passive caption that must not swallow a click
* aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable
* content, links) must NOT set this. */
diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx
index 1b8b3066..c03bacd6 100644
--- a/frontend/src/components/ui/Toaster.tsx
+++ b/frontend/src/components/ui/Toaster.tsx
@@ -205,7 +205,7 @@ export default function Toaster({
{/* lib/toastDock is a one-slot registry: this identity changes only when a surface docks
or undocks, and remounting is then the correct behaviour, not the hazard the rule
warns about. */}
- {/* eslint-disable-next-line react-hooks/static-components */}
+ {/* eslint-disable-next-line react-hooks/static-components -- a one-slot dock registry: the identity changes only on dock/undock, where a remount is right */}
)}
diff --git a/frontend/src/components/ui/overlayOrder.ts b/frontend/src/components/ui/overlayOrder.ts
index d04a8df5..28be5f58 100644
--- a/frontend/src/components/ui/overlayOrder.ts
+++ b/frontend/src/components/ui/overlayOrder.ts
@@ -3,9 +3,11 @@ import { useState } from "react";
// An overlay's toast-host rank (see the overlay-hosts note in Toaster). Its own module so Toaster exports
// only components (Fast Refresh); the counter is module state, so there is exactly one sequence.
let overlaySeq = 0;
+// Outside the hook: the React Compiler cannot lower `++` on a module variable inside one (react-hooks/todo).
+const nextOverlaySeq = () => ++overlaySeq;
/** This overlay's rank among open overlays — taken once, when it mounts. Call it in the overlay
* itself, never in a component that can remount while the overlay stays open. */
export function useOverlayOrder(): number {
- const [order] = useState(() => ++overlaySeq);
+ const [order] = useState(nextOverlaySeq);
return order;
}
diff --git a/frontend/src/components/ui/sortableA11y.ts b/frontend/src/components/ui/sortableA11y.ts
index 3c4dd5bc..15f721b1 100644
--- a/frontend/src/components/ui/sortableA11y.ts
+++ b/frontend/src/components/ui/sortableA11y.ts
@@ -65,7 +65,7 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
// dnd-kit reports the item over ITSELF in the pick-up tick (see above), and this flag is
// the only thing separating that from a real move. A ref would be the wrong lifetime —
// one drag, not the component.
- // eslint-disable-next-line react-hooks/immutability
+ // eslint-disable-next-line react-hooks/immutability -- per-DRAG state the announcement closures share; a ref would live as long as the component
moved = true;
return t("common.dnd.movedOver", landing(active.id, over.id));
},
diff --git a/frontend/src/components/useDebugRecording.ts b/frontend/src/components/useDebugRecording.ts
index b5b39269..0e8c5d37 100644
--- a/frontend/src/components/useDebugRecording.ts
+++ b/frontend/src/components/useDebugRecording.ts
@@ -106,6 +106,7 @@ export function useDebugRecording() {
if (!session) return;
hide(true);
let image: Blob | null = null;
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
// Two frames: the pill's `invisible` must be painted before the capture reads the tab.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
diff --git a/frontend/src/components/useDownloadActions.tsx b/frontend/src/components/useDownloadActions.tsx
index 3e31a8dd..4726d893 100644
--- a/frontend/src/components/useDownloadActions.tsx
+++ b/frontend/src/components/useDownloadActions.tsx
@@ -89,6 +89,7 @@ export function useDownloadActions({
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try without catch yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await quickShareLink(qc, job, t);
} finally {
diff --git a/frontend/src/components/usePlaylistActions.tsx b/frontend/src/components/usePlaylistActions.tsx
index c502fb7f..2938e658 100644
--- a/frontend/src/components/usePlaylistActions.tsx
+++ b/frontend/src/components/usePlaylistActions.tsx
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
-import { ExternalLink, Pencil, RotateCcw, Trash2, Upload, Youtube } from "lucide-react";
+import { ExternalLink, Pencil, RotateCcw, Trash2, Upload } from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { qk } from "../lib/queryKeys";
import { api, type Playlist } from "../lib/api";
import { notify } from "../lib/notifications";
@@ -130,6 +131,7 @@ export function usePlaylistActions() {
// Re-read after the dialog: another menu may have started one while this one asked.
if (!ok || syncing(pl.id)) return;
mark("reverting", pl.id, true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
await api.revertPlaylist(pl.id);
refresh(pl.id);
@@ -148,6 +150,7 @@ export function usePlaylistActions() {
if (syncing(pl.id)) return;
const name = playlistName(pl, t);
mark("pushing", pl.id, true);
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
const plan = await api.playlistPushPlan(pl.id);
const nothingToDo =
@@ -266,7 +269,7 @@ export function usePlaylistActions() {
icon: pushing.has(pl.id) ? (
) : linked ? (
-
+
) : (
),
diff --git a/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx b/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx
index d3e3c96a..8098dfdc 100644
--- a/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx
+++ b/frontend/src/components/usePlexPlaylistEdits.dom.test.tsx
@@ -99,13 +99,13 @@ describe("usePlexPlaylistEdits · the reorder", () => {
// answers the same from now on — so the rollback's own refetch comes back deep-equal, keeps
// the same `q.data`, and could never correct a stale rollback.
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
- act(() =>
+ act(() => {
qc.setQueryData(qk.plexPlaylist(5, "en"), {
id: 5,
title: "F",
items: [movie("1"), movie("3")],
- }),
- );
+ });
+ });
// The view has taken the refreshed list in BEFORE the reorder fails — the race as it happens.
// (react-query batches its notification, so without this wait the refresh landed only after the
// rollback and quietly corrected a stale one, which proved nothing.)
diff --git a/frontend/src/components/usePlexPlaylistEdits.ts b/frontend/src/components/usePlexPlaylistEdits.ts
index 1259e83b..0603d8cf 100644
--- a/frontend/src/components/usePlexPlaylistEdits.ts
+++ b/frontend/src/components/usePlexPlaylistEdits.ts
@@ -96,6 +96,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
const before = items.map((i) => i.id);
const set = new Set(keys);
setItems((prev) => prev.filter((i) => !set.has(i.id)));
+ // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3
try {
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
else await api.plexPlaylistRemoveBulk(playlistId, keys);
diff --git a/frontend/src/design-lab/batchB.tsx b/frontend/src/design-lab/batchB.tsx
index a15943b3..a4b3b2f0 100644
--- a/frontend/src/design-lab/batchB.tsx
+++ b/frontend/src/design-lab/batchB.tsx
@@ -16,8 +16,8 @@ import {
SlidersHorizontal,
Tv,
Users,
- Youtube,
} from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
import { Btn } from "./batchA";
import { hexContrast, hueGap } from "../lib/colorMath";
import { Reference, Sample } from "./labKit";
@@ -285,7 +285,7 @@ function WatchPage() {
type NavDef = { icon: typeof Home; en: string; hu: string };
const NAV: NavDef[] = [
{ icon: Home, en: "Home", hu: "Kezdőlap" },
- { icon: Youtube, en: "Feed", hu: "Feed" },
+ { icon: YouTubeIcon, en: "Feed", hu: "Feed" },
{ icon: Tv, en: "Channels", hu: "Csatornák" },
{ icon: ListVideo, en: "Playlists", hu: "Listák" },
{ icon: Clapperboard, en: "Plex", hu: "Plex" },
diff --git a/frontend/src/i18n/locales/en/player.json b/frontend/src/i18n/locales/en/player.json
index ad2d6cd1..ac70e6d0 100644
--- a/frontend/src/i18n/locales/en/player.json
+++ b/frontend/src/i18n/locales/en/player.json
@@ -34,6 +34,7 @@
"playInApp": "Play in the in-app player",
"unavailableTitle": "Can't play this here",
"unavailableBody": "This video isn't available — it may be private, removed, or region-restricted.",
+ "apiLoadFailedBody": "The YouTube player didn't load — a connection problem or a content blocker may be stopping it. It still plays on YouTube.",
"embedDisabledBody": "The owner disabled playback on other sites (common for auto-generated “Topic” music tracks). It still plays on YouTube.",
"openOnYoutube": "Open on YouTube",
"shortcutsHint": "Click: play/pause · Scroll or ↑ ↓: volume · F: fill the window",
diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json
index 4aadfe84..b150a7c0 100644
--- a/frontend/src/i18n/locales/hu/player.json
+++ b/frontend/src/i18n/locales/hu/player.json
@@ -34,6 +34,7 @@
"playInApp": "Lejátszás a beépített lejátszóban",
"unavailableTitle": "Ez itt nem játszható le",
"unavailableBody": "Ez a videó nem elérhető — lehet privát, eltávolított vagy régiókorlátozott.",
+ "apiLoadFailedBody": "A YouTube-lejátszó nem töltődött be — kapcsolati hiba vagy tartalomblokkoló akadályozhatja. A YouTube-on viszont lejátszható.",
"embedDisabledBody": "A tulajdonos letiltotta a lejátszást más oldalakon (gyakori az automatikusan generált „Topic” zenei sávoknál). A YouTube-on viszont lejátszható.",
"openOnYoutube": "Megnyitás YouTube-on",
"shortcutsHint": "Kattintás: lejátszás/szünet · Görgő vagy ↑ ↓: hangerő · F: teljes ablak",
diff --git a/frontend/src/lib/brandIcons.ts b/frontend/src/lib/brandIcons.ts
new file mode 100644
index 00000000..59669785
--- /dev/null
+++ b/frontend/src/lib/brandIcons.ts
@@ -0,0 +1,16 @@
+import { createLucideIcon } from "lucide-react";
+
+// Brand marks the app draws itself. Lucide deprecated its brand icons and will remove them, so the
+// YouTube glyph is held here instead — the same 24×24 stroke paths as lucide's `Youtube` (v0.408,
+// ISC licence, © Lucide contributors), built with lucide's own factory so it takes the same props
+// (`className`, `size`, `strokeWidth`) and renders identically next to every other icon.
+export const YouTubeIcon = createLucideIcon("YouTube", [
+ [
+ "path",
+ {
+ d: "M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17",
+ key: "1q2vi4",
+ },
+ ],
+ ["path", { d: "m10 15 5-3-5-3z", key: "1jp15x" }],
+]);
diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts
index e559a6ff..ec751155 100644
--- a/frontend/src/lib/clipboard.ts
+++ b/frontend/src/lib/clipboard.ts
@@ -39,6 +39,7 @@ function legacyCopy(text: string): boolean {
ta.style.height = "1px";
ta.style.padding = "0";
ta.style.border = "none";
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- the iOS-tested hidden-but-selectable textarea (see the header); not re-styled blind
ta.style.clip = "rect(0, 0, 0, 0)";
ta.style.userSelect = "text";
ta.style.setProperty("-webkit-user-select", "text");
@@ -47,6 +48,7 @@ function legacyCopy(text: string): boolean {
ta.focus();
ta.select();
ta.setSelectionRange(0, text.length); // iOS needs an explicit range
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- the only SYNCHRONOUS copy: iOS Safari needs it inside the click gesture, and a non-secure origin has no navigator.clipboard
return document.execCommand("copy");
} catch {
return false;
diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts
index d115d6a6..199647b0 100644
--- a/frontend/src/lib/history.ts
+++ b/frontend/src/lib/history.ts
@@ -207,6 +207,9 @@ export function useHistorySubview(
type Overlay = { token: number; cb: () => void };
const overlayStack: Overlay[] = [];
let overlayCounter = 0;
+// A plain function, not `++overlayCounter` inline: the React Compiler cannot lower an update of a
+// module variable inside a hook, and skipped the whole hook for it (react-hooks/todo).
+const nextOverlayToken = () => ++overlayCounter;
// Overlay history entries we've actually pushed. Reconciled toward overlayStack.length.
let historyDepth = 0;
// Programmatic history.back() calls still awaiting their popstate — each must be ignored.
@@ -222,7 +225,7 @@ function reconcileHistory(): void {
const want = overlayStack.length;
while (historyDepth < want) {
historyDepth++;
- window.history.pushState({ ...window.history.state, _ov: ++overlayCounter }, "");
+ window.history.pushState({ ...window.history.state, _ov: nextOverlayToken() }, "");
}
while (historyDepth > want) {
historyDepth--;
@@ -371,7 +374,7 @@ export function useBackToClose(onClose: () => void): void {
cb.current = onClose;
useEffect(() => {
ensureOverlayListener();
- const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
+ const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
overlayStack.push(entry);
scheduleReconcile();
return () => {
@@ -396,7 +399,7 @@ export function useBackToExit(active: boolean, onExit: () => void): void {
useEffect(() => {
if (!active) return;
ensureOverlayListener();
- const entry: Overlay = { token: ++overlayCounter, cb: () => cb.current() };
+ const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
overlayStack.push(entry);
scheduleReconcile();
return () => {
diff --git a/frontend/src/lib/lintProbe.testkit.ts b/frontend/src/lib/lintProbe.testkit.ts
index 4e513bea..849b8f42 100644
--- a/frontend/src/lib/lintProbe.testkit.ts
+++ b/frontend/src/lib/lintProbe.testkit.ts
@@ -9,7 +9,31 @@ import { beforeAll } from "vitest";
// hook below pays that cost once per test file, under its own budget; importing this registers it.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
-export const eslint = new ESLint({ cwd: FRONTEND });
+// The probes lint TEXT under file names that do not exist (`__probe.tsx`, `x.ts`, a feature-folder
+// `Button.tsx`). Since E32 the src config runs type-aware rules, whose project service refuses a
+// file no tsconfig includes, so those names are let into TypeScript's default project here — the
+// probe still runs the real config, typed rules included, rather than a copy with them switched off.
+const PROBE_FILES = [
+ "src/*/__*probe.ts",
+ "src/*/__*probe.tsx",
+ "src/components/ui/__*probe.tsx",
+ "src/lib/api/__*probe.ts",
+ "src/components/Button.tsx",
+ "src/lib/x.ts",
+];
+
+export const eslint = new ESLint({
+ cwd: FRONTEND,
+ overrideConfig: {
+ files: ["src/**/*.{ts,tsx}"],
+ languageOptions: {
+ parserOptions: {
+ projectService: { allowDefaultProject: PROBE_FILES, defaultProject: "tsconfig.json" },
+ tsconfigRootDir: FRONTEND,
+ },
+ },
+ },
+});
beforeAll(async () => {
await eslint.lintText("", { filePath: "src/components/__probe.tsx" });
diff --git a/frontend/src/lib/mediaLog.ts b/frontend/src/lib/mediaLog.ts
index ad839847..c97b55b3 100644
--- a/frontend/src/lib/mediaLog.ts
+++ b/frontend/src/lib/mediaLog.ts
@@ -88,3 +88,11 @@ export function logYouTubeState(state: number, at: number): void {
export function logYouTubeError(code: number): void {
log.warn("player.media_error", `youtube error ${code}`, { player: "youtube", code });
}
+
+/** The IFrame API script never arrived (blocked, offline, timed out), so there is no player at all. */
+export function logYouTubeApiFailed(err: unknown): void {
+ log.warn("player.yt_api_failed", "the YouTube player could not be loaded", {
+ player: "youtube",
+ reason: err instanceof Error ? err.message : String(err),
+ });
+}
diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts
index 43aae549..d6147779 100644
--- a/frontend/src/lib/modules.ts
+++ b/frontend/src/lib/modules.ts
@@ -13,9 +13,9 @@ import {
SlidersHorizontal,
Tv,
Users,
- Youtube,
type LucideIcon,
} from "lucide-react";
+import { YouTubeIcon } from "./brandIcons";
import type { Me } from "./api";
import type { Page } from "./urlState";
@@ -62,7 +62,7 @@ const MODULES: readonly ModuleDef[] = [
titleKey: "home.title",
icon: Home,
},
- { id: "feed", labelKey: "header.account.feed", railKey: "nav.rail.feed", icon: Youtube },
+ { id: "feed", labelKey: "header.account.feed", railKey: "nav.rail.feed", icon: YouTubeIcon },
{
id: "channels",
labelKey: "header.account.channels",
diff --git a/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx b/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx
new file mode 100644
index 00000000..54ae2e66
--- /dev/null
+++ b/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx
@@ -0,0 +1,84 @@
+// @vitest-environment jsdom
+//
+// The YouTube IFrame API script failing to load must END in an error state. `loadYouTubeApi` used to
+// return a promise that only ever resolved: a blocked or failed script (a content blocker, no
+// network) left the modal on an empty stage forever, with no error and no way to retry short of a
+// reload — the promise was memoised, so every later open waited on the same dead one (E32 audit).
+//
+// Its own file on purpose: `loadYouTubeApi` memoises the API promise in a module-level singleton,
+// and these tests need a module instance that has never seen `window.YT`.
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { QueryClient } from "@tanstack/react-query";
+import { createRef, type RefObject } from "react";
+import { useYtProgress, YT_API_LOAD_FAILED, YT_API_TIMEOUT_MS } from "./playerModalHooks";
+import type { YTPlayer } from "./youtube";
+
+const VID = "EC5oFYy9z9g";
+
+const apiScripts = () =>
+ [...document.head.querySelectorAll("script")].filter((s) => s.src.includes("iframe_api"));
+
+const settle = async () => {
+ for (let i = 0; i < 6; i++) await Promise.resolve();
+};
+
+function mountPlayer(setPlayerError: (code: number | null) => void) {
+ const mount = document.createElement("div");
+ document.body.appendChild(mount);
+ return renderHook(() =>
+ useYtProgress({
+ activeId: VID,
+ resumeAt: 0,
+ mountRef: { current: mount },
+ playerRef: createRef() as RefObject,
+ currentIdRef: { current: VID },
+ navRef: { current: { queue: undefined, index: 0 } },
+ modeRef: { current: { autoMode: "off", loopMode: "off" } },
+ setWatched: () => {},
+ setPlayerError,
+ setLiveData: () => {},
+ reapplyStored: () => {},
+ focusModal: () => {},
+ advanceOnEnd: () => {},
+ sessionPosRef: { current: new Map() },
+ qc: new QueryClient(),
+ }),
+ );
+}
+
+describe("useYtProgress — the IFrame API failing to load", () => {
+ // Fail any load still in flight, so the module's memoised promise is cleared for the next test
+ // (a pending one would carry a timer from before that test's fake clock).
+ afterEach(async () => {
+ for (const s of apiScripts()) s.dispatchEvent(new Event("error"));
+ await settle();
+ vi.useRealTimers();
+ });
+
+ it("shows the load-failed state when the script errors, and a later open tries again", async () => {
+ const setPlayerError = vi.fn();
+ const first = mountPlayer(setPlayerError);
+ const [tag] = apiScripts();
+ expect(tag).toBeDefined();
+ tag!.dispatchEvent(new Event("error"));
+ await settle();
+ expect(setPlayerError).toHaveBeenCalledWith(YT_API_LOAD_FAILED);
+ first.unmount();
+
+ // Not memoised as a failure: the next open loads the script again rather than waiting on the
+ // dead promise.
+ const again = mountPlayer(vi.fn());
+ expect(apiScripts()).toHaveLength(1);
+ again.unmount();
+ });
+
+ it("gives up after the timeout when the script never answers", async () => {
+ vi.useFakeTimers();
+ const setPlayerError = vi.fn();
+ const { unmount } = mountPlayer(setPlayerError);
+ await vi.advanceTimersByTimeAsync(YT_API_TIMEOUT_MS + 1);
+ expect(setPlayerError).toHaveBeenCalledWith(YT_API_LOAD_FAILED);
+ unmount();
+ });
+});
diff --git a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx
index c27fee77..30bb2c78 100644
--- a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx
+++ b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx
@@ -12,7 +12,7 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { QueryClient } from "@tanstack/react-query";
-import { createRef, type MutableRefObject } from "react";
+import { createRef, type RefObject } from "react";
import { useYtProgress } from "./playerModalHooks";
import { patchCachedProgress } from "./progressCache";
import { qk } from "./queryKeys";
@@ -73,7 +73,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map) {
activeId: VID,
resumeAt: 748,
mountRef: { current: mount },
- playerRef: createRef() as MutableRefObject,
+ playerRef: createRef() as RefObject,
currentIdRef: { current: VID },
navRef: { current: { queue: undefined, index: 0 } },
modeRef: { current: { autoMode: "off", loopMode: "off" } },
diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts
index b0b15f88..99f3b720 100644
--- a/frontend/src/lib/playerModalHooks.ts
+++ b/frontend/src/lib/playerModalHooks.ts
@@ -4,7 +4,6 @@ import {
useRef,
useState,
type Dispatch,
- type MutableRefObject,
type RefObject,
type SetStateAction,
} from "react";
@@ -22,7 +21,7 @@ import { api, type Video } from "./api";
import { qk } from "./queryKeys";
import { patchCachedProgress } from "./progressCache";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
-import { logYouTubeError, logYouTubeState } from "./mediaLog";
+import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog";
import type { AutoMode, LoopMode } from "./playerModes";
// How close to the end (seconds) counts as "finished" → auto-mark watched.
@@ -32,19 +31,36 @@ const FINISH_MARGIN = 10;
const MAX_CONSECUTIVE_ERRORS = 3;
// --- IFrame Player API loader (singleton) ---
+/** `playerError` when the IFrame API itself never arrived — not one of YouTube's own error codes
+ * (2/5/100/101/150, all positive), and distinct from the -1 an unparseable one maps to. */
+export const YT_API_LOAD_FAILED = -2;
+/** How long the script may take before the player gives up on it. The real answer comes in well
+ * under a second; anything near this is a blocked or stalled request, not a slow one. */
+export const YT_API_TIMEOUT_MS = 15_000;
let apiPromise: Promise | null = null;
function loadYouTubeApi(): Promise {
if (apiPromise) return apiPromise;
- apiPromise = new Promise((resolve) => {
+ apiPromise = new Promise((resolve, reject) => {
const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT);
+ const tag = document.createElement("script");
+ // A failure is not memoised: the next open loads the script again instead of waiting on a
+ // promise that already lost (a blocker switched off, the network back).
+ const fail = (why: string) => {
+ window.clearTimeout(timer);
+ apiPromise = null;
+ tag.remove();
+ reject(new Error(`YouTube IFrame API ${why}`));
+ };
+ const timer = window.setTimeout(() => fail("timed out"), YT_API_TIMEOUT_MS);
const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => {
+ window.clearTimeout(timer);
if (typeof prev === "function") prev();
// The API-ready callback only fires once the script has installed window.YT.
resolve(w.YT!);
};
- const tag = document.createElement("script");
+ tag.onerror = () => fail("failed to load");
tag.src = "https://www.youtube.com/iframe_api";
document.head.appendChild(tag);
});
@@ -67,7 +83,7 @@ export function useYtVolume({
dialogRef,
focusModal,
}: {
- playerRef: MutableRefObject;
+ playerRef: RefObject;
dialogRef: RefObject;
focusModal: () => void;
}): {
@@ -188,7 +204,7 @@ export function useNativeControlsYield({
stageRef,
focusModal,
}: {
- playerRef: MutableRefObject;
+ playerRef: RefObject;
stageRef: RefObject;
focusModal: () => void;
}): {
@@ -234,13 +250,13 @@ export function useYtBigView({
focusModal,
}: {
stageRef: RefObject;
- playerRef: MutableRefObject;
+ playerRef: RefObject;
focusModal: () => void;
}): {
isFullscreen: boolean;
maximized: boolean;
- maximizedRef: MutableRefObject;
- isFullscreenRef: MutableRefObject;
+ maximizedRef: RefObject;
+ isFullscreenRef: RefObject;
toggleFullscreen: () => void;
toggleTrueFullscreen: () => void;
exitBigView: () => void;
@@ -327,10 +343,10 @@ export function useYtProgress({
activeId: string;
resumeAt: number;
mountRef: RefObject;
- playerRef: MutableRefObject;
- currentIdRef: MutableRefObject;
- navRef: MutableRefObject<{ queue: Video[] | undefined; index: number }>;
- modeRef: MutableRefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
+ playerRef: RefObject;
+ currentIdRef: RefObject;
+ navRef: RefObject<{ queue: Video[] | undefined; index: number }>;
+ modeRef: RefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
setWatched: (on: boolean) => void;
setPlayerError: (code: number | null) => void;
setLiveData: (d: { title?: string; author?: string } | null) => void;
@@ -340,7 +356,7 @@ export function useYtProgress({
// Positions this player session has actually STORED, per video id. Written from the server's
// answer, read back by the modal's resume so stepping next→prev inside one session does not
// rewind to the value the frozen launch queue was snapshotted with.
- sessionPosRef: MutableRefObject>;
+ sessionPosRef: RefObject>;
qc: QueryClient;
}): void {
const autoMarkedRef = useRef(false);
@@ -391,66 +407,74 @@ export function useYtProgress({
}
};
- loadYouTubeApi().then((YT) => {
- if (cancelled || !mountRef.current) return;
- playerRef.current = new YT.Player(mountRef.current, {
- width: "100%",
- height: "100%",
- videoId: id,
- playerVars: {
- autoplay: 1,
- start: resumeAt || undefined,
- rel: 0, // limit "related" to the same channel (full removal is no longer possible)
- enablejsapi: 1,
- origin: window.location.origin,
- playsinline: 1,
- // Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size).
- vq: "hd1080",
- },
- events: {
- onReady: () => {
- focusModal();
- reapplyStored();
+ loadYouTubeApi()
+ .then((YT) => {
+ if (cancelled || !mountRef.current) return;
+ playerRef.current = new YT.Player(mountRef.current, {
+ width: "100%",
+ height: "100%",
+ videoId: id,
+ playerVars: {
+ autoplay: 1,
+ start: resumeAt || undefined,
+ rel: 0, // limit "related" to the same channel (full removal is no longer possible)
+ enablejsapi: 1,
+ origin: window.location.origin,
+ playsinline: 1,
+ // Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size).
+ vq: "hd1080",
},
- onStateChange: (e: YTPlayerEvent) => {
- const p0 = playerRef.current;
- logYouTubeState(
- e?.data,
- p0 && typeof p0.getCurrentTime === "function" ? p0.getCurrentTime() : 0,
- );
- // 1 === playing → sync the navigated-to video's title/author for display.
- if (e?.data === 1) {
- errorStreakRef.current = 0; // a successful play breaks any unplayable run
- const p = playerRef.current;
- const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
- if (d) setLiveData({ title: d.title, author: d.author });
- }
- // 0 === ended → mark watched, then advance to the next queue item if any.
- if (e?.data === 0 && currentIdRef.current === id) {
- if (!autoMarkedRef.current) {
- autoMarkedRef.current = true;
- setWatched(true);
+ events: {
+ onReady: () => {
+ focusModal();
+ reapplyStored();
+ },
+ onStateChange: (e: YTPlayerEvent) => {
+ const p0 = playerRef.current;
+ logYouTubeState(
+ e?.data,
+ p0 && typeof p0.getCurrentTime === "function" ? p0.getCurrentTime() : 0,
+ );
+ // 1 === playing → sync the navigated-to video's title/author for display.
+ if (e?.data === 1) {
+ errorStreakRef.current = 0; // a successful play breaks any unplayable run
+ const p = playerRef.current;
+ const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
+ if (d) setLiveData({ title: d.title, author: d.author });
}
+ // 0 === ended → mark watched, then advance to the next queue item if any.
+ if (e?.data === 0 && currentIdRef.current === id) {
+ if (!autoMarkedRef.current) {
+ autoMarkedRef.current = true;
+ setWatched(true);
+ }
+ advanceOnEnd();
+ }
+ },
+ onError: (e: YTPlayerEvent) => {
+ logYouTubeError(typeof e?.data === "number" ? e.data : -1);
+ setPlayerError(typeof e?.data === "number" ? e.data : -1);
+ // onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
+ // auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
+ // items can't spin. We do NOT mark the broken video watched.
+ if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
+ const { autoMode: a } = modeRef.current;
+ const { queue: q } = navRef.current;
+ if (a === "off" || !q || q.length <= 1) return;
+ errorStreakRef.current += 1;
+ if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
advanceOnEnd();
- }
+ },
},
- onError: (e: YTPlayerEvent) => {
- logYouTubeError(typeof e?.data === "number" ? e.data : -1);
- setPlayerError(typeof e?.data === "number" ? e.data : -1);
- // onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
- // auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
- // items can't spin. We do NOT mark the broken video watched.
- if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
- const { autoMode: a } = modeRef.current;
- const { queue: q } = navRef.current;
- if (a === "off" || !q || q.length <= 1) return;
- errorStreakRef.current += 1;
- if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
- advanceOnEnd();
- },
- },
+ });
+ })
+ // The script never arrived (blocked, offline, timed out) or the player could not be built:
+ // an error state with the way out, never an empty stage that waits forever.
+ .catch((err: unknown) => {
+ if (cancelled) return;
+ logYouTubeApiFailed(err);
+ setPlayerError(YT_API_LOAD_FAILED);
});
- });
// The session's positions map (mutated in place, never replaced) — held here for the cleanup.
const sessionPos = sessionPosRef.current;
@@ -477,7 +501,7 @@ export function useYtProgress({
window.removeEventListener("pagehide", onHide);
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next
// natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2).
- Promise.resolve(persist()).finally(() => {
+ void Promise.resolve(persist()).finally(() => {
// Write this session's stored positions INTO the cached lists first. Marking them stale is
// not enough on its own: nothing refetches the feed until it remounts (staleTime 30s,
// refetchOnWindowFocus off), so a reopen used to resume from the position the cache was
@@ -511,6 +535,6 @@ export function useYtProgress({
//
// useEffectEvent cannot help here the way it does above: YT.Player takes its callbacks as an
// object literal, and an effect event may not be passed around as a value.
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- ONE player per video: the captured values are per-item snapshots, live ones are refs
}, [activeId]);
}
diff --git a/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx b/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx
index 4db4896e..7fa37a5a 100644
--- a/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx
+++ b/frontend/src/lib/plexPlayerHooks.controls.dom.test.tsx
@@ -26,7 +26,9 @@ describe("useControlsVisibility", () => {
it("hides 3 s after the last activity while playing", () => {
const { result } = setup();
act(() => result.current.wake());
- act(() => vi.advanceTimersByTime(3000));
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
expect(result.current.uiVisible).toBe(false);
});
@@ -36,16 +38,45 @@ describe("useControlsVisibility", () => {
result.current.wake();
result.current.holdOnHover(true);
});
- act(() => vi.advanceTimersByTime(10_000));
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
expect(result.current.uiVisible).toBe(true);
act(() => result.current.holdOnHover(false));
- act(() => vi.advanceTimersByTime(2900));
+ act(() => {
+ vi.advanceTimersByTime(2900);
+ });
expect(result.current.uiVisible).toBe(true); // a fresh 3 s from the leave, not from the wake
- act(() => vi.advanceTimersByTime(100));
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
expect(result.current.uiVisible).toBe(false);
});
+ // E28 E6 / round 2 P4: a keyboard focus in the chrome holds the bar — but only for FOCUS_HOLD_MS
+ // after the last key, not for the whole film. The 3 s timer finds the hold, re-arms for what is
+ // left of it, and hides once it is over (E32 rewrote that re-arm without recursion).
+ it("holds for a keyboard focus until 10 s after the last key, then hides", () => {
+ const { result } = setup();
+ act(() => {
+ result.current.holdOnFocus(true);
+ result.current.wake();
+ });
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(result.current.uiVisible).toBe(true); // the hold, not the 3 s
+ act(() => {
+ vi.advanceTimersByTime(6900);
+ });
+ expect(result.current.uiVisible).toBe(true); // 9.9 s after the key
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+ expect(result.current.uiVisible).toBe(false); // the hold is over, focus or not
+ });
+
// C3: on touch nothing moves a mouse, so a tap toggles the bar and playback arms the timer.
it("toggle hides a shown bar at once and shows a hidden one", () => {
const { result } = setup();
diff --git a/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx b/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx
index eb8b6017..3d7cbcd7 100644
--- a/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx
+++ b/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx
@@ -12,7 +12,7 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { createRef, type ReactNode, type MutableRefObject } from "react";
+import { createRef, type ReactNode, type RefObject } from "react";
import { usePlexTracks } from "./plexPlayerHooks";
import { api, type PlexItemDetail } from "./api";
import { dismissError, getError } from "./errorDialog";
@@ -49,7 +49,7 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}
videoRef: createRef(),
hlsRef: createRef(),
multiAudioRef: { current: false },
- audioRef: { current: 0 } as MutableRefObject,
+ audioRef: { current: 0 } as RefObject,
aoffRef: { current: 0 },
absRef: { current: abs },
loadStartRef: { current: loadStart },
@@ -325,7 +325,7 @@ function TrackHarness() {
videoRef: createRef(),
hlsRef: createRef(),
multiAudioRef: { current: false },
- audioRef: { current: 0 } as MutableRefObject,
+ audioRef: { current: 0 } as RefObject,
aoffRef: { current: 0 },
absRef: { current: 3600 },
loadStartRef: { current: 0 },
diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts
index 31d97f15..1ac466be 100644
--- a/frontend/src/lib/plexPlayerHooks.ts
+++ b/frontend/src/lib/plexPlayerHooks.ts
@@ -3,7 +3,6 @@ import {
useEffect,
useRef,
useState,
- type MutableRefObject,
type RefObject,
type WheelEvent as ReactWheelEvent,
} from "react";
@@ -113,16 +112,23 @@ export function useControlsVisibility(opts: {
if (hideTimer.current) window.clearTimeout(hideTimer.current);
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
const wokeAt = Date.now();
- const arm = (ms: number) => {
- hideTimer.current = window.setTimeout(() => {
- // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
- if (videoRef.current?.paused || menuOpenRef.current || hovered.current) return;
- const held = FOCUS_HOLD_MS - (Date.now() - wokeAt);
- if (focused.current && held > 0) return arm(held);
- setVisible(false);
- }, ms);
- };
- arm(HIDE_MS);
+ // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
+ const busy = () => !!videoRef.current?.paused || menuOpenRef.current || hovered.current;
+ // Not a timer that re-arms itself: a self-calling `arm` read to the compiler's analysis as a
+ // value from outside this callback (memo-dependencies), and a self-naming function expression
+ // tripped one of its invariants. The focus hold re-arms at most once — the second timer fires
+ // when the hold is over, so it has only `busy` left to ask.
+ hideTimer.current = window.setTimeout(() => {
+ if (busy()) return;
+ const held = FOCUS_HOLD_MS - (Date.now() - wokeAt);
+ if (focused.current && held > 0) {
+ hideTimer.current = window.setTimeout(() => {
+ if (!busy()) setVisible(false);
+ }, held);
+ return;
+ }
+ setVisible(false);
+ }, HIDE_MS);
}, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
const show = useCallback(
(visible: boolean) => {
@@ -188,8 +194,8 @@ export function usePlexProgress(opts: {
id: string;
qc: QueryClient;
videoRef: RefObject;
- absRef: MutableRefObject;
- durationRef: MutableRefObject;
+ absRef: RefObject;
+ durationRef: RefObject;
}): { settle: () => void; finish: (confirmed?: Promise) => void } {
const { id, qc, videoRef, absRef, durationRef } = opts;
const settleRef = useRef<() => void>(() => {});
@@ -341,10 +347,7 @@ export function usePlexProgress(opts: {
* answer is where the session was ASKED to start. Anything that restarts the session "here" — an
* audio switch, an A/V offset, a keyboard seek — must use this: reading `abs` restarted a resume at
* 20:00 from 0:00, and the next checkpoint saved that (deep-review round 4). */
-export function playhead(
- absRef: MutableRefObject,
- loadStartRef: MutableRefObject,
-): number {
+export function playhead(absRef: RefObject, loadStartRef: RefObject): number {
return absRef.current > 0 ? absRef.current : loadStartRef.current;
}
@@ -439,8 +442,8 @@ export function useAutoSkip(opts: {
}): {
activeMarker: PlexMarker | undefined;
skipProgress: number | null;
- skipProgressRef: MutableRefObject;
- cancelAutoSkipRef: MutableRefObject<() => void>;
+ skipProgressRef: RefObject;
+ cancelAutoSkipRef: RefObject<() => void>;
doSkip: (m: PlexMarker) => void;
} {
const {
@@ -594,12 +597,12 @@ export function usePlexTracks(opts: {
videoRef: RefObject;
hlsRef: RefObject;
multiAudioRef: RefObject;
- audioRef: MutableRefObject;
- aoffRef: MutableRefObject;
- absRef: MutableRefObject;
+ audioRef: RefObject;
+ aoffRef: RefObject;
+ absRef: RefObject;
/** The position the current (or still in-flight) session was ASKED to start at — written before
* the session request goes out, unlike `sessionK`, which only lands once it has answered. */
- loadStartRef: MutableRefObject;
+ loadStartRef: RefObject;
}): {
audioOrd: number | null;
setAudioOrd: (ord: number | null) => void;
@@ -677,7 +680,7 @@ export function usePlexTracks(opts: {
}
} else {
// Single audio (or the session has not answered yet): re-map the track server-side.
- loadSession(playhead(absRef, loadStartRef));
+ void loadSession(playhead(absRef, loadStartRef));
}
},
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
diff --git a/frontend/src/lib/useDebounced.dom.test.ts b/frontend/src/lib/useDebounced.dom.test.ts
index 3107b6a0..ebaa987b 100644
--- a/frontend/src/lib/useDebounced.dom.test.ts
+++ b/frontend/src/lib/useDebounced.dom.test.ts
@@ -16,7 +16,9 @@ describe("useDebounced", () => {
});
rerender({ v: "ab" });
expect(result.current).toBe("a");
- act(() => vi.advanceTimersByTime(300));
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
expect(result.current).toBe("ab");
});
@@ -29,7 +31,9 @@ describe("useDebounced", () => {
expect(result.current).toBe("boom");
rerender({ v: "boomx", s: 1 });
expect(result.current).toBe("boom");
- act(() => vi.advanceTimersByTime(300));
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
expect(result.current).toBe("boomx");
});
});
diff --git a/frontend/src/lib/usePref.dom.test.ts b/frontend/src/lib/usePref.dom.test.ts
index 05c5bc67..072e9a55 100644
--- a/frontend/src/lib/usePref.dom.test.ts
+++ b/frontend/src/lib/usePref.dom.test.ts
@@ -143,7 +143,9 @@ describe("usePref", () => {
// Local state is instant; the server write is deferred.
expect(result.current[0]).toBe(30);
expect(savePrefs).not.toHaveBeenCalled();
- act(() => vi.advanceTimersByTime(300));
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
expect(savePrefs).toHaveBeenCalledTimes(1);
expect(savePrefs).toHaveBeenCalledWith({ vol: 30 });
});
diff --git a/frontend/src/lib/usePref.ts b/frontend/src/lib/usePref.ts
index e081dee8..bbd69f49 100644
--- a/frontend/src/lib/usePref.ts
+++ b/frontend/src/lib/usePref.ts
@@ -124,10 +124,10 @@ export function usePref(
pendingSave.current = flush;
saveTimer.current = window.setTimeout(() => {
pendingSave.current = null;
- flush();
+ void flush();
}, debounceMs);
} else {
- flush();
+ void flush();
}
},
[qc, serverKey, localKey, debounceMs],
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
index 3b6092e4..3cf0c554 100644
--- a/frontend/tsconfig.node.json
+++ b/frontend/tsconfig.node.json
@@ -24,21 +24,24 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
- // The build-tool configs include two hand-written .js files (tailwind/postcss). checkJs runs
- // the type checker over them via their `@type` JSDoc, so a bad tailwind/postcss option is
- // caught by the gate instead of silently mis-building CSS. Was the last hole in C-4.5.
+ // The build-tool configs include a hand-written .js file (postcss; tailwind 4 needs no config
+ // file, and the stale `tailwind.config.js` entry pointed at nothing until E32). checkJs runs
+ // the type checker over it via its `@type` JSDoc, so a bad option is caught by the gate instead
+ // of silently mis-building CSS. Was the last hole in C-4.5.
"allowJs": true,
"checkJs": true
},
// reactPlugin.ts is the ONE React transform both configs import (E27 S1) — it is build tooling,
// not app source, so it belongs here and not under tsconfig.json's ["src"]. A file imported by a
- // listed one but absent from `include` is TS6307, not a silent pass.
+ // listed one but absent from `include` is TS6307, not a silent pass. playwright.config.ts is
+ // here as well as in e2e/tsconfig.json because eslint's typed rules (E32) find a file's project
+ // through tsconfig.json's `references`, which name this project and not the e2e one.
"include": [
"vite.config.ts",
"vitest.config.ts",
"reactPlugin.ts",
"devOnlyGuard.ts",
- "tailwind.config.js",
+ "playwright.config.ts",
"postcss.config.js"
]
}
From cff8fe9a6ae6d798752684e1b096ca1c7c404526 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 16:38:46 +0200
Subject: [PATCH 06/54] test(frontend): a React Compiler ratchet over
everything the build ships
compilerCoverage.contract.test.ts used to check two files. It now runs the
real compiler over all of src (minus tests) and holds the exact set it skips
today, file -> function -> category: 93 functions. A new skip or a category
change is red, and so is a listed skip that has gone, so the list only
shrinks. Channels and DataTable must also show up as compiled, so a harness
that compiled nothing cannot pass.
Measured against the audit's scan: no function this sprint touched started
to skip. PlexPersonView and useOverlayOrder now compile.
reactPlugin.ts claimed the compiler's rules were linted, which was false
while react-hooks/refs was off. That comment and the refs block in
eslint.config.js now say what holds the line and why refs is still off:
245 real hits, pinned by the ratchet, burned down from S3.
---
frontend/eslint.config.js | 18 +-
frontend/reactPlugin.ts | 10 +-
.../compilerCoverage.contract.test.ts | 190 ++++++++++++------
.../src/components/compilerSkips.testkit.ts | 124 ++++++++++++
4 files changed, 274 insertions(+), 68 deletions(-)
create mode 100644 frontend/src/components/compilerSkips.testkit.ts
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index cf5cc369..4fd99ee6 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -569,15 +569,15 @@ export default tseslint.config(
// bulk `--suppress-all` would hide the next violation instead of
// answering it, and it cannot hold a warning-level rule anyway.
//
- // `refs` is off because it fires on this app's popover architecture, not on its history, so
- // suppressing it would not help the next popover anyone writes. Measured over the 217:
- // 79 are `pop.refs.setReference` / `getReferenceProps` — @floating-ui/react's callback-ref
- // setters, which are the documented way to use it — and 74 more are passing the popover handle
- // down as a prop (`popover={pop}`, `ref={fade.ref}`), which is what `components/ui`'s popover
- // engine is FOR. That is 153 of 217 flagged for having the word `refs` in an object that is not
- // a React ref. The remaining ~64 are genuine render-phase `someRef.current = x` writes and are
- // real (E27 S2 answered the ones the other rules could see, at their sites).
- // Re-evaluate when the rule can: it shipped in 1.0.0 and this looks like over-approximation.
+ // `refs` is OFF — the one rule here switched off while it has real hits, and recorded as such
+ // in docs/gates.md. E27 turned it off calling 153 of its 217 hits false positives (the popover
+ // engine's `pop.refs.setReference` / `getReferenceProps()`, a popover handle passed as a prop)
+ // and the other ~64 real render-phase `someRef.current = x` writes. The E32 audit showed the
+ // triage was wrong: the rule and the React Compiler share one analysis, so every one of those
+ // "false positives" is a component the compiler SKIPS — 21 popover components among them, and
+ // Channels' 0.9 s row menu was the bill. It stays off only because 245 hits (measured E32 S1) cannot be fixed in
+ // one sprint; until they are, compilerCoverage.contract.test.ts pins every skipped function
+ // (none may be added), and E32 S3+ burns them down to turn this back on.
"react-hooks/refs": "off",
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
diff --git a/frontend/reactPlugin.ts b/frontend/reactPlugin.ts
index ce3ddc66..8a1f2843 100644
--- a/frontend/reactPlugin.ts
+++ b/frontend/reactPlugin.ts
@@ -11,8 +11,14 @@ import reactCompiler from "babel-plugin-react-compiler";
//
// The React Compiler auto-memoises: it infers what `useCallback`/`useMemo` were being hand-written
// for and applies the equivalent itself. It is enabled repo-wide with no opt-out list because it
-// bails out on its own for anything it cannot prove safe (its rules are also linted, via
-// eslint-plugin-react-hooks). `target` follows the installed React major and must move with it.
+// bails out on its own for anything it cannot prove safe — SILENTLY: `panicThreshold` is "none" and
+// there is no logger here, so a skipped component still works and simply re-renders everything under
+// it. This comment used to say "its rules are also linted"; it was false while `react-hooks/refs` was
+// off, and 95 functions sat uncompiled behind it (E32). What actually holds the line now is
+// src/components/compilerCoverage.contract.test.ts, which runs THIS compiler over the shipped source
+// and fails on any skip not on its list; the react-hooks lint rules are the early warning, with the
+// one still switched off recorded in docs/gates.md. `target` follows the installed React major and
+// must move with it.
//
// The plugin is IMPORTED rather than named by the usual `"babel-plugin-react-compiler"` string. Two
// reasons, both about the gate: a string is invisible to knip, which then reports the dependency as
diff --git a/frontend/src/components/compilerCoverage.contract.test.ts b/frontend/src/components/compilerCoverage.contract.test.ts
index 2cd61f43..3950bdbe 100644
--- a/frontend/src/components/compilerCoverage.contract.test.ts
+++ b/frontend/src/components/compilerCoverage.contract.test.ts
@@ -1,66 +1,142 @@
-import { readFileSync } from "node:fs";
-import { transformSync } from "@babel/core";
-import reactCompiler from "babel-plugin-react-compiler";
import { describe, expect, it } from "vitest";
+import { compileReport, shippedSources, type CompileReport } from "./compilerSkips.testkit";
// The React Compiler bails out of a component it cannot prove safe WITHOUT a word: the component
-// still works, it just re-renders everything under it on every render. Its main tripwire, a ref read
-// or written during render, is not linted here (`react-hooks/refs` is off, see eslint.config.js), so
-// nothing caught it. The channel manager is where it hurt (the owner, 2026-10-03): Channels and
-// DataTable both bailed out, every open, close and reposition of a row's right-click menu rebuilt
-// the rows and columns, and on "All" rows the menu took ~0.9 s to open and ~0.6 s to close.
+// still works, it just re-renders everything under it on every render. The channel manager is where
+// it hurt (the owner, 2026-10-03): Channels and DataTable both bailed out, every open, close and
+// reposition of a row's right-click menu rebuilt the rows and columns, and on "All" rows the menu
+// took ~0.9 s to open and ~0.6 s to close. The E32 audit then found 95 such functions across src.
//
-// This runs the real compiler (the options reactPlugin.ts gives it) over the files that must stay
-// compiled and fails on any function it skips, with the compiler's own reason.
-const MUST_COMPILE = ["components/Channels.tsx", "components/ui/DataTable.tsx"];
+// A RATCHET over everything the build compiles (E32 S1). KNOWN_SKIPS is the exact set the compiler
+// skips today, `file → function → the compiler's category`; everything else must compile. Both
+// directions are red:
+// • a NEW skip (or a known one changing category) — fix the function; if it truly cannot be
+// compiled, adding it here is a decision for the owner, with the reason in docs/gates.md;
+// • a skip that is GONE — delete its entry, so the list only ever shrinks.
+// E32 S3+ burns it down: the popover engine (Refs: `getReferenceProps()` / `refs.setReference`
+// passed during render), render-phase ref writes, try/finally (Todo), the two exhaustive-deps
+// suppressions. The end state is IncompatibleLibrary alone (VirtualGrid on TanStack Virtual).
+const KNOWN_SKIPS: CompileReport["skipped"] = {
+ "components/AboutCell.tsx": { AboutCell: "Refs" },
+ "components/ActiveFilterChips.tsx": { OverflowPopover: "Refs" },
+ "components/AddToPlaylist.tsx": { AddToPlaylist: "Refs", MembershipPanel: "Todo" },
+ "components/BootGate.tsx": { BootGate: "Refs" },
+ "components/ChannelStatsPopover.tsx": { ChannelStatsPopover: "Refs" },
+ "components/ChannelTags.tsx": {
+ ChannelTagsControl: "Refs",
+ SuggestedChipsLane: "Refs",
+ TagEditorMenu: "Refs",
+ },
+ "components/ChannelsProvider.tsx": { ChannelsProvider: "Refs" },
+ "components/ChatThread.tsx": { ChatThread: "Refs" },
+ "components/ConfigPanel.tsx": { ConfigPanel: "Refs" },
+ "components/DateFilterSelect.tsx": { DateFilterSelect: "Refs" },
+ "components/DownloadCenter.tsx": { DownloadCenter: "Todo" },
+ "components/DownloadDialog.tsx": { DownloadDialog: "Todo" },
+ "components/Feed.tsx": { Feed: "Immutability" },
+ "components/IssueComposer.tsx": { Attachments: "Immutability", ComposerBody: "Refs" },
+ "components/KeyGate.tsx": { KeyGate: "Todo" },
+ "components/LanguageSwitcher.tsx": { LanguageSwitcher: "Refs" },
+ "components/Messages.tsx": { ConversationList: "Refs" },
+ "components/NavSidebar.tsx": { NavSidebar: "Refs" },
+ "components/NavigationProvider.tsx": { NavigationProvider: "Refs" },
+ "components/OnboardingWizard.tsx": { OnboardingWizard: "Refs" },
+ "components/PersonalViewsModal.tsx": { PersonalViewsModal: "Refs" },
+ "components/PlayerModal.tsx": { PlayerModal: "Todo" },
+ "components/PlaylistLimitsHint.tsx": { PlaylistLimitsHint: "Refs" },
+ "components/Playlists.tsx": { Playlists: "Immutability" },
+ "components/PlaylistsRail.tsx": { PlaylistsRail: "Todo" },
+ "components/PlexBrowse.tsx": { PlexBrowse: "Immutability" },
+ "components/PlexCollectionEditor.tsx": { PlexCollectionEditor: "Todo" },
+ "components/PlexCollectionView.tsx": { PlexCollectionView: "Todo" },
+ "components/PlexPlayer.tsx": { PlexPlayer: "Suppression" },
+ "components/PlexPlaylistAdd.tsx": { PlexPlaylistAdd: "Todo" },
+ "components/PlexPlaylistView.tsx": { PlexPlaylistView: "Refs" },
+ "components/PlexStatsOverlay.tsx": { PlexStatsOverlay: "Todo" },
+ "components/SavedViewsPopover.tsx": { SavedViewsPopover: "Refs" },
+ "components/SavedViewsProvider.tsx": { SavedViewsProvider: "Todo" },
+ "components/SettingsPanel.tsx": { SignInMethods: "Todo" },
+ "components/SetupWizard.tsx": { SetupWizard: "Todo" },
+ "components/SyncStatus.tsx": { SyncStatus: "Refs" },
+ "components/TagFilterChips.tsx": { TagFilterChips: "Refs" },
+ "components/TagManager.tsx": { TagManager: "Refs", TagRow: "Refs" },
+ "components/UpNextCard.tsx": { UpNextCard: "Refs" },
+ "components/UploadDialog.tsx": { UploadDialog: "Todo" },
+ "components/VideoEditor.tsx": { VideoEditor: "Refs" },
+ "components/ViewSwitcher.tsx": { ViewSwitcher: "Refs" },
+ "components/WatchPage.tsx": { WatchPage: "Todo", useMediaSession: "Todo" },
+ "components/Welcome.tsx": { AuthCard: "Todo", Lightbox: "Refs" },
+ "components/YearFilter.tsx": { YearFilter: "Refs" },
+ "components/plexViews.tsx": { PlexSeasonView: "Todo", PlexShowView: "Todo" },
+ "components/ui/ActionMenu.tsx": { ActionMenu: "Refs" },
+ "components/ui/AnchoredPopover.tsx": { PopoverSurfaceInner: "Refs" },
+ "components/ui/Combobox.tsx": { Combobox: "Refs" },
+ "components/ui/FilterSelect.tsx": { FilterSelect: "Refs" },
+ "components/ui/FocusSafeButton.tsx": { FocusSafeButton: "Refs" },
+ "components/ui/Modal.tsx": { Modal: "Refs" },
+ "components/ui/NavRail.tsx": { NavRailGroup: "Refs" },
+ "components/ui/PageScroller.tsx": { PageScroller: "Refs" },
+ "components/ui/PriorityStepper.tsx": { PriorityStepper: "Refs" },
+ "components/ui/SidePanel.tsx": { SidePanel: "Refs" },
+ "components/ui/SortControl.tsx": { SortControl: "Refs" },
+ "components/ui/Toaster.tsx": { Toaster: "Globals" },
+ "components/ui/Tooltip.tsx": { Tooltip: "Refs" },
+ "components/ui/VirtualGrid.tsx": { VirtualGrid: "IncompatibleLibrary" },
+ "components/ui/anchoredPopoverHooks.ts": { useAnchoredPopover: "Refs" },
+ "components/ui/sortableA11y.ts": { useSortableA11y: "Immutability" },
+ "components/ui/tableHeader.tsx": { FilterFunnel: "Refs", FilterOptionList: "Refs" },
+ "components/useDebugRecording.ts": { useDebugRecording: "Todo" },
+ "components/useDownloadActions.tsx": { useDownloadActions: "Todo" },
+ "components/usePlaylistActions.tsx": { usePlaylistActions: "Todo" },
+ "components/usePlexPlaylistEdits.ts": { usePlexPlaylistEdits: "Todo" },
+ "lib/history.ts": { useBackToClose: "Refs", useBackToExit: "Refs", useHistorySubview: "Refs" },
+ "lib/keymap.ts": { useGlobalKeymap: "Refs" },
+ "lib/layerStack.ts": { useLayer: "Refs" },
+ "lib/listNav.ts": { useListNav: "Refs" },
+ "lib/playerModalHooks.ts": { useYtBigView: "Refs", useYtProgress: "Suppression" },
+ "lib/plexPlayerHooks.ts": { useAutoSkip: "Refs", usePlexTracks: "Todo" },
+ "lib/useChannelPriority.ts": { useChannelPriority: "Refs" },
+ "lib/useChipOverflowFit.ts": { useChipOverflowFit: "Refs" },
+ "lib/useDismiss.ts": { useDismiss: "Refs" },
+ "lib/usePref.ts": { usePref: "Refs" },
+ "lib/useUndoable.ts": { useUndoable: "Refs" },
+ "lib/useUrlMirror.ts": { useUrlMirror: "Refs" },
+};
-function bailouts(path: string): string[] {
- const source = readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
- const found: string[] = [];
- transformSync(source, {
- filename: path,
- babelrc: false,
- configFile: false,
- parserOpts: { plugins: ["jsx", "typescript"] },
- plugins: [
- [
- reactCompiler,
- {
- target: "19",
- logger: {
- logEvent: (
- _file: string | null,
- e: { kind: string; fnLoc?: unknown; detail?: unknown },
- ) => {
- if (
- e.kind !== "CompileError" &&
- e.kind !== "CompileSkip" &&
- e.kind !== "PipelineError"
- )
- return;
- const loc = e.fnLoc as { start?: { line?: number } } | undefined;
- const d = e.detail as
- | { options?: { reason?: string; details?: { message?: string }[] } }
- | { reason?: string; details?: { message?: string }[] }
- | undefined;
- const o =
- d && "options" in d
- ? d.options
- : (d as { reason?: string; details?: { message?: string }[] });
- found.push(
- `line ${loc?.start?.line}: ${o?.details?.[0]?.message ?? o?.reason ?? e.kind}`,
- );
- },
- },
- },
- ],
- ],
- });
- return found;
-}
+// The files a regression hurt most: compiled, and they must stay so (a positive check too, so a
+// harness that compiled nothing could not pass as "no skips").
+const MUST_COMPILE: Record = {
+ "components/Channels.tsx": ["Channels"],
+ "components/ui/DataTable.tsx": ["DataTable"],
+};
+
+type Row = [file: string, fn: string, category: string];
+const rows = (skips: CompileReport["skipped"]): Row[] =>
+ Object.entries(skips).flatMap(([file, fns]) =>
+ Object.entries(fns).map(([fn, category]): Row => [file, fn, category]),
+ );
describe("the React Compiler", () => {
- it.each(MUST_COMPILE)("compiles every function in %s", (path) => {
- expect(bailouts(path)).toEqual([]);
+ // ~8 s for the whole tree, once, shared by the checks below.
+ const report = compileReport(shippedSources());
+ const actual = rows(report.skipped);
+ const known = rows(KNOWN_SKIPS);
+ const key = ([f, fn]: Row) => `${f} → ${fn}`;
+
+ it("skips nothing new (fix it, or the owner decides to list it)", () => {
+ const knownAs = new Map(known.map((r) => [key(r), r[2]]));
+ expect(
+ actual.filter((r) => knownAs.get(key(r)) !== r[2]).map((r) => `${key(r)}: ${r[2]}`),
+ ).toEqual([]);
+ });
+
+ it("lists no skip that has gone (delete it — the list only shrinks)", () => {
+ const actualAs = new Map(actual.map((r) => [key(r), r[2]]));
+ expect(known.filter((r) => !actualAs.has(key(r))).map(key)).toEqual([]);
+ });
+
+ it.each(Object.entries(MUST_COMPILE))("compiles %s", (file, fns) => {
+ expect(report.skipped[file] ?? {}).toEqual({});
+ for (const fn of fns) expect(report.compiled[file]).toContain(fn);
});
});
diff --git a/frontend/src/components/compilerSkips.testkit.ts b/frontend/src/components/compilerSkips.testkit.ts
new file mode 100644
index 00000000..d42731e3
--- /dev/null
+++ b/frontend/src/components/compilerSkips.testkit.ts
@@ -0,0 +1,124 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { parseSync, transformSync, type types as t } from "@babel/core";
+import reactCompiler from "babel-plugin-react-compiler";
+
+// Runs the real React Compiler — the options reactPlugin.ts gives it — over the shipped source and
+// reports every function it SKIPS, keyed `file → function → category`. The compiler skips without a
+// word (`panicThreshold` is "none" and the build has no logger), so this is the only place a skip is
+// visible at all. Used by compilerCoverage.contract.test.ts.
+
+const SRC = new URL("../", import.meta.url);
+
+/** Every file the production build compiles: src, minus tests and test kits. */
+export function shippedSources(): string[] {
+ return (readdirSync(SRC, { recursive: true }) as string[])
+ .map((p) => p.split("\\").join("/"))
+ .filter(
+ (p) => /\.(ts|tsx)$/.test(p) && !/\.(test|testkit)\.tsx?$/.test(p) && !p.endsWith(".d.ts"),
+ )
+ .sort();
+}
+
+type Node = t.Node & { [k: string]: unknown };
+const isFn = (n: Node) =>
+ n.type === "FunctionDeclaration" ||
+ n.type === "FunctionExpression" ||
+ n.type === "ArrowFunctionExpression";
+
+/** The name a reader would look a function up by: its own, the variable it is assigned to (through
+ * a `memo(…)` / `forwardRef(…)` wrapper), or `default` for an anonymous default export. */
+function functionNames(ast: t.File): Map {
+ const names = new Map();
+ const walk = (node: Node, parents: Node[]) => {
+ if (isFn(node) && node.loc) {
+ let name = (node as t.FunctionDeclaration).id?.name ?? null;
+ for (let i = parents.length - 1; !name && i >= 0; i--) {
+ const p = parents[i]!;
+ if (p.type === "VariableDeclarator" && p.id.type === "Identifier") name = p.id.name;
+ else if (p.type === "ExportDefaultDeclaration") name = "default";
+ else if (p.type !== "CallExpression" && p.type !== "TSAsExpression") break;
+ }
+ names.set(
+ `${node.loc.start.line}:${node.loc.start.column}`,
+ name ?? ``,
+ );
+ }
+ for (const key of Object.keys(node)) {
+ if (key === "loc" || key === "leadingComments" || key === "trailingComments") continue;
+ const v = node[key];
+ if (Array.isArray(v)) {
+ for (const c of v)
+ if (c && typeof c === "object" && "type" in c) walk(c as Node, [...parents, node]);
+ } else if (v && typeof v === "object" && "type" in v) walk(v as Node, [...parents, node]);
+ }
+ };
+ walk(ast as unknown as Node, []);
+ return names;
+}
+
+type Detail = {
+ category?: string;
+ reason?: string;
+ options?: { category?: string; reason?: string };
+};
+
+export interface CompileReport {
+ /** file → function → the compiler's category for the skip (Refs, Todo, Immutability, …). */
+ skipped: Record>;
+ /** file → the functions it did compile. */
+ compiled: Record;
+}
+
+export function compileReport(files: string[]): CompileReport {
+ const skipped: CompileReport["skipped"] = {};
+ const compiled: CompileReport["compiled"] = {};
+ for (const file of files) {
+ const source = readFileSync(new URL(file, SRC), "utf8");
+ const opts = { filename: file, babelrc: false, configFile: false } as const;
+ const ast = parseSync(source, { ...opts, parserOpts: { plugins: ["jsx", "typescript"] } });
+ if (!ast) throw new Error(`could not parse ${file}`);
+ const names = functionNames(ast);
+ const nameAt = (loc: t.SourceLocation | null) =>
+ loc
+ ? (names.get(`${loc.start.line}:${loc.start.column}`) ?? ``)
+ : "";
+ transformSync(source, {
+ ...opts,
+ parserOpts: { plugins: ["jsx", "typescript"] },
+ plugins: [
+ [
+ reactCompiler,
+ {
+ target: "19",
+ logger: {
+ logEvent: (
+ _f: string | null,
+ e: {
+ kind: string;
+ fnLoc?: t.SourceLocation | null;
+ detail?: Detail;
+ reason?: string;
+ },
+ ) => {
+ if (e.kind === "CompileSuccess") {
+ (compiled[file] ??= []).push(nameAt(e.fnLoc ?? null));
+ return;
+ }
+ let category: string;
+ if (e.kind === "CompileError")
+ category = e.detail?.options?.category ?? e.detail?.category ?? "Unknown";
+ else if (e.kind === "CompileSkip") category = `Skip: ${e.reason}`;
+ else if (e.kind === "PipelineError") category = "PipelineError";
+ else return;
+ const fns = (skipped[file] ??= {});
+ // The first reason is the one that stopped it; later ones are the same function.
+ fns[nameAt(e.fnLoc ?? null)] ??= category;
+ },
+ },
+ },
+ ],
+ ],
+ });
+ }
+ return { skipped, compiled };
+}
From 7ed2ac4e9540eb3b6e9b341ca71a053c1a9fa834 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 16:44:28 +0200
Subject: [PATCH 07/54] build(frontend): knip also checks what ships; strict
indexing in every project
`npm run knip` now runs a second, --production pass, which asks whether the
code that ships uses an export, not just whether anything does. Of the 47
exports only tests reached:
- 34 are exported for their test and say so with @internal, naming the test.
- 11 were module-private names with a stray `export`. Two of them were not
used at all and are deleted.
- The unused alreadyReported re-export is gone.
Test kits, the e2e suite and the two build-tooling modules are excluded from
the production project, each named in knip.jsonc.
noUncheckedIndexedAccess is now on for the node and e2e tsconfigs too (0
errors), matching the app project. tsconfig.node.json listed a
tailwind.config.js that has not existed since Tailwind 4. That entry is now
playwright.config.ts, which the typed lint finds through this project.
---
frontend/e2e/tsconfig.json | 2 ++
frontend/knip.json | 7 -------
frontend/knip.jsonc | 20 +++++++++++++++++++
frontend/package.json | 2 +-
frontend/src/components/channelColumns.tsx | 1 +
frontend/src/components/plexDetailAside.tsx | 1 +
frontend/src/components/ui/CommandPalette.tsx | 2 +-
frontend/src/components/ui/FilterSelect.tsx | 1 +
frontend/src/components/ui/Skeleton.tsx | 2 +-
frontend/src/components/ui/progressiveRows.ts | 3 ++-
frontend/src/components/ui/readAhead.ts | 4 ++++
frontend/src/components/ui/scrollFold.ts | 4 +++-
frontend/src/components/ui/surfaces.ts | 2 --
frontend/src/design-lab/labTokens.ts | 3 +--
frontend/src/lib/activeAccount.ts | 1 +
frontend/src/lib/api.ts | 10 ++--------
frontend/src/lib/api/discover.ts | 4 ++--
frontend/src/lib/api/plex.ts | 2 +-
frontend/src/lib/api/tagModes.ts | 7 +++++--
frontend/src/lib/api/tags.ts | 2 +-
frontend/src/lib/colorMath.ts | 1 +
frontend/src/lib/format.ts | 1 +
frontend/src/lib/issueDraft.ts | 1 +
frontend/src/lib/jobResult.ts | 1 +
frontend/src/lib/keymap.ts | 1 +
frontend/src/lib/layerStack.ts | 2 ++
frontend/src/lib/listPaging.ts | 5 ++++-
frontend/src/lib/log.ts | 7 ++++++-
frontend/src/lib/notifications.ts | 1 +
frontend/src/lib/playerModalHooks.ts | 1 +
frontend/src/lib/playerVolume.ts | 5 ++++-
frontend/src/lib/plexStats.ts | 8 ++++++++
frontend/src/lib/useFocusKeeper.ts | 1 +
frontend/tsconfig.node.json | 2 ++
34 files changed, 84 insertions(+), 33 deletions(-)
delete mode 100644 frontend/knip.json
create mode 100644 frontend/knip.jsonc
diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json
index 246d917f..81e33f9c 100644
--- a/frontend/e2e/tsconfig.json
+++ b/frontend/e2e/tsconfig.json
@@ -11,6 +11,8 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
+ // E32: an index read is `T | undefined` here, as it really is (0 errors when switched on).
+ "noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
diff --git a/frontend/knip.json b/frontend/knip.json
deleted file mode 100644
index 03dea5bc..00000000
--- a/frontend/knip.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "$schema": "https://unpkg.com/knip@6/schema.json",
- "playwright": {
- "config": ["playwright.config.ts"],
- "entry": ["e2e/**/*.@(spec|setup).ts"]
- }
-}
diff --git a/frontend/knip.jsonc b/frontend/knip.jsonc
new file mode 100644
index 00000000..7fa7cc10
--- /dev/null
+++ b/frontend/knip.jsonc
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://unpkg.com/knip@6/schema.json",
+ // `--production` (E32) asks a second question besides "is this used?": "is it used by the code
+ // that SHIPS?" — an export only a test reaches is either dead in the bundle or exported for its
+ // test, which then says so with a JSDoc `@internal` tag (knip skips those in production mode).
+ // What is not production code at all is excluded from the production project with a `!…!`
+ // pattern: the test kits, the e2e suite, and the two build-tooling modules the vite config
+ // imports (they shape the bundle but are not in it).
+ "project": [
+ "**/*.{ts,tsx,js}!",
+ "!**/*.testkit.ts!",
+ "!e2e/**!",
+ "!reactPlugin.ts!",
+ "!devOnlyGuard.ts!",
+ ],
+ "playwright": {
+ "config": ["playwright.config.ts"],
+ "entry": ["e2e/**/*.@(spec|setup).ts"],
+ },
+}
diff --git a/frontend/package.json b/frontend/package.json
index 64620f6a..282b942f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,7 +14,7 @@
"typecheck:e2e": "tsc --noEmit -p e2e/tsconfig.json",
"build": "npm run typecheck:app && npm run typecheck:node && vite build",
"preview": "vite preview",
- "knip": "knip",
+ "knip": "knip && knip --production",
"lint": "eslint . --max-warnings 0",
"format": "prettier --write .",
"format:check": "prettier --check .",
diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx
index 35a5f466..32e4bc1e 100644
--- a/frontend/src/components/channelColumns.tsx
+++ b/frontend/src/components/channelColumns.tsx
@@ -43,6 +43,7 @@ export function applySyncFilter(rows: ManagedChannel[], value: unknown): Managed
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
+/** @internal exported for channelColumns.dom.test.tsx */
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
return values.some((v) => c.tag_ids.includes(Number(v)));
diff --git a/frontend/src/components/plexDetailAside.tsx b/frontend/src/components/plexDetailAside.tsx
index 3c312a76..0696bae9 100644
--- a/frontend/src/components/plexDetailAside.tsx
+++ b/frontend/src/components/plexDetailAside.tsx
@@ -184,6 +184,7 @@ export function DetailKeywords({
// The crew shows its first CREW_SHOWN names and a "Show N more" that opens the rest: the column beside the
// text is the hero's tallest part, and twelve names ran it ~170px past the text (E31, the owner's call).
// A list only a little longer than that shows whole: a toggle that hides one or two names saves no room.
+/** @internal exported for PlexInfo.dom.test.tsx */
export const CREW_SHOWN = 6;
const CREW_FOLD_OVER = CREW_SHOWN + 2;
diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx
index 2cdee2fc..05adc686 100644
--- a/frontend/src/components/ui/CommandPalette.tsx
+++ b/frontend/src/components/ui/CommandPalette.tsx
@@ -19,7 +19,7 @@ import { fieldClass } from "./fieldStyles";
// and playlists. On the shell it reuses `Modal` in its `top` placement — same layer stack, Escape,
// focus restore and Back-to-close as every other dialog.
-export interface CommandItem {
+interface CommandItem {
/** Unique across the whole palette; also the React key. */
id: string;
label: string;
diff --git a/frontend/src/components/ui/FilterSelect.tsx b/frontend/src/components/ui/FilterSelect.tsx
index d310cc06..f3ad40a9 100644
--- a/frontend/src/components/ui/FilterSelect.tsx
+++ b/frontend/src/components/ui/FilterSelect.tsx
@@ -13,6 +13,7 @@ import { SURFACE } from "./surfaces";
// Built on the same AnchoredPopover platform as ActionMenu/Combobox (arrows/typeahead/Escape/anchor-flip);
// reusable anywhere a small, known set of options currently renders as a chip group. See FilterSelect.dom.test.
+/** @internal exported for FilterSelect.dom.test.tsx */
export interface FilterSelectOption {
value: string;
label: string;
diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx
index a481a499..5c1576ff 100644
--- a/frontend/src/components/ui/Skeleton.tsx
+++ b/frontend/src/components/ui/Skeleton.tsx
@@ -7,7 +7,7 @@ import { SURFACE } from "./surfaces";
// paragraph's, for prose that arrives after its page). The
// shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role.
-export function Skeleton({ className, inline = false }: { className?: string; inline?: boolean }) {
+function Skeleton({ className, inline = false }: { className?: string; inline?: boolean }) {
// Inline: a span, for a bone inside phrasing content (a chip is a button, which may not hold a div).
const Tag = inline ? "span" : "div";
return (
diff --git a/frontend/src/components/ui/progressiveRows.ts b/frontend/src/components/ui/progressiveRows.ts
index d420c3bf..d3e39d50 100644
--- a/frontend/src/components/ui/progressiveRows.ts
+++ b/frontend/src/components/ui/progressiveRows.ts
@@ -12,8 +12,9 @@ import { startTransition, useEffect, useState } from "react";
//
// Not virtualization: every row still ends up in the DOM, so browser find, keyboard nav past the screen,
// and the Back focus/scroll restore (which wait for the list to grow) keep working as they did.
+/** @internal exported for DataTable.progressive.dom.test.tsx */
export const FIRST_ROWS = 30;
-export const STEP_ROWS = 40;
+const STEP_ROWS = 40;
// `view` names what the rows show (page, size, sort, filters). A new view starts over from the first
// rows, so the urgent render after a click or a key stays small. New data for the same view (a refetch)
diff --git a/frontend/src/components/ui/readAhead.ts b/frontend/src/components/ui/readAhead.ts
index 701adcdc..817eda1d 100644
--- a/frontend/src/components/ui/readAhead.ts
+++ b/frontend/src/components/ui/readAhead.ts
@@ -8,12 +8,16 @@
// - ceiling MAX_LOOKAHEAD_ROWS: a tall window renders many rows, and an uncapped lookahead would
// eagerly prefetch several pages on every mount / filter change — wasted work on the app's
// heaviest query (the feed). The cap bounds that while still buffering a couple of screens ahead.
+/** @internal exported for readAhead.test.ts */
export const PREFETCH_ROWS = 4;
+/** @internal exported for readAhead.test.ts */
export const PREFETCH_VIEWPORTS = 2;
+/** @internal exported for readAhead.test.ts */
export const MAX_LOOKAHEAD_ROWS = 40;
/** How many rows ahead of the last rendered row to keep buffered, for a viewport rendering
* `renderedRows` rows. Clamped to [PREFETCH_ROWS, MAX_LOOKAHEAD_ROWS]. */
+/** @internal exported for readAhead.test.ts */
export function readAheadLookahead(renderedRows: number): number {
return Math.min(MAX_LOOKAHEAD_ROWS, Math.max(PREFETCH_ROWS, renderedRows * PREFETCH_VIEWPORTS));
}
diff --git a/frontend/src/components/ui/scrollFold.ts b/frontend/src/components/ui/scrollFold.ts
index 7a0aee54..11e08800 100644
--- a/frontend/src/components/ui/scrollFold.ts
+++ b/frontend/src/components/ui/scrollFold.ts
@@ -10,9 +10,10 @@ import { CONTENT_ID } from "./landmarks";
// back (`room - saving > FOLD_AT`): a page barely taller than the window would otherwise fold, lose its
// scroll, land at the top, unfold, and so on, with every wheel tick;
// - unfold only at the very top, not at the fold point: between the two the header keeps whatever it was.
+/** @internal exported for scrollFold.test.ts */
export const FOLD_AT = 32;
-export interface ScrollPosition {
+interface ScrollPosition {
// The scroller's scrollTop.
top: number;
// How far it can scroll at all: scrollHeight − clientHeight.
@@ -20,6 +21,7 @@ export interface ScrollPosition {
}
// `saving` = the height the header gives back when it folds (measured while it is unfolded).
+/** @internal exported for scrollFold.test.ts */
export function nextFolded(
folded: boolean,
{ top, room }: ScrollPosition,
diff --git a/frontend/src/components/ui/surfaces.ts b/frontend/src/components/ui/surfaces.ts
index 403442f6..acc5f2b7 100644
--- a/frontend/src/components/ui/surfaces.ts
+++ b/frontend/src/components/ui/surfaces.ts
@@ -28,5 +28,3 @@ export const SURFACE = {
/** A full-width sticky band (the design lab's header): square. */
band: "glass",
} as const;
-
-export type SurfaceRole = keyof typeof SURFACE;
diff --git a/frontend/src/design-lab/labTokens.ts b/frontend/src/design-lab/labTokens.ts
index a58c2f9e..ea28b967 100644
--- a/frontend/src/design-lab/labTokens.ts
+++ b/frontend/src/design-lab/labTokens.ts
@@ -6,9 +6,8 @@
/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode (index.css --edge-strong). */
export const STRONG = "var(--edge-strong)";
-/** B3 state layers (index.css utilities). */
+/** B3 state layer (an index.css utility). */
export const LAYER = "state-layer";
-export const FILLED = "state-filled";
/** B9: the header + fade edge (index.css utilities; the host is positioned). */
export const FADE_BELOW = "fade-below";
diff --git a/frontend/src/lib/activeAccount.ts b/frontend/src/lib/activeAccount.ts
index c57493ff..3f196d3f 100644
--- a/frontend/src/lib/activeAccount.ts
+++ b/frontend/src/lib/activeAccount.ts
@@ -10,6 +10,7 @@
// again through `onAccountPinned`.
//
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
+/** @internal exported for 13 tests (BootGate.dom.test.tsx, …) */
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
// Re-readers for module-level per-account state, run each time the tab is pinned.
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 5dd9fc72..3b4fdd87 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -6,16 +6,10 @@
// 3. spreads each domain's method slice into the single `api` object consumers call.
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
// motivated R7.5 cannot re-grow here. (An automated guard for that invariant lands in S3.)
-import {
- alreadyReported,
- getActiveAccount,
- HttpError,
- setActiveAccount,
- setUnauthorizedHandler,
-} from "./api/core";
+import { getActiveAccount, HttpError, setActiveAccount, setUnauthorizedHandler } from "./api/core";
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
-export { alreadyReported, HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler };
+export { HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler };
import { accountApi } from "./api/account";
export type * from "./api/account";
diff --git a/frontend/src/lib/api/discover.ts b/frontend/src/lib/api/discover.ts
index c69e1118..e7d3794f 100644
--- a/frontend/src/lib/api/discover.ts
+++ b/frontend/src/lib/api/discover.ts
@@ -19,11 +19,11 @@ export type HeroTier = "resume" | "comfort" | "surprise";
* leads with `HERO_TIERS[start % 3]`, which is how a press can ASK for a tier. */
export const HERO_TIERS: readonly HeroTier[] = ["resume", "comfort", "surprise"];
-export type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist";
+type DiscoverKind = "video" | "movie" | "show" | "episode" | "channel" | "playlist";
// What can go on the comfort shelf. `plex_item` covers a movie or a single episode; a show is pinned
// as `plex_show` because "put that show on" resolves to whichever episode is next.
-export const PIN_KINDS = ["video", "channel", "plex_item", "plex_show", "playlist"] as const;
+const PIN_KINDS = ["video", "channel", "plex_item", "plex_show", "playlist"] as const;
export type PinKind = (typeof PIN_KINDS)[number];
export interface DiscoverCard {
diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts
index ea60bdbc..516124d6 100644
--- a/frontend/src/lib/api/plex.ts
+++ b/frontend/src/lib/api/plex.ts
@@ -58,7 +58,7 @@ export interface PlexCard {
// TMDB enrichment block (E5 S2), served from OUR DB (never a live TMDB call) on a movie/show detail.
// null until the row has been enriched. Powers the S5 enriched detail page: multi-source ratings row,
// facts sidebar, keyword chips (→ filter), and the full crew.
-export interface PlexTmdbCrew {
+interface PlexTmdbCrew {
name: string;
job: string;
tmdb_id?: number | null;
diff --git a/frontend/src/lib/api/tagModes.ts b/frontend/src/lib/api/tagModes.ts
index 2282b53d..a8e67d28 100644
--- a/frontend/src/lib/api/tagModes.ts
+++ b/frontend/src/lib/api/tagModes.ts
@@ -2,8 +2,11 @@
// URL (de)serializer, the sidebar comboboxes, and the saved-view migration. Kept in its own leaf (no
// imports) so node-env tests and any module can pull it in without dragging the HTTP/i18n machinery.
-/** A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL. */
-export type TagMode = "or" | "and";
+/**
+ * A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL.
+ * @internal exported for its test
+ */
+type TagMode = "or" | "and";
/** The three tag categories, each with its OWN independent match mode. */
export type TagCategory = "language" | "topic" | "other";
export type TagModes = Record;
diff --git a/frontend/src/lib/api/tags.ts b/frontend/src/lib/api/tags.ts
index e1f9ec2b..144eb30d 100644
--- a/frontend/src/lib/api/tags.ts
+++ b/frontend/src/lib/api/tags.ts
@@ -11,7 +11,7 @@ export interface Tag {
}
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern (E8·S4).
-export interface TagSuggestion {
+interface TagSuggestion {
tag_id: number;
confidence: number;
}
diff --git a/frontend/src/lib/colorMath.ts b/frontend/src/lib/colorMath.ts
index c5d072f0..a58f6b19 100644
--- a/frontend/src/lib/colorMath.ts
+++ b/frontend/src/lib/colorMath.ts
@@ -34,6 +34,7 @@ export function hueGap(a: string, b: string): number {
/** `color-mix(in srgb, a w, b)` for two opaque hex colours: the channels mix gamma-encoded, as the
* browser does in the srgb space. */
+/** @internal exported for theme.contract.test.ts */
export function mixHex(a: string, b: string, w: number): string {
const [x, y] = [rgb(a), rgb(b)];
return `#${x
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 92a2999e..1ba32853 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -60,6 +60,7 @@ export function formatSpeed(bps: number | null | undefined, lang: string): strin
* Module-level because it is constant, and exported so the tests can hold that invariant. */
// A non-empty tuple type (first element + rest) so `RELATIVE_UNITS[0]` is known-present under
// noUncheckedIndexedAccess — the "sub-minute" branch relies on that first entry always existing.
+/** @internal exported for format.test.ts */
export const RELATIVE_UNITS: readonly [
[number, Intl.RelativeTimeFormatUnit],
...[number, Intl.RelativeTimeFormatUnit][],
diff --git a/frontend/src/lib/issueDraft.ts b/frontend/src/lib/issueDraft.ts
index 60783988..4602d936 100644
--- a/frontend/src/lib/issueDraft.ts
+++ b/frontend/src/lib/issueDraft.ts
@@ -58,6 +58,7 @@ export function draftSteps(
/** The operating system a user agent names. Checked most specific first: Android's agent also says
* "Linux" — and says it first — and an iPad's also says "Mac OS X". */
+/** @internal exported for issueDraft.test.ts */
export function osName(ua: string): string {
const tests: [RegExp, (m: RegExpExecArray) => string][] = [
[/Android [\d.]+/, (m) => m[0]],
diff --git a/frontend/src/lib/jobResult.ts b/frontend/src/lib/jobResult.ts
index ff22e8ef..70b7bc45 100644
--- a/frontend/src/lib/jobResult.ts
+++ b/frontend/src/lib/jobResult.ts
@@ -7,6 +7,7 @@ import { vocabKey } from "./vocab";
* one-liner: `_summarize`'s "new=3, failed=0" (backend/app/scheduler.py). A flat one reads back into a
* result, and `enrich`'s bare count ("50") into `{enriched: 50}` as `_result_data` names it; a nested
* phase (a Python dict repr) or anything else does not, and stays as it was. */
+/** @internal exported for jobResult.dom.test.ts */
export function parseSummary(summary: string | null | undefined): JobResult | null {
if (!summary || /[{}[\]]/.test(summary)) return null;
if (/^\d+$/.test(summary)) return { enriched: Number(summary) };
diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts
index 3372b9c3..1284b35b 100644
--- a/frontend/src/lib/keymap.ts
+++ b/frontend/src/lib/keymap.ts
@@ -259,6 +259,7 @@ function keyMatches(e: KeyboardEvent, name: string): boolean {
* Ctrl+A too, so select-all cycled the Plex audio track. It also means Shift+H is not `h` — a
* narrowing from the old `case "h": case "H":`, and the reason Shift chords can now be their own
* rows (Shift+← steps an episode without ← also seeking). */
+/** @internal exported for keymap.test.ts */
export function matches(e: KeyboardEvent, chord: string): boolean {
const parts = chord.split("+");
const key = parts[parts.length - 1] ?? "";
diff --git a/frontend/src/lib/layerStack.ts b/frontend/src/lib/layerStack.ts
index 23abd042..a653c91d 100644
--- a/frontend/src/lib/layerStack.ts
+++ b/frontend/src/lib/layerStack.ts
@@ -48,6 +48,7 @@ export function anyBlockingLayer(): boolean {
return stack.some((id) => !passive.has(id));
}
+/** @internal exported for layerStack.dom.test.ts */
export function isTopmost(id: number): boolean {
return stack.length > 0 && stack[stack.length - 1] === id;
}
@@ -78,6 +79,7 @@ export function anyBlockingLayerAbove(id: number): boolean {
// mutation; `anyLayerAbove` stays as the fallback for a layer above with no Escape handler of its own.
// WeakSet so a spent event is GC'd without a manual sweep.
const handledEscapes = new WeakSet();
+/** @internal exported for keymap.test.ts, layerStack.dom.test.ts */
export function markEscapeHandled(e: KeyboardEvent): void {
handledEscapes.add(e);
}
diff --git a/frontend/src/lib/listPaging.ts b/frontend/src/lib/listPaging.ts
index 78f9c033..6468535a 100644
--- a/frontend/src/lib/listPaging.ts
+++ b/frontend/src/lib/listPaging.ts
@@ -3,7 +3,10 @@
// over hundreds of channels, the Plex pickers, the filter and sort menus — had only the arrows. One
// definition of "a page" here, so the palette and the dropdowns cannot drift apart.
-/** A page when there is no layout to measure (jsdom, or a list not painted yet). */
+/**
+ * A page when there is no layout to measure (jsdom, or a list not painted yet).
+ * @internal exported for listPaging.test.ts
+ */
export const PAGE_FALLBACK = 10;
/** The nearest ancestor that scrolls vertically. */
diff --git a/frontend/src/lib/log.ts b/frontend/src/lib/log.ts
index e3c347c8..ce36e3bf 100644
--- a/frontend/src/lib/log.ts
+++ b/frontend/src/lib/log.ts
@@ -21,7 +21,10 @@ import { FRONTEND_VERSION } from "./version";
export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
-/** OpenTelemetry severity numbers — the same table as `logs.SEVERITY` (a contract test compares). */
+/**
+ * OpenTelemetry severity numbers — the same table as `logs.SEVERITY` (a contract test compares).
+ * @internal exported for log.contract.test.ts
+ */
export const SEVERITY: Record = {
TRACE: 1,
DEBUG: 5,
@@ -59,6 +62,7 @@ export interface LogEvent {
/** Events kept. At ~0.5 KB each the ring stays around a megabyte — a long session's worth of
* navigation and requests, without growing for as long as a tab stays open. */
+/** @internal exported for log.test.ts */
export const RING_MAX = 2000;
// --- redaction (mirrors backend/app/logs.py) ------------------------------------------------------
@@ -97,6 +101,7 @@ function redactText(text: string): string {
/** Recursively scrub a structure destined for the log. A value whose KEY names a secret is dropped
* whole — a prefix of a token is still a leak. Everything else is scrubbed as text. */
+/** @internal exported for log.contract.test.ts */
export function redact(value: unknown, key = ""): unknown {
const lowered = key.toLowerCase();
if (key && SECRET_KEY_PARTS.some((part) => lowered.includes(part))) return REDACTED;
diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts
index 96d0914b..60b16a72 100644
--- a/frontend/src/lib/notifications.ts
+++ b/frontend/src/lib/notifications.ts
@@ -95,6 +95,7 @@ const ERROR_TTL = 15000;
// A toast that carries an action (an "Undo") lives at least this long, whatever the reader's toast
// time: its button is the only way back, and a 3 s toast leaves no time to reach it (owner, E28).
// The setting still decides for longer, and "never" still means never.
+/** @internal exported for notifications.actionTtl.dom.test.ts */
export const ACTION_TTL_MIN = 8000;
// 6b: per-account notification settings (persisted by the Settings panel into the
diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts
index 99f3b720..3b6881a1 100644
--- a/frontend/src/lib/playerModalHooks.ts
+++ b/frontend/src/lib/playerModalHooks.ts
@@ -36,6 +36,7 @@ const MAX_CONSECUTIVE_ERRORS = 3;
export const YT_API_LOAD_FAILED = -2;
/** How long the script may take before the player gives up on it. The real answer comes in well
* under a second; anything near this is a blocked or stalled request, not a slow one. */
+/** @internal exported for playerModalHooks.apiload.dom.test.tsx */
export const YT_API_TIMEOUT_MS = 15_000;
let apiPromise: Promise | null = null;
function loadYouTubeApi(): Promise {
diff --git a/frontend/src/lib/playerVolume.ts b/frontend/src/lib/playerVolume.ts
index f9cc2beb..face470f 100644
--- a/frontend/src/lib/playerVolume.ts
+++ b/frontend/src/lib/playerVolume.ts
@@ -17,7 +17,10 @@ export interface VolumeScale {
round: boolean;
}
-/** YouTube iframe API: 0–100 integers, no mute flag (level 0 IS muted). */
+/**
+ * YouTube iframe API: 0–100 integers, no mute flag (level 0 IS muted).
+ * @internal exported for playerVolume.test.ts
+ */
export const YT_SCALE: VolumeScale = { min: 0, max: 100, default: 100, round: true };
/** HTML5 : a 0..1 float (mute is a separate flag the caller carries). */
export const PLEX_SCALE: VolumeScale = { min: 0, max: 1, default: 1, round: false };
diff --git a/frontend/src/lib/plexStats.ts b/frontend/src/lib/plexStats.ts
index dbb094da..a8b8aa7f 100644
--- a/frontend/src/lib/plexStats.ts
+++ b/frontend/src/lib/plexStats.ts
@@ -58,6 +58,7 @@ export interface StatRow {
const DASH = "—";
// bits-per-second → "4.2 Mbps" / "820 kbps". Undefined/0 → dash (hls.js reports 0 before a level loads).
+/** @internal exported for plexStats.test.ts */
export function fmtBitrate(bps: number | undefined, lang: string): string {
if (!bps || bps <= 0) return DASH;
if (bps >= 1_000_000) return `${formatDecimal(bps / 1_000_000, lang)} Mbps`;
@@ -65,12 +66,14 @@ export function fmtBitrate(bps: number | undefined, lang: string): string {
}
// Seconds of buffer ahead of the playhead → "12.3 sec" / "12,3 mp".
+/** @internal exported for plexStats.test.ts */
export function fmtBufferSecs(s: number, lang: string): string {
if (!Number.isFinite(s) || s < 0) return DASH;
return formatUnit(s, "second", lang, 1);
}
// Dropped / total decoded frames, with a drop percentage once any frames have been decoded.
+/** @internal exported for plexStats.test.ts */
export function fmtFrames(dropped: number, total: number, lang: string): string {
if (total <= 0) return DASH;
const pct = (dropped / total) * 100;
@@ -80,12 +83,14 @@ export function fmtFrames(dropped: number, total: number, lang: string): string
// Intrinsic decoded resolution. 0×0 means the browser hasn't decoded (or can't) — an honest dash,
// which is itself the tell for a not-yet-playable codec pre-transcode.
+/** @internal exported for plexStats.test.ts */
export function fmtRes(w: number, h: number): string {
if (w <= 0 || h <= 0) return DASH;
return `${w}×${h}`;
}
// Seconds → m:ss (offsets/positions are small; hours are folded into minutes deliberately).
+/** @internal exported for plexStats.test.ts */
export function fmtClock(s: number): string {
if (!Number.isFinite(s) || s < 0) return DASH;
const total = Math.floor(s);
@@ -95,6 +100,7 @@ export function fmtClock(s: number): string {
}
// hls.js active level → "2 / 4 · 1280×720" (1-based index for humans). Auto/unknown level → dash + count.
+/** @internal exported for plexStats.test.ts */
export function fmtLevel(l: HlsLevelInfo | null): string {
if (!l || l.total <= 0) return DASH;
const idx = l.index >= 0 ? `${l.index + 1} / ${l.total}` : `auto / ${l.total}`;
@@ -103,6 +109,7 @@ export function fmtLevel(l: HlsLevelInfo | null): string {
}
// Video/audio codec from the active hls.js level (only HLS exposes them; direct play → dash).
+/** @internal exported for plexStats.test.ts */
export function fmtCodec(video: string | undefined, audio: string | undefined): string {
const v = video?.split(".")[0] || null; // "avc1.640028" → "avc1"
const a = audio?.split(".")[0] || null;
@@ -115,6 +122,7 @@ export function fmtCodec(video: string | undefined, audio: string | undefined):
// "· HDR→SDR" tag when the GPU OpenCL tonemap path handled a 10-bit/HDR source ("transcode · GPU ·
// HDR→SDR"). Non-transcode modes (direct/remux) and an unknown encoder read as the bare mode. Raw tokens
// (not i18n'd), consistent with the other technical values in this overlay (e.g. transport "hls").
+/** @internal exported for plexStats.test.ts */
export function fmtMode(
playable: string,
hwEncoder?: boolean | null,
diff --git a/frontend/src/lib/useFocusKeeper.ts b/frontend/src/lib/useFocusKeeper.ts
index 5292d324..aa00c603 100644
--- a/frontend/src/lib/useFocusKeeper.ts
+++ b/frontend/src/lib/useFocusKeeper.ts
@@ -38,6 +38,7 @@ function firstFocusable(node: Node | null, fromEnd = false): HTMLElement | null
* else the previous one, widening to the parent's neighbours — so its removal does not drop the
* focus to . For a removal the caller makes itself (a chip's ×) anywhere, including a modal
* or a side panel, where the keeper below does not watch. False when nothing was found. */
+/** @internal exported for useFocusKeeper.dom.test.tsx */
export function focusNeighbour(el: HTMLElement): boolean {
for (let at: HTMLElement | null = el; at && at !== document.body; at = at.parentElement) {
const hit = firstFocusable(at.nextSibling) ?? firstFocusable(at.previousSibling, true);
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
index 3cf0c554..0d2f3c60 100644
--- a/frontend/tsconfig.node.json
+++ b/frontend/tsconfig.node.json
@@ -23,6 +23,8 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
+ // E32: an index read is `T | undefined` here, as it really is (0 errors when switched on).
+ "noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
// The build-tool configs include a hand-written .js file (postcss; tailwind 4 needs no config
// file, and the stale `tailwind.config.js` entry pointed at nothing until E32). checkJs runs
From b1ff4058ac85f406a7374a379c037bdf5caa9646 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 16:50:56 +0200
Subject: [PATCH 08/54] build: CI builds the bundle and audits what ships; the
toolchain advisories are cleared
The server gate adds `vite build` (and with it devOnlyGuard, which until now
first ran inside the image build, after main was pushed) and
`npm audit --omit=dev --audit-level=high`. pip-audit is blocking: when it was
finally read it held three urllib3 advisories with a fix available.
urllib3 goes 2.7.0 -> 2.8.0 as a single in-place pin, passing pip check,
rather than a regeneration that would move every package.
The dev toolchain's 18 advisories are gone:
- vite 5 -> 6.4.3 (path traversal, fs.deny bypass, and esbuild <= 0.24.2
through it);
- vitest 3 -> 4.1.11 (mocker path traversal);
- typescript-eslint 8.46 -> 8.71;
- `npm audit fix` for the rest.
tsc, eslint, knip, all 2269 tests and the build pass unchanged.
scripts/check-node-modules.mjs checks that frontend/node_modules holds
exactly what package-lock.json pins. The local gate's frontend lanes judge
the host tree, and nothing tied it to the lock the image installs.
---
.gitea/workflows/check.yml | 21 +-
backend/requirements.lock | 7 +-
frontend/package-lock.json | 1470 +++++++++++---------------------
frontend/package.json | 6 +-
scripts/check-node-modules.mjs | 40 +
5 files changed, 567 insertions(+), 977 deletions(-)
create mode 100644 scripts/check-node-modules.mjs
diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml
index 017d6393..7bd1487a 100644
--- a/.gitea/workflows/check.yml
+++ b/.gitea/workflows/check.yml
@@ -1,7 +1,9 @@
# Server-side mirror of the local `siftlode check` gate (E1 S3 / DX-1), so `main` is always green
# independent of any one machine. Two jobs match the two local lanes:
-# frontend — tsc (3 projects) + eslint + prettier --check + knip + vitest
-# backend — ruff + the full pytest suite (pure-logic + the DB-backed lane) against a real Postgres
+# frontend — tsc (3 projects) + eslint + prettier --check + knip (both modes) + vitest + vite build
+# + npm audit of the production dependencies
+# backend — ruff + basedpyright + the full pytest suite (pure-logic + the DB-backed lane) against
+# a real Postgres + pip-audit of the lock
#
# They are declared side by side but they DO NOT run in parallel: the lab has a single act_runner
# with one slot, so they queue. Measured across runs #86-#91 — the backend job starts at the exact
@@ -56,6 +58,12 @@ jobs:
- run: npm run format:check
- run: npm run knip
- run: npm run test
+ # The production bundle (E32): before this, `vite build` — and devOnlyGuard, which fails it if
+ # dev-only code reaches the bundle — first ran inside the image build, after main was pushed.
+ - run: npx vite build
+ # What ships to the browser: a known CVE at high or above in a production dependency fails.
+ # (The dev tooling's own advisories are burned down by hand; see docs/gates.md.)
+ - run: npm audit --omit=dev --audit-level=high
backend:
runs-on: ubuntu-latest
@@ -112,8 +120,9 @@ jobs:
- name: pytest
run: python -m pytest
working-directory: backend
- # Dependency CVE audit over the pinned lock. Non-blocking for now (informational) — a real CVE
- # shows up in the run without failing the gate; tighten to a hard fail once the noise is known.
- - name: pip-audit (advisory)
+ # Dependency CVE audit over the pinned lock. BLOCKING since E32: it ran advisory-only, and when
+ # it was finally read it had three urllib3 advisories with a fix available (bumped in the same
+ # change). An advisory without a fix is answered with `--ignore-vuln ` here, a reason and a
+ # date in the comment, and a line in docs/gates.md.
+ - name: pip-audit
run: pip install --no-cache-dir pip-audit && pip-audit -r backend/requirements.lock
- continue-on-error: true
diff --git a/backend/requirements.lock b/backend/requirements.lock
index 91c17790..eb3457d1 100644
--- a/backend/requirements.lock
+++ b/backend/requirements.lock
@@ -1,7 +1,10 @@
# Pinned dependency lock for reproducible builds (SEC-5). GENERATED — do not hand-edit.
# Source of truth is requirements.txt (the floor ranges); regenerate after editing it with:
# docker run --rm -v "$PWD":/b python:3.12-slim sh -c 'pip install -q -r /b/requirements.txt && pip freeze --exclude pip --exclude setuptools --exclude wheel' | sort
-# The prod Dockerfile + Dockerfile.test + CI install THIS file; pip-audit scans it in CI.
+# The prod Dockerfile + Dockerfile.test + CI install THIS file; pip-audit scans it in CI (blocking
+# since E32). A security fix may be ONE pin bumped in place instead of a full regeneration, which
+# would move every other package too — then `pip install -r requirements.lock && pip check` must
+# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177, E32).
APScheduler==3.11.3
Authlib==1.7.2
Mako==1.4.1
@@ -53,7 +56,7 @@ starlette==1.6.0
typing-inspection==0.4.4
typing_extensions==4.16.0
tzlocal==5.4.4
-urllib3==2.7.0
+urllib3==2.8.0
uvicorn==0.52.3
uvloop==0.22.1
watchfiles==1.2.0
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a049ed8d..4dbaadcb 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -47,9 +47,9 @@
"prettier": "3.6.2",
"tailwindcss": "^4.3.3",
"typescript": "^5.5.3",
- "typescript-eslint": "8.46.0",
- "vite": "^5.3.4",
- "vitest": "^3.2.7"
+ "typescript-eslint": "8.71.0",
+ "vite": "^6.4.3",
+ "vitest": "^4.1.11"
},
"engines": {
"node": "24.x"
@@ -583,9 +583,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
- "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
@@ -596,13 +596,13 @@
"aix"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
- "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
@@ -613,13 +613,13 @@
"android"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
- "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
@@ -630,13 +630,13 @@
"android"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
- "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
@@ -647,13 +647,13 @@
"android"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
- "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
@@ -664,13 +664,13 @@
"darwin"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
- "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
@@ -681,13 +681,13 @@
"darwin"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
- "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
@@ -698,13 +698,13 @@
"freebsd"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
- "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
@@ -715,13 +715,13 @@
"freebsd"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
- "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
@@ -732,13 +732,13 @@
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
- "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
@@ -749,13 +749,13 @@
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
- "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
@@ -766,13 +766,13 @@
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
- "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
@@ -783,13 +783,13 @@
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
- "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
@@ -800,13 +800,13 @@
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
- "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
@@ -817,13 +817,13 @@
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
- "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
@@ -834,13 +834,13 @@
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
- "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
@@ -851,13 +851,13 @@
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
- "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
@@ -868,13 +868,30 @@
"linux"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
- "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
@@ -885,13 +902,30 @@
"netbsd"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
- "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
@@ -902,13 +936,30 @@
"openbsd"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
- "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
@@ -919,13 +970,13 @@
"sunos"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
- "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
@@ -936,13 +987,13 @@
"win32"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
- "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
@@ -953,13 +1004,13 @@
"win32"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
- "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
@@ -970,7 +1021,7 @@
"win32"
],
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/@eslint-community/eslint-plugin-eslint-comments": {
@@ -1361,44 +1412,6 @@
"@emnapi/runtime": "^1.7.1"
}
},
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/@oxc-parser/binding-android-arm-eabi": {
"version": "0.137.0",
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.137.0.tgz",
@@ -2466,6 +2479,13 @@
"win32"
]
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -2978,21 +2998,20 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
- "integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz",
+ "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.46.0",
- "@typescript-eslint/type-utils": "8.46.0",
- "@typescript-eslint/utils": "8.46.0",
- "@typescript-eslint/visitor-keys": "8.46.0",
- "graphemer": "^1.4.0",
- "ignore": "^7.0.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/type-utils": "8.71.0",
+ "@typescript-eslint/utils": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.1.0"
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3002,9 +3021,9 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.46.0",
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "@typescript-eslint/parser": "^8.71.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
@@ -3018,17 +3037,17 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz",
- "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz",
+ "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.46.0",
- "@typescript-eslint/types": "8.46.0",
- "@typescript-eslint/typescript-estree": "8.46.0",
- "@typescript-eslint/visitor-keys": "8.46.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3038,20 +3057,20 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz",
- "integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz",
+ "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.46.0",
- "@typescript-eslint/types": "^8.46.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/tsconfig-utils": "^8.71.0",
+ "@typescript-eslint/types": "^8.71.0",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3061,18 +3080,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz",
- "integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz",
+ "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.0",
- "@typescript-eslint/visitor-keys": "8.46.0"
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3083,9 +3102,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz",
- "integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz",
+ "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3096,21 +3115,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz",
- "integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz",
+ "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.0",
- "@typescript-eslint/typescript-estree": "8.46.0",
- "@typescript-eslint/utils": "8.46.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0",
+ "@typescript-eslint/utils": "8.71.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3120,14 +3139,14 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz",
- "integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz",
+ "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3139,22 +3158,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz",
- "integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz",
+ "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.46.0",
- "@typescript-eslint/tsconfig-utils": "8.46.0",
- "@typescript-eslint/types": "8.46.0",
- "@typescript-eslint/visitor-keys": "8.46.0",
- "debug": "^4.3.4",
- "fast-glob": "^3.3.2",
- "is-glob": "^4.0.3",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/project-service": "8.71.0",
+ "@typescript-eslint/tsconfig-utils": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3164,30 +3182,43 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "typescript": ">=4.8.4 <6.0.0"
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
- "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
- "version": "9.0.9",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
- "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
- "brace-expansion": "^2.0.2"
+ "brace-expansion": "^5.0.8"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -3207,16 +3238,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz",
- "integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz",
+ "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.46.0",
- "@typescript-eslint/types": "8.46.0",
- "@typescript-eslint/typescript-estree": "8.46.0"
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3226,19 +3257,19 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz",
- "integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz",
+ "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.0",
- "eslint-visitor-keys": "^4.2.1"
+ "@typescript-eslint/types": "8.71.0",
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3248,6 +3279,19 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -3300,246 +3344,41 @@
}
}
},
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": {
- "version": "8.71.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz",
- "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.71.0",
- "@typescript-eslint/types": "^8.71.0",
- "debug": "^4.4.3"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
- "version": "8.71.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz",
- "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.71.0",
- "@typescript-eslint/visitor-keys": "8.71.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.71.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz",
- "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
- "version": "8.71.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz",
- "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.71.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz",
- "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/project-service": "8.71.0",
- "@typescript-eslint/tsconfig-utils": "8.71.0",
- "@typescript-eslint/types": "8.71.0",
- "@typescript-eslint/visitor-keys": "8.71.0",
- "debug": "^4.4.3",
- "minimatch": "^10.2.2",
- "semver": "^7.7.3",
- "tinyglobby": "^0.2.15",
- "ts-api-utils": "^2.5.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": {
- "version": "8.71.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz",
- "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.71.0",
- "@typescript-eslint/types": "8.71.0",
- "@typescript-eslint/typescript-estree": "8.71.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.71.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz",
- "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@typescript-eslint/types": "8.71.0",
- "eslint-visitor-keys": "^5.0.0"
- },
- "engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/balanced-match": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": {
- "version": "5.0.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
- "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^4.0.2"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/eslint-visitor-keys": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
- "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/minimatch": {
- "version": "10.2.6",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
- "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
- "dev": true,
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "brace-expansion": "^5.0.8"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/@vitest/eslint-plugin/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/@vitest/expect": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
- "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "3.2.7",
- "@vitest/utils": "3.2.7",
- "chai": "^5.2.0",
- "tinyrainbow": "^2.0.0"
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
- "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "3.2.7",
+ "@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
- "magic-string": "^0.30.17"
+ "magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
@@ -3551,42 +3390,42 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
- "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^2.0.0"
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
- "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "3.2.7",
- "pathe": "^2.0.3",
- "strip-literal": "^3.0.0"
+ "@vitest/utils": "4.1.11",
+ "pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
- "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "3.2.7",
- "magic-string": "^0.30.17",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
@@ -3594,28 +3433,25 @@
}
},
"node_modules/@vitest/spy": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
- "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "tinyspy": "^4.0.3"
- },
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
- "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "3.2.7",
- "loupe": "^3.1.4",
- "tinyrainbow": "^2.0.0"
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -3751,9 +3587,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.35",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
- "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==",
+ "version": "2.11.27",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
+ "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3764,9 +3600,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
- "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3774,23 +3610,10 @@
"concat-map": "0.0.1"
}
},
- "node_modules/braces": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
- "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fill-range": "^7.1.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/browserslist": {
- "version": "4.28.2",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
- "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "version": "4.29.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
+ "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
"dev": true,
"funding": [
{
@@ -3808,11 +3631,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.10.12",
- "caniuse-lite": "^1.0.30001782",
- "electron-to-chromium": "^1.5.328",
- "node-releases": "^2.0.36",
- "update-browserslist-db": "^1.2.3"
+ "baseline-browser-mapping": "^2.11.26",
+ "caniuse-lite": "^1.0.30001813",
+ "electron-to-chromium": "^1.5.439",
+ "node-releases": "^2.0.57",
+ "update-browserslist-db": "^1.3.3"
},
"bin": {
"browserslist": "cli.js"
@@ -3821,16 +3644,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
- "node_modules/cac": {
- "version": "6.7.14",
- "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
- "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -3856,9 +3669,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001799",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
- "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+ "version": "1.0.30001814",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz",
+ "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==",
"dev": true,
"funding": [
{
@@ -3877,18 +3690,11 @@
"license": "CC-BY-4.0"
},
"node_modules/chai": {
- "version": "5.3.3",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
- "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "assertion-error": "^2.0.1",
- "check-error": "^2.1.1",
- "deep-eql": "^5.0.1",
- "loupe": "^3.1.0",
- "pathval": "^2.0.0"
- },
"engines": {
"node": ">=18"
}
@@ -3910,16 +3716,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/check-error": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
- "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 16"
- }
- },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -4058,16 +3854,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/deep-eql": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
- "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4130,9 +3916,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.371",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
- "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==",
+ "version": "1.5.444",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz",
+ "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==",
"dev": true,
"license": "ISC"
},
@@ -4184,9 +3970,9 @@
}
},
"node_modules/es-module-lexer": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
- "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
@@ -4220,9 +4006,9 @@
}
},
"node_modules/esbuild": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
- "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -4230,32 +4016,35 @@
"esbuild": "bin/esbuild"
},
"engines": {
- "node": ">=12"
+ "node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.21.5",
- "@esbuild/android-arm": "0.21.5",
- "@esbuild/android-arm64": "0.21.5",
- "@esbuild/android-x64": "0.21.5",
- "@esbuild/darwin-arm64": "0.21.5",
- "@esbuild/darwin-x64": "0.21.5",
- "@esbuild/freebsd-arm64": "0.21.5",
- "@esbuild/freebsd-x64": "0.21.5",
- "@esbuild/linux-arm": "0.21.5",
- "@esbuild/linux-arm64": "0.21.5",
- "@esbuild/linux-ia32": "0.21.5",
- "@esbuild/linux-loong64": "0.21.5",
- "@esbuild/linux-mips64el": "0.21.5",
- "@esbuild/linux-ppc64": "0.21.5",
- "@esbuild/linux-riscv64": "0.21.5",
- "@esbuild/linux-s390x": "0.21.5",
- "@esbuild/linux-x64": "0.21.5",
- "@esbuild/netbsd-x64": "0.21.5",
- "@esbuild/openbsd-x64": "0.21.5",
- "@esbuild/sunos-x64": "0.21.5",
- "@esbuild/win32-arm64": "0.21.5",
- "@esbuild/win32-ia32": "0.21.5",
- "@esbuild/win32-x64": "0.21.5"
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escalade": {
@@ -4537,36 +4326,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fast-glob": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
- "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "^2.0.2",
- "@nodelib/fs.walk": "^1.2.3",
- "glob-parent": "^5.1.2",
- "merge2": "^1.3.0",
- "micromatch": "^4.0.8"
- },
- "engines": {
- "node": ">=8.6.0"
- }
- },
- "node_modules/fast-glob/node_modules/glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -4581,16 +4340,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fastq": {
- "version": "1.20.1",
- "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
- "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
"node_modules/fd-package-json": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
@@ -4601,6 +4350,24 @@
"walk-up-path": "^4.0.0"
}
},
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4614,19 +4381,6 @@
"node": ">=16.0.0"
}
},
- "node_modules/fill-range": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
- "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "to-regex-range": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -4837,13 +4591,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/graphemer": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
- "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -5065,16 +4812,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.12.0"
- }
- },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -5107,9 +4844,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [
{
@@ -5266,37 +5003,6 @@
"node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/knip/node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/knip/node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5607,13 +5313,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/loupe": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
- "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5664,30 +5363,6 @@
"node": ">= 0.4"
}
},
- "node_modules/merge2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/micromatch": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
- "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "braces": "^3.0.3",
- "picomatch": "^2.3.1"
- },
- "engines": {
- "node": ">=8.6"
- }
- },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -5758,9 +5433,9 @@
"license": "MIT"
},
"node_modules/node-releases": {
- "version": "2.0.47",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
- "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
+ "version": "2.0.57",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz",
+ "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5774,6 +5449,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5946,16 +5635,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/pathval": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
- "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14.16"
- }
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5964,13 +5643,13 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
- "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
- "node": ">=8.6"
+ "node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
@@ -6118,27 +5797,6 @@
"node": ">=6"
}
},
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
"node_modules/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
@@ -6224,17 +5882,6 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
- "node_modules/reusify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
- "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
"node_modules/rollup": {
"version": "4.61.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
@@ -6287,30 +5934,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
- "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
- },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -6378,9 +6001,9 @@
"license": "ISC"
},
"node_modules/smol-toml": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
- "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
+ "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -6408,9 +6031,9 @@
"license": "MIT"
},
"node_modules/std-env": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
- "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
"dev": true,
"license": "MIT"
},
@@ -6427,26 +6050,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/strip-literal": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
- "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "js-tokens": "^9.0.1"
- },
- "funding": {
- "url": "https://github.com/sponsors/antfu"
- }
- },
- "node_modules/strip-literal/node_modules/js-tokens": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
- "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -6502,11 +6105,14 @@
"license": "MIT"
},
"node_modules/tinyexec": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
- "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/tinyglobby": {
"version": "0.2.17",
@@ -6525,61 +6131,10 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
- "node_modules/tinyglobby/node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/tinypool": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
- "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- }
- },
"node_modules/tinyrainbow": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
- "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/tinyspy": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
- "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
+ "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6606,19 +6161,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "is-number": "^7.0.0"
- },
- "engines": {
- "node": ">=8.0"
- }
- },
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
@@ -6692,16 +6234,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.46.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz",
- "integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==",
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz",
+ "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.46.0",
- "@typescript-eslint/parser": "8.46.0",
- "@typescript-eslint/typescript-estree": "8.46.0",
- "@typescript-eslint/utils": "8.46.0"
+ "@typescript-eslint/eslint-plugin": "8.71.0",
+ "@typescript-eslint/parser": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0",
+ "@typescript-eslint/utils": "8.71.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6711,8 +6253,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0",
- "typescript": ">=4.8.4 <6.0.0"
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/unbash": {
@@ -6733,9 +6275,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
"dev": true,
"funding": [
{
@@ -6774,21 +6316,24 @@
}
},
"node_modules/vite": {
- "version": "5.4.21",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
- "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "esbuild": "^0.21.3",
- "postcss": "^8.4.43",
- "rollup": "^4.20.0"
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -6797,19 +6342,25 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^18.0.0 || >=20.0.0",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
- "terser": "^5.4.0"
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
+ "jiti": {
+ "optional": true
+ },
"less": {
"optional": true
},
@@ -6830,92 +6381,89 @@
},
"terser": {
"optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
}
}
},
- "node_modules/vite-node": {
- "version": "3.2.4",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
- "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cac": "^6.7.14",
- "debug": "^4.4.1",
- "es-module-lexer": "^1.7.0",
- "pathe": "^2.0.3",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
- },
- "bin": {
- "vite-node": "vite-node.mjs"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
"node_modules/vitest": {
- "version": "3.2.7",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
- "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@types/chai": "^5.2.2",
- "@vitest/expect": "3.2.7",
- "@vitest/mocker": "3.2.7",
- "@vitest/pretty-format": "^3.2.7",
- "@vitest/runner": "3.2.7",
- "@vitest/snapshot": "3.2.7",
- "@vitest/spy": "3.2.7",
- "@vitest/utils": "3.2.7",
- "chai": "^5.2.0",
- "debug": "^4.4.1",
- "expect-type": "^1.2.1",
- "magic-string": "^0.30.17",
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
"pathe": "^2.0.3",
- "picomatch": "^4.0.2",
- "std-env": "^3.9.0",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
- "tinyexec": "^0.3.2",
- "tinyglobby": "^0.2.14",
- "tinypool": "^1.1.1",
- "tinyrainbow": "^2.0.0",
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
- "vite-node": "3.2.4",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
- "@types/debug": "^4.1.12",
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "@vitest/browser": "3.2.7",
- "@vitest/ui": "3.2.7",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
"happy-dom": "*",
- "jsdom": "*"
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
- "@types/debug": {
+ "@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
- "@vitest/browser": {
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
@@ -6926,22 +6474,12 @@
},
"jsdom": {
"optional": true
+ },
+ "vite": {
+ "optional": false
}
}
},
- "node_modules/vitest/node_modules/picomatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
- "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 282b942f..97286735 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -63,8 +63,8 @@
"prettier": "3.6.2",
"tailwindcss": "^4.3.3",
"typescript": "^5.5.3",
- "typescript-eslint": "8.46.0",
- "vite": "^5.3.4",
- "vitest": "^3.2.7"
+ "typescript-eslint": "8.71.0",
+ "vite": "^6.4.3",
+ "vitest": "^4.1.11"
}
}
diff --git a/scripts/check-node-modules.mjs b/scripts/check-node-modules.mjs
new file mode 100644
index 00000000..55e23679
--- /dev/null
+++ b/scripts/check-node-modules.mjs
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+// Does frontend/node_modules hold exactly what frontend/package-lock.json pins? (E32)
+//
+// The local gate's frontend lanes run against the HOST's node_modules, and nothing tied that tree to
+// the lock: a `git pull` that bumped a dependency, or an `npm install` that was never committed, left
+// tsc/eslint/vitest judging the code against packages the build image (`npm ci`) does not use — a
+// green that proves nothing about what ships, in either direction. CI is immune (it runs `npm ci`);
+// this makes the local lanes say so instead of quietly differing.
+//
+// Checks every package the lock places under node_modules/: present, at the pinned version. A package
+// marked `optional` may be absent (a platform binary for another OS). Exit 1 with the list and the
+// one command that fixes it.
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const FRONTEND = join(dirname(fileURLToPath(import.meta.url)), "..", "frontend");
+const lock = JSON.parse(readFileSync(join(FRONTEND, "package-lock.json"), "utf8"));
+
+const problems = [];
+for (const [path, entry] of Object.entries(lock.packages ?? {})) {
+ if (!path.startsWith("node_modules/") || entry.link) continue;
+ const manifest = join(FRONTEND, path, "package.json");
+ if (!existsSync(manifest)) {
+ if (!entry.optional) problems.push(`missing ${path.slice(13)}@${entry.version}`);
+ continue;
+ }
+ const installed = JSON.parse(readFileSync(manifest, "utf8")).version;
+ if (installed !== entry.version)
+ problems.push(`mismatch ${path.slice(13)}: installed ${installed}, lock pins ${entry.version}`);
+}
+
+if (problems.length) {
+ console.error(`frontend/node_modules does not match package-lock.json (${problems.length}):`);
+ for (const p of problems.slice(0, 20)) console.error(` ${p}`);
+ if (problems.length > 20) console.error(` … and ${problems.length - 20} more`);
+ console.error("Fix: cd frontend && npm ci");
+ process.exit(1);
+}
+console.log(`node_modules matches package-lock.json (${Object.keys(lock.packages).length - 1} entries)`);
From da554df7c7b8fd2b894e4a27d60b5c0b0668c8a6 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 17:06:28 +0200
Subject: [PATCH 09/54] build(image): the image passes a Trivy scan: OS
security updates, no runtime pip
The first Trivy scan of the shipped image (0.98.0) found four fixable HIGH
advisories. One was pcre2: a Debian fix newer than the base snapshot, now
taken by `apt-get upgrade` at build time. The others were urllib3 (bumped in
the lock), and msgpack, setuptools and urllib3 vendored inside pip itself.
pip is removed once it has installed the lock, since nothing runs it at
runtime.
A local build of this Dockerfile with --pull scans clean: 0 HIGH/CRITICAL
with a fix, no secrets. .trivyignore.yaml is the publish gate's allowlist,
empty today. Each entry must carry a statement and an expiry date.
---
.trivyignore.yaml | 15 +++++++++++++++
Dockerfile | 8 +++++++-
2 files changed, 22 insertions(+), 1 deletion(-)
create mode 100644 .trivyignore.yaml
diff --git a/.trivyignore.yaml b/.trivyignore.yaml
new file mode 100644
index 00000000..79dbc1c5
--- /dev/null
+++ b/.trivyignore.yaml
@@ -0,0 +1,15 @@
+# The CVE gate's allowlist (E32). The publish gate scans the built image and the committed tree with
+# Trivy and fails on any HIGH or CRITICAL finding that has a fix available. A finding that cannot be
+# fixed yet is let through ONLY by an entry here, and every entry carries both:
+# statement — why it is acceptable for now (what is exposed, what is not)
+# expired_at — the date it stops being accepted; after it, the gate fails again on its own
+# A contract test (frontend/src/lib/gates.contract.test.ts) refuses an entry without either, and
+# docs/gates.md lists them.
+#
+# Example:
+# vulnerabilities:
+# - id: CVE-2026-00000
+# statement: in libfoo's XML path, which nothing in the image calls; no fixed Debian build yet
+# expired_at: 2026-12-31
+vulnerabilities: []
+secrets: []
diff --git a/Dockerfile b/Dockerfile
index d6b362c5..c1c69b2a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -53,8 +53,11 @@ RUN adduser --disabled-password --gecos "" appuser \
# Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp
# uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
+# `apt-get upgrade` (E32): the base image is a snapshot, and the Debian security fixes released since it
+# was cut are only in the archive — the first Trivy scan found one (pcre2) fixed upstream but not in it.
ARG DENO_VERSION=v2.9.1
RUN apt-get update \
+ && apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
&& unzip -o /tmp/deno.zip -d /usr/local/bin \
@@ -78,9 +81,12 @@ RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nCo
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
+# pip itself is removed once it has installed the lock (E32): nothing runs it at runtime, and it carries
+# its own VENDORED copies of urllib3, msgpack and setuptools, which Trivy reports as the image's own —
+# an attack surface and a CVE stream for a tool the running app never calls.
COPY backend/requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
- pip install --upgrade pip && pip install -r requirements.lock
+ pip install --upgrade pip && pip install -r requirements.lock && pip uninstall -y pip
COPY --chown=appuser backend/ .
# Read at runtime by config.py as the app_version (a committed single source of truth).
From 751e1ae5edf393383dc7ac4c4203fbbbf84621ee Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 17:18:53 +0200
Subject: [PATCH 10/54] test: a size and complexity ratchet; coverage reported
on both sides
The "class size" item from the quality checklist, measured:
- A shipped file over 800 lines may not grow past its line count today
(13 frontend, 11 backend). No other file may cross 800.
- A function over the complexity limit may not rise, and no new one may
appear. The limit is eslint `complexity` 20 on the frontend (64 functions
over it today) and ruff C901 10 on the backend (32).
- Each is a per-file ceiling. Shrinking is free; an entry back under the
limit must be deleted, so the lists only shrink.
C901 is measured in a test rather than switched on in ruff.toml: ruff has no
per-function baseline, and a noqa would let a function grow without limit.
Coverage (owner decision D4) is printed with no threshold:
`vitest --coverage` (v8) gives 80% of lines, and the pytest lane ends with
the total for app/, 78%. The bar stays "every regression test seen red".
.coverage is ignored by git and kept out of the image's build context.
---
.dockerignore | 1 +
.gitignore | 1 +
backend/docker-compose.test.yml | 7 +-
backend/requirements-dev.txt | 3 +
backend/tests/test_source_size.py | 139 +++++++++++++++++
frontend/package-lock.json | 148 +++++++++++++++++++
frontend/package.json | 1 +
frontend/src/lib/sourceSize.contract.test.ts | 134 +++++++++++++++++
frontend/src/lib/sourceSize.testkit.ts | 63 ++++++++
frontend/vitest.config.ts | 8 +
10 files changed, 504 insertions(+), 1 deletion(-)
create mode 100644 backend/tests/test_source_size.py
create mode 100644 frontend/src/lib/sourceSize.contract.test.ts
create mode 100644 frontend/src/lib/sourceSize.testkit.ts
diff --git a/.dockerignore b/.dockerignore
index f9bc435f..5ae987fb 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,6 +4,7 @@
*.env.local
**/__pycache__/
**/*.pyc
+**/.coverage
.venv/
venv/
# Docker patterns are anchored at the context root with NO implicit `**/` (unlike .gitignore, where
diff --git a/.gitignore b/.gitignore
index d5349a1d..d034f201 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ __pycache__/
venv/
.mypy_cache/
.pytest_cache/
+.coverage
.ruff_cache/
# Node / frontend
diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml
index 95d6ee95..4ce8ae39 100644
--- a/backend/docker-compose.test.yml
+++ b/backend/docker-compose.test.yml
@@ -35,4 +35,9 @@ services:
volumes:
- ./:/app
working_dir: /app
- command: python -m pytest
+ # The suite, then the coverage total (E32 D4: reported, no threshold). pytest's exit code is the
+ # lane's — the report is printed either way and never decides it.
+ command:
+ - sh
+ - -c
+ - 'python -m pytest --cov=app --cov-report= ; rc=$$?; echo ">> backend coverage (app/) $$(python -m coverage report --format=total)% of lines"; exit $$rc'
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
index 311a35bd..b82f6104 100644
--- a/backend/requirements-dev.txt
+++ b/backend/requirements-dev.txt
@@ -7,7 +7,10 @@
# basedpyright: the backend type-check lane (E27 S3). Pinned like everything else here — a type
# checker whose version floats silently changes what "clean" means, and the ratchet baseline it
# writes is keyed to the diagnostics THIS version produces.
+# pytest-cov: the coverage REPORT the gate prints (E32 D4) — a number to read, deliberately with no
+# threshold; "every regression test seen red" is the bar, not a percentage.
basedpyright==1.40.1
pytest==8.4.2
+pytest-cov==7.1.0
ruff==0.15.21
httpx2==2.13.0
diff --git a/backend/tests/test_source_size.py b/backend/tests/test_source_size.py
new file mode 100644
index 00000000..467f3e1e
--- /dev/null
+++ b/backend/tests/test_source_size.py
@@ -0,0 +1,139 @@
+"""The size / complexity RATCHET for the backend (E32, owner 2026-10-03 — "class size" from the quality
+checklist, measured rather than argued). The frontend's twin is src/lib/sourceSize.contract.test.ts.
+
+Two limits, each with today's offenders as a CEILING:
+ * a module over MAX_LINES lines — a listed one may not grow past its line count; any other module
+ may not cross the limit;
+ * a function over ruff's C901 limit (cyclomatic complexity, MAX_COMPLEXITY) — per module, the
+ offenders' values highest-first may not rise, and no new offender may appear.
+Shrinking is free; a module or function that drops UNDER the limit must be taken off its list (red
+until it is), so the lists only get shorter. Raising a ceiling is a decision for the owner, with the
+reason in docs/gates.md.
+
+C901 is measured here rather than switched on in ruff.toml because ruff has no per-function
+baseline: on, it would need a `noqa` on each of today's offenders, and a `noqa` lets the function
+grow without limit. This keeps the ceiling on the number.
+"""
+
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+BACKEND = Path(__file__).resolve().parent.parent
+MAX_LINES = 800
+MAX_COMPLEXITY = 10 # ruff's C901 default (mccabe max-complexity)
+
+BIG_MODULES: dict[str, int] = {
+ "app/downloads/edit.py": 833,
+ "app/downloads/pipeline.py": 870,
+ "app/logs.py": 839,
+ "app/models.py": 1607,
+ "app/plex/stream.py": 1128,
+ "app/plex/subtitles.py": 1092,
+ "app/plex/sync.py": 841,
+ "app/routes/admin.py": 864,
+ "app/routes/auth.py": 1353,
+ "app/routes/downloads.py": 1237,
+ "app/sync/playlists.py": 843,
+}
+
+COMPLEX: dict[str, list[int]] = {
+ "app/bodylimit.py": [11],
+ "app/discover/hero.py": [14],
+ "app/discover/pins.py": [12],
+ "app/downloads/edit.py": [16, 15],
+ "app/downloads/formats.py": [11],
+ "app/downloads/gc.py": [15, 11],
+ "app/downloads/og.py": [12, 11],
+ "app/downloads/pipeline.py": [14, 12],
+ "app/feedquery.py": [28, 14],
+ "app/plex/query.py": [13],
+ "app/plex/stream.py": [14],
+ "app/plex/subtitles.py": [12],
+ "app/plex/sync.py": [13],
+ "app/plex/tmdb.py": [11],
+ "app/plex/watch_sync.py": [12],
+ "app/routes/auth.py": [19],
+ "app/routes/debug_sessions.py": [12],
+ "app/routes/downloads.py": [11],
+ "app/routes/plex/browse.py": [25, 18, 13],
+ "app/routes/plex/detail.py": [13],
+ "app/routes/search.py": [15],
+ "app/sync/playlists.py": [19],
+ "app/sync/runner.py": [13],
+ "app/sync/tagsuggest.py": [14],
+ "app/worker.py": [18],
+}
+
+
+def _line_counts() -> dict[str, int]:
+ return {
+ p.relative_to(BACKEND).as_posix(): len(p.read_text(encoding="utf-8").split("\n"))
+ for p in sorted((BACKEND / "app").rglob("*.py"))
+ }
+
+
+def _complexity() -> dict[str, list[int]]:
+ out = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "ruff",
+ "check",
+ "--config",
+ str(BACKEND / "ruff.toml"),
+ "--select",
+ "C901",
+ "--output-format",
+ "json",
+ "--exit-zero",
+ str(BACKEND / "app"),
+ ],
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout
+ found: dict[str, list[int]] = {}
+ for m in json.loads(out):
+ value = re.search(r"\((\d+) > \d+\)", m["message"])
+ assert value, m["message"]
+ rel = Path(m["filename"]).resolve().relative_to(BACKEND).as_posix()
+ found.setdefault(rel, []).append(int(value.group(1)))
+ return {f: sorted(v, reverse=True) for f, v in found.items()}
+
+
+def test_no_module_crosses_the_line_limit_and_a_listed_one_does_not_grow():
+ over = [
+ f"{f}: {n} lines (ceiling {BIG_MODULES.get(f, MAX_LINES)})"
+ for f, n in _line_counts().items()
+ if n > BIG_MODULES.get(f, MAX_LINES)
+ ]
+ assert over == []
+
+
+def test_lists_no_module_that_is_back_under_the_limit():
+ lines = _line_counts()
+ assert [f for f in BIG_MODULES if lines.get(f, 0) <= MAX_LINES] == []
+
+
+def test_no_function_crosses_the_complexity_limit_and_a_listed_one_does_not_rise():
+ worse = []
+ for f, values in _complexity().items():
+ ceiling = COMPLEX.get(f, [])
+ for i, v in enumerate(values):
+ cap = ceiling[i] if i < len(ceiling) else MAX_COMPLEXITY
+ if v > cap:
+ worse.append(f"{f}: #{i + 1} is {v} (ceiling {cap})")
+ assert worse == []
+
+
+def test_lists_no_offender_that_is_back_under_the_limit():
+ actual = _complexity()
+ stale = [
+ f"{f}: listed {len(v)}, now {len(actual.get(f, []))}"
+ for f, v in COMPLEX.items()
+ if len(actual.get(f, [])) < len(v)
+ ]
+ assert stale == []
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4dbaadcb..38982ff5 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -33,6 +33,7 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
+ "@vitest/coverage-v8": "4.1.11",
"@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
@@ -380,6 +381,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -3313,6 +3324,37 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
+ "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.11",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.11",
+ "vitest": "4.1.11"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/eslint-plugin": {
"version": "1.6.27",
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.27.tgz",
@@ -3562,6 +3604,25 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz",
+ "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -4679,6 +4740,13 @@
"node": ">=18"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -4826,6 +4894,45 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -5353,6 +5460,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz",
+ "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 97286735..aba641ae 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -49,6 +49,7 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
+ "@vitest/coverage-v8": "4.1.11",
"@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
diff --git a/frontend/src/lib/sourceSize.contract.test.ts b/frontend/src/lib/sourceSize.contract.test.ts
new file mode 100644
index 00000000..b6cd8146
--- /dev/null
+++ b/frontend/src/lib/sourceSize.contract.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from "vitest";
+import {
+ complexityOffenders,
+ lineCounts,
+ MAX_COMPLEXITY,
+ MAX_LINES,
+ shippedFiles,
+} from "./sourceSize.testkit";
+
+// The size / complexity RATCHET for the shipped frontend (E32, owner 2026-10-03 — "class size" from
+// the quality checklist, measured rather than argued). Two limits, each with today's offenders as a
+// CEILING:
+// • a file over 800 lines — the listed ones may not grow past their line count; any other
+// file may not cross the limit;
+// • a function over complexity 20 (eslint `complexity`, cyclomatic) — per file, the
+// offenders' values highest-first may not rise, and no new offender may appear.
+// Shrinking is free; a file or function that drops UNDER the limit must be taken off its list (red
+// until it is), so the lists only get shorter. Raising a ceiling is a decision for the owner, with
+// the reason in docs/gates.md. JSX branches count toward complexity, so a component scores high by
+// construction — the limit catches the outliers, it does not grade style.
+
+const BIG_FILES: Record = {
+ "components/AdminUsers.tsx": 993,
+ "components/Channels.tsx": 904,
+ "components/Feed.tsx": 934,
+ "components/Home.tsx": 1136,
+ "components/IssueComposer.tsx": 861,
+ "components/PlayerModal.tsx": 1142,
+ "components/Playlists.tsx": 1069,
+ "components/PlexPlayer.tsx": 1984,
+ "components/PlexPlaylistView.tsx": 830,
+ "components/plexViews.tsx": 941,
+ "components/SettingsPanel.tsx": 885,
+ "components/Welcome.tsx": 944,
+ "lib/plexPlayerHooks.ts": 922,
+};
+
+const COMPLEX: Record = {
+ "App.tsx": [28],
+ "components/AccountPanel.tsx": [29],
+ "components/AdminLogs.tsx": [35],
+ "components/auditColumns.tsx": [24],
+ "components/ChannelPage.tsx": [91],
+ "components/Channels.tsx": [53],
+ "components/ConfigPanel.tsx": [28, 24],
+ "components/DownloadCenter.tsx": [36],
+ "components/downloadRow.tsx": [25],
+ "components/Feed.tsx": [64],
+ "components/Home.tsx": [34, 32, 21],
+ "components/IssueComposer.tsx": [34],
+ "components/KeyGate.tsx": [22],
+ "components/NotificationsPanel.tsx": [28],
+ "components/OnboardingWizard.tsx": [23],
+ "components/PersonalViewsModal.tsx": [24],
+ "components/PlayerModal.tsx": [99],
+ "components/Playlists.tsx": [66],
+ "components/PlexBrowse.tsx": [45],
+ "components/plexCards.tsx": [58],
+ "components/PlexCollectionView.tsx": [41],
+ "components/PlexDiscover.tsx": [21],
+ "components/PlexInfo.tsx": [65],
+ "components/PlexPlayer.tsx": [103, 22],
+ "components/PlexPlaylistView.tsx": [22],
+ "components/PlexSidebar.tsx": [32],
+ "components/PlexStatsOverlay.tsx": [29],
+ "components/plexViews.tsx": [50, 30, 21],
+ "components/Scheduler.tsx": [21, 21],
+ "components/SetupWizard.tsx": [21],
+ "components/SyncStatus.tsx": [22],
+ "components/ui/AnchoredPopover.tsx": [22],
+ "components/ui/anchoredPopoverHooks.ts": [30],
+ "components/ui/Combobox.tsx": [22],
+ "components/ui/DataTable.tsx": [42],
+ "components/ui/PanelGroup.tsx": [23],
+ "components/ui/SidePanel.tsx": [45],
+ "components/ui/SortControl.tsx": [23],
+ "components/usePlaylistActions.tsx": [25],
+ "components/usePlexActiveFilters.ts": [29],
+ "components/useVideoActions.tsx": [25],
+ "components/VideoCard.tsx": [39],
+ "components/VideoEditor.tsx": [50],
+ "components/WatchPage.tsx": [30],
+ "components/Welcome.tsx": [39, 24],
+ "lib/api/core.ts": [48, 22],
+ "lib/api/plex.ts": [26],
+ "lib/diagnostics.ts": [22],
+ "lib/keymap.ts": [28],
+ "lib/logHooks.ts": [21],
+ "lib/plexDetail.ts": [24],
+ "lib/plexPlayerHooks.ts": [30],
+ "lib/urlState.ts": [26, 24],
+ "lib/useActiveFilters.ts": [28],
+};
+
+describe("file size", () => {
+ const lines = lineCounts(shippedFiles());
+
+ it(`no file crosses ${MAX_LINES} lines, and a listed one does not grow`, () => {
+ const over = Object.entries(lines)
+ .filter(([f, n]) => n > (BIG_FILES[f] ?? MAX_LINES))
+ .map(([f, n]) => `${f}: ${n} lines (ceiling ${BIG_FILES[f] ?? MAX_LINES})`);
+ expect(over).toEqual([]);
+ });
+
+ it("lists no file that is back under the limit (delete it)", () => {
+ const back = Object.keys(BIG_FILES).filter((f) => (lines[f] ?? 0) <= MAX_LINES);
+ expect(back).toEqual([]);
+ });
+});
+
+describe("function complexity", async () => {
+ const actual = await complexityOffenders();
+
+ it(`no function crosses ${MAX_COMPLEXITY}, and a listed one does not rise`, () => {
+ const worse: string[] = [];
+ for (const [file, values] of Object.entries(actual)) {
+ const ceiling = COMPLEX[file] ?? [];
+ values.forEach((v, i) => {
+ if (v > (ceiling[i] ?? MAX_COMPLEXITY))
+ worse.push(`${file}: #${i + 1} is ${v} (ceiling ${ceiling[i] ?? MAX_COMPLEXITY})`);
+ });
+ }
+ expect(worse).toEqual([]);
+ });
+
+ it("lists no offender that is back under the limit (shorten the entry)", () => {
+ const stale = Object.entries(COMPLEX)
+ .filter(([file, values]) => (actual[file]?.length ?? 0) < values.length)
+ .map(
+ ([file, values]) => `${file}: listed ${values.length}, now ${actual[file]?.length ?? 0}`,
+ );
+ expect(stale).toEqual([]);
+ });
+});
diff --git a/frontend/src/lib/sourceSize.testkit.ts b/frontend/src/lib/sourceSize.testkit.ts
new file mode 100644
index 00000000..a9f70bde
--- /dev/null
+++ b/frontend/src/lib/sourceSize.testkit.ts
@@ -0,0 +1,63 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { ESLint } from "eslint";
+import tseslint from "typescript-eslint";
+
+// Measures the shipped frontend source for the size/complexity ratchet (E32): lines per file, and the
+// cyclomatic complexity of every function over the limit. Used by sourceSize.contract.test.ts.
+
+export const MAX_LINES = 800;
+export const MAX_COMPLEXITY = 20;
+
+const SRC = fileURLToPath(new URL("../", import.meta.url));
+
+/** src-relative paths of everything the build ships (no tests, no test kits). */
+export function shippedFiles(): string[] {
+ return (readdirSync(SRC, { recursive: true }) as string[])
+ .map((p) => p.split("\\").join("/"))
+ .filter(
+ (p) => /\.(ts|tsx)$/.test(p) && !/\.(test|testkit)\.tsx?$/.test(p) && !p.endsWith(".d.ts"),
+ )
+ .sort();
+}
+
+export function lineCounts(files: string[]): Record {
+ const out: Record = {};
+ for (const f of files) out[f] = readFileSync(SRC + f, "utf8").split("\n").length;
+ return out;
+}
+
+/** file → the complexities over MAX_COMPLEXITY, highest first. A bare rule run (no type info, no
+ * other rules), so it costs a couple of seconds, not the full lint. */
+export async function complexityOffenders(): Promise> {
+ const eslint = new ESLint({
+ cwd: fileURLToPath(new URL("../../", import.meta.url)),
+ overrideConfigFile: true,
+ overrideConfig: [
+ {
+ files: ["src/**/*.{ts,tsx}"],
+ ignores: ["**/*.test.*", "**/*.testkit.*"],
+ languageOptions: {
+ parser: tseslint.parser,
+ parserOptions: { ecmaFeatures: { jsx: true } },
+ },
+ rules: { complexity: ["error", MAX_COMPLEXITY] },
+ },
+ ],
+ });
+ const out: Record = {};
+ for (const r of await eslint.lintFiles(["src/**/*.{ts,tsx}"])) {
+ const values = r.messages
+ .filter((m) => m.ruleId === "complexity")
+ .map((m) => Number(/complexity of (\d+)/.exec(m.message)?.[1]))
+ .sort((a, b) => b - a);
+ if (values.length)
+ out[
+ r.filePath
+ .split("\\")
+ .join("/")
+ .replace(/^.*\/frontend\/src\//, "")
+ ] = values;
+ }
+ return out;
+}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index c8ce1502..f7fc3212 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -19,5 +19,13 @@ export default defineConfig({
// collected, and the gate still prints green.
include: ["src/**/*.test.{ts,tsx}"],
environment: "node",
+ // E32 D4: a coverage REPORT, read and not enforced — the gate's `vitest --coverage` prints the
+ // summary; no threshold, because "every regression test seen red" is the bar, not a percentage.
+ coverage: {
+ provider: "v8",
+ include: ["src/**/*.{ts,tsx}"],
+ exclude: ["src/**/*.test.{ts,tsx}", "src/**/*.testkit.ts", "src/**/*.d.ts"],
+ reporter: ["text-summary"],
+ },
},
});
From 783f6408f9aa20062cef0b4e85911168e3a410dd Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 17:26:42 +0200
Subject: [PATCH 11/54] docs: the gate register, held to the effective config
by a test
docs/gates.md lists every place a gate deliberately checks less than it
could, each with its reason and the count measured when the row was
written:
- an ESLint rule off, at warn, or not applied, by scope;
- a weak compiler flag, a ruff ignore, the basedpyright mode and baseline;
- a knip exclusion, a lane that does not run everywhere;
- an accepted CVE.
gates.contract.test.ts reads the EFFECTIVE configuration and fails when a
narrowing has no row:
- ESLint's computed config per scope;
- the three tsconfigs, ruff.toml, pyrightconfig, pytest.ini and knip.jsonc;
- .trivyignore.yaml, where an entry also needs a statement and an expiry;
- CI's pip-audit ignores.
It also pins `--max-warnings 0`, which is what makes a warn-level rule fail.
Seen red on a missing row, a new `off` rule and an allowlist entry without
an expiry.
CLAUDE.md gains the rule E32 exists for: a gate with any real hit is never
switched off or narrowed, and an "enforced by" comment is checked by running
the check. The gate descriptions there, in docs/development.md and in
backend/app/README.md name the lanes as they now are, and the "directly"
recipe points at the full list.
---
CLAUDE.md | 11 +-
backend/app/README.md | 10 +-
docs/development.md | 35 ++--
docs/gates.md | 110 ++++++++++++
frontend/src/lib/gates.contract.test.ts | 223 ++++++++++++++++++++++++
5 files changed, 370 insertions(+), 19 deletions(-)
create mode 100644 docs/gates.md
create mode 100644 frontend/src/lib/gates.contract.test.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 5a0f01ea..c9f95274 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,12 +12,15 @@ router-free navigation provider.
## The gate — never merge or ship red
-The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + typecheck (3 tsc projects) + eslint + prettier
---check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
-`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
+The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + the lock check + typecheck (3 tsc projects) +
+eslint + prettier --check + knip (both modes) + vitest + vite build + pytest**, then a coverage report (no threshold). It runs
+before every `release` merges and on every `publish`, which **refuses to build on failure — there is deliberately no `--skip`
+flag**; a release also requires the Playwright suite, and the image must pass a Trivy scan before it is pushed. Run it and get it
+green **before** any merge, release, or handing work to review.
- Full gate (agents with the private ops dispatcher): `siftlode check`.
-- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres).
+- Directly, without the dispatcher: the commands in [`docs/development.md`](docs/development.md) § The gate (frontend lanes, `ruff` over `backend scripts`, basedpyright in the test image, and `pytest` — the DB-lane needs Postgres).
+- **A gate with any real hit is never switched off or narrowed** — not a lint rule, a compiler flag, a lane or a release step. Fix the sites, or suppress each one inline naming its invariant (`-- why`, lint-enforced). What stays narrowed is in [`docs/gates.md`](docs/gates.md), and a contract test fails when a narrowing is missing from it. A comment saying "X is enforced by Y" is checked by running Y (E32: one such comment hid 95 functions the React Compiler skipped).
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` and burn down epic by epic. Fixed some? Regenerate with `--writebaseline`.
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
diff --git a/backend/app/README.md b/backend/app/README.md
index ec7ba9ef..b7c885c5 100644
--- a/backend/app/README.md
+++ b/backend/app/README.md
@@ -72,7 +72,9 @@ mutation it records, so an audit entry can never exist without its change (or vi
## The gate
Everything below must be green before a change is done (run via `siftlode check`):
-`ruff` (backend) · `tsc` ×3 + `eslint` + `prettier` + `knip` (frontend) · `vitest` · `pytest`. The
-pytest lane runs in `backend/docker-compose.test.yml` — an ephemeral Postgres + the backend tree
-bind-mounted — so the DB-backed integration tests (the `db` fixture) actually run; `SIFTLODE_TEST_
-REQUIRE_DB=1` there turns a would-be silent skip into a hard failure. See `docs/development.md`.
+`ruff` + `basedpyright` (backend) · the lock check + `tsc` ×3 + `eslint` + `prettier` + `knip` +
+`vite build` (frontend) · `vitest` · `pytest`. The pytest lane runs in `backend/docker-compose.test.yml`
+— an ephemeral Postgres + the backend tree bind-mounted — so the DB-backed integration tests (the `db`
+fixture) actually run; a missing database is a hard failure, not a silent skip, unless a hand run asks
+for pure-logic only with `SIFTLODE_TEST_REQUIRE_DB=0`. See `docs/development.md`, and `docs/gates.md`
+for what each gate deliberately leaves out.
diff --git a/docs/development.md b/docs/development.md
index 4b4227a2..090f54ac 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -39,36 +39,49 @@ decisions and the chosen patterns, rendered with the app's real CSS. It never re
## The gate
A change is done when all of this is green. CI runs the same checks server-side
-(`.gitea/workflows/check.yml`), so "main is always green".
+(`.gitea/workflows/check.yml`), so "main is always green". What each gate deliberately does NOT
+check — and why — is the [gate register](gates.md); a test fails when a narrowing is missing from it.
**Backend**
```bash
-# lint + format check
+# lint + format check (ruff is pinned in backend/requirements-dev.txt)
python -m ruff check --config backend/ruff.toml backend scripts
python -m ruff format --check --config backend/ruff.toml backend scripts
-# tests — pure-logic + the DB-backed integration lane, against an ephemeral Postgres.
-# The backend tree is bind-mounted, so it always tests your working files.
+# type check — in the test image (requirements.lock does not install on Windows); new errors only,
+# the pre-existing ones are in backend/.basedpyright/baseline.json
+docker compose -f backend/docker-compose.test.yml run --rm --no-deps --build test \
+ python -m basedpyright --project pyrightconfig.json
+
+# tests — pure-logic + the DB-backed integration lane, against an ephemeral Postgres, ending with the
+# coverage total. The backend tree is bind-mounted, so it always tests your working files.
docker compose -f backend/docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from test
docker compose -f backend/docker-compose.test.yml down -v
```
-The test compose sets `SIFTLODE_TEST_REQUIRE_DB=1`, which turns a would-be silent skip (no DB, or no
-ffmpeg) into a hard failure — so the gate can't print green while the DB-backed tests were quietly not
-run. Tests that need a database take the `db` fixture (`backend/tests/conftest.py`); the rest are
-pure-logic and run without one.
+A missing database or ffmpeg is a hard error, not a skip — so the gate can't print green while the
+DB-backed tests were quietly not run (`SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only"
+run for a machine without Postgres). Tests that need a database take the `db` fixture
+(`backend/tests/conftest.py`); the rest are pure-logic and run without one.
**Frontend** (from `frontend/`)
```bash
+node ../scripts/check-node-modules.mjs # node_modules == package-lock.json (else: npm ci)
npm run typecheck # tsc x3 (app / node / e2e)
-npm run lint # eslint
+npm run lint # eslint (typed rules included; warnings fail)
npm run format:check # prettier
-npm run knip # unused exports/deps
-npm run test # vitest
+npm run knip # unused files/exports/deps — and unused by what SHIPS (--production)
+npm run test # vitest (incl. the compiler, size/complexity and gate-register contracts)
+npx vite build # the production bundle (devOnlyGuard fails it on dev-only code)
```
+Coverage is reported, never enforced: `npm run test -- --coverage`, and the pytest lane's last line.
+The Playwright suite (`npm run e2e`) runs against the `:8080` stack built from your tree; the release
+requires it. Before an image is pushed, it is scanned with Trivy (HIGH/CRITICAL with a fix fails;
+exceptions in `.trivyignore.yaml`, each with a reason and an expiry).
+
## Branching
Trunk-based: branch off `main`, keep the branch gate-green, and land the whole change back to `main`
diff --git a/docs/gates.md b/docs/gates.md
new file mode 100644
index 00000000..7616e2ae
--- /dev/null
+++ b/docs/gates.md
@@ -0,0 +1,110 @@
+# Gate register
+
+Every place a gate deliberately checks **less** than it could: a rule switched off or narrowed for a
+scope, a flag left off, a lane that does not run everywhere, an accepted finding. One row each, with
+the reason and the number measured when the row was written.
+
+**Why this file exists (E32).** The worst defect E32 found was not in any code a reviewer read: a lint
+rule had been switched off with 64 real hits counted in its own comment, and the "false positives"
+beside them were the React Compiler skipping 95 functions without a word. The rule since: **a gate
+with any real hit is never switched off or narrowed** — fix the sites, or suppress each one inline and
+name its invariant. Whatever is left narrowed is listed here, and
+[`frontend/src/lib/gates.contract.test.ts`](../frontend/src/lib/gates.contract.test.ts) reads the
+**effective** configuration and fails when a narrowing is not on this page. A row here is a decision,
+not an oversight; a narrowing missing from here is a test failure.
+
+How the test matches: each narrowing must appear in a table row below as its `code-quoted` id, in a
+row whose **Scope** cell names the scope exactly as the test reports it.
+
+## ESLint (frontend)
+
+`npm run lint` is `eslint . --max-warnings 0`, so a rule at **warn** fails the lane exactly like an
+error (the test checks the flag is still there). The warn rows are listed so that claim stays checked.
+A core rule switched off because its typescript-eslint twin replaces it (`no-unused-vars` →
+`@typescript-eslint/no-unused-vars`) is not listed separately; neither are the core rules
+typescript-eslint's compatibility layer switches off for TS files because the type checker reports
+them itself (`no-undef`, `no-redeclare`, `no-dupe-keys`, …), nor the formatting rules
+`eslint-config-prettier` switches off (Prettier owns formatting and has its own lane).
+
+| Scope | Narrowed | Why | Measured |
+| --- | --- | --- | --- |
+| src | `react-hooks/refs` — off | The one rule off while it has real hits. Its analysis IS the React Compiler's: every hit is a component the compiler skips (the popover engine's `getReferenceProps()` / `refs.setReference` passed during render, render-phase ref writes). Too many to fix in one sprint; until they are, `components/compilerCoverage.contract.test.ts` pins every skipped function (none may be added) and E32 S3+ burns them down to turn this on. | 245 hits; 93 skipped functions (E32 S1) |
+| src | `@typescript-eslint/no-unused-vars` — off | tsc owns it (`noUnusedLocals` + `noUnusedParameters` in every project), so a finding is reported once, by one tool. | — |
+| src | `react-hooks/exhaustive-deps` — warn | Preset level; `--max-warnings 0` makes it fail. Every suppression names its invariant (`eslint-comments/require-description`). | 2 suppressions (both drop their whole function from the compiler — E32 S3) |
+| src | `react-hooks/incompatible-library` — warn | Preset level; fails under `--max-warnings 0`. VirtualGrid (TanStack Virtual) is the one site, suppressed inline. | 1 |
+| src | `react-hooks/unsupported-syntax` — warn | Preset level; fails under `--max-warnings 0`. | 0 |
+| src | `react-refresh/only-export-components` — warn | Fails under `--max-warnings 0`; `allowConstantExport` lets a module export constants beside its component. | 0 |
+| src | `@typescript-eslint/no-unused-expressions` — narrowed | `allowShortCircuit` + `allowTernary`: `cond && f()` / `a ? f() : g()` side-effect statements are an idiom here. | — |
+| src tests | `no-restricted-globals`, `no-restricted-properties` — off | A test stands a double in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly. | — |
+| src tests | `local/literal-a11y-text`, `local/button-primitive`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw button as a focus target, an untranslated label) that is not product UI. | — |
+| FocusSafeButton.tsx | `local/focus-safe-disabled` — off | It IS the focus-safe primitive the rule points everyone else to. | — |
+| AnchoredPopover.tsx | `local/focus-safe-disabled` — off | The popover's menu row: its native `disabled` is what floating-ui's roving focus reads to skip it. | — |
+| design-lab | `local/literal-a11y-text` — off | The dev-only design lab (never in the bundle — devOnlyGuard fails the build if it is) holds the owner's working copy, not product copy. | — |
+| e2e | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | Playwright specs drive a browser from Node: no React, no product UI, and they print to the console on purpose. They get the base + typed rules + the playwright rules. | — |
+| ts configs | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | The build configs (vite, vitest, playwright, reactPlugin, devOnlyGuard) run in Node at build time. | — |
+| js configs | `@typescript-eslint/*`, `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax`, `no-var`, `prefer-const`, `prefer-rest-params`, `prefer-spread`, `no-array-constructor`, `no-unused-expressions` — not applied | `eslint.config.js` and `postcss.config.js`: plain-JS build config, linted with `js.configs.recommended` only. | — |
+| everywhere | ignored paths: `dist`, `node_modules`, `playwright-report`, `test-results`, `e2e/.auth` | Build output, installed packages, test artefacts, a live Playwright session. Generated, not written. | — |
+
+**Not enabled** (decided, not forgotten): `strictTypeChecked` — owner decision E32 D3, ~2000 hits with
+none real in the sample; the curated typed pair `no-floating-promises` + `no-deprecated` runs instead.
+
+## TypeScript
+
+| Scope | Narrowed | Why | Measured |
+| --- | --- | --- | --- |
+| all three projects | `skipLibCheck` | The `.d.ts` of installed packages are not type-checked (ours are). Standard for an app: a package's own types are its maintainers' gate, and checking them costs minutes and reports errors we cannot fix. | — |
+
+Every project has `strict`, `noUncheckedIndexedAccess`, `noUnusedLocals`, `noUnusedParameters` and
+`noFallthroughCasesInSwitch` on; the test fails if one goes off.
+
+## Ruff (backend + scripts)
+
+| Scope | Narrowed | Why | Measured |
+| --- | --- | --- | --- |
+| backend + scripts | `E402` — ignored | A few modules (`main.py`, `scripts/e2e_seed.py`) run setup — logging, `sys.path` — before their imports on purpose. | — |
+| backend + scripts | `B008` — narrowed: `fastapi.Depends`, `fastapi.Query` declared immutable | FastAPI's dependency injection IS a call in a default argument, on every route. Every other call in a default still fails. | 474 sites, all of them FastAPI |
+| backend + scripts | `C901` — not in ruff; measured by `tests/test_source_size.py` | Ruff has no per-function baseline, so turning it on would need a `noqa` per offender, and a `noqa` lets a function grow without limit. The test holds each offender at its current value. | 32 functions over 10 |
+
+## basedpyright (backend)
+
+| Scope | Narrowed | Why | Measured |
+| --- | --- | --- | --- |
+| backend/app + alembic | `typeCheckingMode` — `standard` | Not `strict`/`all`: those report on every untyped third-party boundary (SQLAlchemy rows, yt-dlp dicts) — thousands of findings, near-zero real ones. `standard` was measured at 7 findings, all fixed (E32 S1). | — |
+| backend/app + alembic | `baseline.json` — pre-existing errors absorbed | The lane fails on NEW errors only; the baseline only shrinks (the lane fails when a run tightens it and it is not committed). Matching is per (file, rule, column span) + count, so burn the hot files first. | 195 entries (E32 S1) |
+| tests/ | not type-checked | A test's job is to fail loudly when wrong — the opposite of the silent runtime failure this lane catches — and annotating fixtures for a checker is churn. | — |
+
+## pytest (backend)
+
+| Scope | Narrowed | Why | Measured |
+| --- | --- | --- | --- |
+| a hand run | `SIFTLODE_TEST_REQUIRE_DB=0` — the DB-backed and ffmpeg tests skip | The explicit "pure-logic only" run for a machine without Postgres. Off by default since E32: without it a missing DB is a hard error; the gate and CI never set it. | — |
+
+## knip (frontend)
+
+| Scope | Narrowed | Why | Measured |
+| --- | --- | --- | --- |
+| --production | `!**/*.testkit.ts!`, `!e2e/**!` | Test kits and the e2e suite are test code: the production pass asks what the SHIPPED code uses. | — |
+| --production | `!reactPlugin.ts!`, `!devOnlyGuard.ts!` | Build tooling the vite config imports: it shapes the bundle but is not in it. | — |
+| --production | `@internal` exports | An export only its test imports says so with a JSDoc `@internal` tag naming the test; knip skips those in production mode. | 35 (E32 S1) |
+
+## Lanes and pipeline
+
+| Scope | Narrowed | Why | Measured |
+| --- | --- | --- | --- |
+| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 5 specs × 3 engines |
+| CI | no e2e, no Trivy | The lab runner has no browser stack, and Trivy's DB download is not reachable from it. Both run in `release` / `publish` on the dev machine, before anything is pushed. | — |
+| coverage | no threshold | Owner decision E32 D4: reported on both sides, never enforced. "Every regression test seen red" is the bar; a percentage invites tests written to move it. | frontend 80% / backend 78% of lines (E32 S1) |
+| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
+| npm audit (CI) | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
+| React Compiler | 93 functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) |
+| size / complexity | today's offenders as ceilings | `src/lib/sourceSize.contract.test.ts`, `backend/tests/test_source_size.py`: listed files and functions may not grow, nothing new may cross the limit, an entry back under it must be deleted. | files >800 lines: 13 FE / 11 BE; functions over the limit: 64 FE / 32 BE |
+
+## Accepted findings
+
+Entries in `.trivyignore.yaml` (CVE gate) and `--ignore-vuln` flags on CI's `pip-audit` step. Each
+must have a row here (Scope `accepted`) with its reason and its expiry; the test fails on an entry
+without one, and on a `.trivyignore.yaml` entry with no `statement` or no `expired_at`.
+
+| Scope | Finding | Why | Expires |
+| --- | --- | --- | --- |
+| — | none | — | — |
diff --git a/frontend/src/lib/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts
new file mode 100644
index 00000000..32444e82
--- /dev/null
+++ b/frontend/src/lib/gates.contract.test.ts
@@ -0,0 +1,223 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { ESLint, type Linter } from "eslint";
+import prettierConfig from "eslint-config-prettier";
+import ts from "typescript";
+import tseslint from "typescript-eslint";
+import { describe, expect, it } from "vitest";
+
+// The gate register (docs/gates.md) is held to the EFFECTIVE configuration (E32). A narrowing that
+// is not on the register fails here — a rule switched off for a scope, a weak compiler flag, a ruff
+// ignore, a knip exclusion, an accepted CVE. So "the gate checks X" is a claim this test verifies,
+// not one a comment makes. (The comment that claimed the React Compiler's rules "are also linted"
+// stood for months while the rule that would have said so was off.)
+//
+// Each narrowing is a (scope, id) pair; the register must hold a table row whose first cell is the
+// scope and which names the id in backticks.
+
+const REPO = fileURLToPath(new URL("../../../", import.meta.url));
+const FRONTEND = `${REPO}frontend/`;
+const read = (path: string) => readFileSync(REPO + path, "utf8");
+
+/** register scope → every backticked id on its rows. */
+function register(): Map> {
+ const rows = new Map>();
+ for (const line of read("docs/gates.md").split("\n")) {
+ if (!line.startsWith("|") || /^\|(\s*-+\s*\|)+\s*$/.test(line)) continue; // not the |---| rule
+ const scope = line.split("|")[1]!.trim();
+ const ids = rows.get(scope) ?? new Set();
+ for (const m of line.matchAll(/`([^`]+)`/g)) ids.add(m[1]!);
+ rows.set(scope, ids);
+ }
+ return rows;
+}
+
+function missingFrom(found: [scope: string, id: string][]): string[] {
+ const reg = register();
+ return found.filter(([scope, id]) => !reg.get(scope)?.has(id)).map(([s, id]) => `${s}: ${id}`);
+}
+
+const jsonc = (path: string) => {
+ const { config, error } = ts.parseConfigFileTextToJson(path, read(path));
+ if (error)
+ throw new Error(`${path}: ${ts.flattenDiagnosticMessageText(error.messageText, "\n")}`);
+ return config as Record;
+};
+
+// --- ESLint ---------------------------------------------------------------------------------------
+
+type Rules = Partial;
+const severity = (v: Linter.RuleEntry | undefined): number => {
+ if (v === undefined) return -1; // not applied at all
+ const s = Array.isArray(v) ? v[0] : v;
+ return s === 2 || s === "error" ? 2 : s === 1 || s === "warn" ? 1 : 0;
+};
+// Formatting is Prettier's (its own lane): the rules eslint-config-prettier switches off are not a
+// narrowing of anything.
+const PRETTIER_OFF = new Set(
+ Object.entries(prettierConfig.rules ?? {})
+ .filter(([, v]) => severity(v as Linter.RuleEntry) === 0)
+ .map(([r]) => r),
+);
+// …and the core rules typescript-eslint's compatibility layer switches off for TS files because the
+// type checker reports them itself (no-undef, no-redeclare, no-dupe-keys, …).
+const TSC_OWNED = new Set(
+ Object.entries(tseslint.configs.eslintRecommended.rules ?? {})
+ .filter(([, v]) => severity(v as Linter.RuleEntry) === 0)
+ .map(([r]) => r),
+);
+const family = (rule: string) => (rule.includes("/") ? `${rule.split("/")[0]}/*` : rule);
+
+const SCOPES: Record = {
+ src: "src/components/Feed.tsx",
+ "src tests": "src/components/Feed.ytsort.dom.test.tsx",
+ "FocusSafeButton.tsx": "src/components/ui/FocusSafeButton.tsx",
+ "AnchoredPopover.tsx": "src/components/ui/AnchoredPopover.tsx",
+ "design-lab": "src/design-lab/batchA.tsx",
+ e2e: "e2e/nav.spec.ts",
+ "ts configs": "vite.config.ts",
+ "js configs": "eslint.config.js",
+};
+
+async function eslintNarrowings(): Promise<[string, string][]> {
+ const eslint = new ESLint({ cwd: FRONTEND });
+ const rules: Record = {};
+ for (const [scope, file] of Object.entries(SCOPES))
+ rules[scope] = ((await eslint.calculateConfigForFile(file)) as Linter.Config).rules ?? {};
+ const src = rules.src!;
+ const ignorable = (rule: string, all: Rules) =>
+ PRETTIER_OFF.has(rule) ||
+ TSC_OWNED.has(rule) ||
+ // a core rule off because its typescript-eslint twin replaces it is reported as the twin
+ (!rule.includes("/") && `@typescript-eslint/${rule}` in all);
+ const found: [string, string][] = [];
+ // src: everything at off or warn
+ for (const [rule, v] of Object.entries(src))
+ if (severity(v) < 2 && !ignorable(rule, src)) found.push(["src", rule]);
+ // every other scope: what is WEAKER there than in src (a rule family not applied at all is one id)
+ for (const scope of Object.keys(SCOPES).filter((s) => s !== "src")) {
+ const ids = new Set();
+ for (const [rule, v] of Object.entries(src)) {
+ if (ignorable(rule, src) || severity(v) < 2) continue;
+ const there = severity(rules[scope]![rule]);
+ if (there < 2 && !(there === 1 && severity(v) === 1))
+ ids.add(there < 0 ? family(rule) : rule);
+ }
+ for (const id of ids) found.push([scope, id]);
+ }
+ for (const ignored of eslintIgnores()) found.push(["everywhere", ignored]);
+ return found;
+}
+
+/** The global `ignores` of eslint.config.js (the first, file-less config object). */
+function eslintIgnores(): string[] {
+ const m = /\{\s*ignores:\s*\[([^\]]*)\]\s*\}/.exec(read("frontend/eslint.config.js"));
+ return m ? [...m[1]!.matchAll(/"([^"]+)"/g)].map((x) => x[1]!) : [];
+}
+
+describe("the gate register (docs/gates.md)", () => {
+ it("lists every ESLint rule narrowed, by scope", async () => {
+ expect(missingFrom(await eslintNarrowings())).toEqual([]);
+ }, 60_000);
+
+ it("keeps --max-warnings 0, which is what makes a warn-level rule fail", () => {
+ const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record };
+ expect(pkg.scripts.lint).toMatch(/--max-warnings 0\b/);
+ });
+
+ // --- TypeScript ---------------------------------------------------------------------------------
+ const PROJECTS = [
+ "frontend/tsconfig.json",
+ "frontend/tsconfig.node.json",
+ "frontend/e2e/tsconfig.json",
+ ];
+ const REQUIRED_ON = [
+ "strict",
+ "noUncheckedIndexedAccess",
+ "noUnusedLocals",
+ "noUnusedParameters",
+ "noFallthroughCasesInSwitch",
+ ];
+ it.each(PROJECTS)("%s keeps the strict flags on", (path) => {
+ const opts = jsonc(path).compilerOptions as Record;
+ expect(REQUIRED_ON.filter((flag) => opts[flag] !== true)).toEqual([]);
+ });
+ it("lists every weakening compiler flag", () => {
+ const found: [string, string][] = [];
+ for (const path of PROJECTS) {
+ const opts = jsonc(path).compilerOptions as Record;
+ if (opts.skipLibCheck === true) found.push(["all three projects", "skipLibCheck"]);
+ }
+ expect(missingFrom(found)).toEqual([]);
+ });
+
+ // --- Ruff -----------------------------------------------------------------------------------------
+ it("lists every ruff ignore and immutable call", () => {
+ const toml = read("backend/ruff.toml");
+ const list = (key: string) => {
+ const m = new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m").exec(toml);
+ return m ? [...m[1]!.matchAll(/"([^"]+)"/g)].map((x) => x[1]!) : [];
+ };
+ const found: [string, string][] = [
+ ...list("ignore").map((code): [string, string] => ["backend + scripts", code]),
+ ...list("extend-immutable-calls").map((c): [string, string] => ["backend + scripts", c]),
+ ];
+ expect(toml).not.toMatch(/per-file-ignores/); // none today — a new one needs a row and this line
+ expect(missingFrom(found)).toEqual([]);
+ });
+
+ // --- basedpyright ---------------------------------------------------------------------------------
+ it("lists the type-check mode, the baseline and every rule override", () => {
+ const cfg = jsonc("backend/pyrightconfig.json");
+ const found: [string, string][] = [];
+ if (cfg.typeCheckingMode !== "all" && cfg.typeCheckingMode !== "strict")
+ found.push(["backend/app + alembic", "typeCheckingMode"]);
+ found.push(["backend/app + alembic", "baseline.json"]);
+ for (const [key, value] of Object.entries(cfg))
+ if (key.startsWith("report") && (value === false || value === "none" || value === "warning"))
+ found.push(["backend/app + alembic", key]);
+ expect(cfg.include).toEqual(["app", "alembic"]); // tests/ out — its row says why
+ expect(missingFrom(found)).toEqual([]);
+ });
+
+ // --- pytest ---------------------------------------------------------------------------------------
+ it("keeps pytest strict: no deselection, no warning filters, --strict-markers", () => {
+ const ini = read("backend/pytest.ini");
+ expect(ini).toMatch(/--strict-markers/);
+ expect(ini).not.toMatch(/--deselect|--ignore|-p no:|filterwarnings|\s-k\s/);
+ });
+
+ // --- knip -----------------------------------------------------------------------------------------
+ it("lists every knip production exclusion and ignore", () => {
+ const cfg = jsonc("frontend/knip.jsonc") as { project?: string[] } & Record;
+ const found: [string, string][] = (cfg.project ?? [])
+ .filter((p) => p.startsWith("!"))
+ .map((p): [string, string] => ["--production", p]);
+ for (const key of Object.keys(cfg).filter((k) => k.startsWith("ignore")))
+ found.push(["knip", key]);
+ expect(missingFrom(found)).toEqual([]);
+ });
+
+ // --- accepted findings ----------------------------------------------------------------------------
+ it("accepts a CVE only with a statement, an expiry, and a row", () => {
+ const yaml = read(".trivyignore.yaml");
+ const entries = yaml
+ .split(/\n\s*- id:\s*/)
+ .slice(1)
+ .map((chunk) => ({
+ id: chunk.split("\n")[0]!.trim(),
+ statement: /\n\s*statement:\s*\S/.test(chunk),
+ expires: /\n\s*expired_at:\s*\d{4}-\d{2}-\d{2}/.test(chunk),
+ }));
+ expect(entries.filter((e) => !e.statement || !e.expires).map((e) => e.id)).toEqual([]);
+ const pipIgnores = read(".gitea/workflows/check.yml")
+ .split("\n")
+ .filter((l) => !l.trim().startsWith("#")) // the step's comment explains the flag; it is not one
+ .flatMap((l) => [...l.matchAll(/--ignore-vuln[ =](\S+)/g)].map((m) => m[1]!));
+ const found = [...entries.map((e) => e.id), ...pipIgnores].map((id): [string, string] => [
+ "accepted",
+ id,
+ ]);
+ expect(missingFrom(found)).toEqual([]);
+ });
+});
From 614d72856124db929872d3b250e7f6dc017493aa Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 17:41:26 +0200
Subject: [PATCH 12/54] test(e2e): the nav spec opens the admin pages from
their rail panel
E31 put the admin pages behind one rail row ("Administration", nav-admin)
that opens a panel. The spec still clicked nav-scheduler, nav-config,
nav-users and nav-audit as rows of their own, and timed out on every engine.
That went unseen from E31 to now because e2e ran in no required gate, the
exact hole E32 closes: the first run of the new release e2e step found it.
The rail test keeps the rail rows. A new test opens each admin page from the
panel and checks that the group row is current and that the reopened panel
marks the page. Seen red before the fix; the whole suite then passes, 52/52.
---
frontend/e2e/nav.spec.ts | 34 ++++++++++++++++++++++------------
1 file changed, 22 insertions(+), 12 deletions(-)
diff --git a/frontend/e2e/nav.spec.ts b/frontend/e2e/nav.spec.ts
index 4427e1bc..5637be7d 100644
--- a/frontend/e2e/nav.spec.ts
+++ b/frontend/e2e/nav.spec.ts
@@ -6,18 +6,12 @@ import { openApp } from "./helpers";
// this exercises is precisely what the module-registry refactor replaces.
test.describe("page switching", () => {
// Core + admin modules (the E2E account is an admin, matching the real primary user), so this
- // also exercises the render ternary's admin branches (scheduler / config / users / audit).
- const RAIL = [
- "channels",
- "playlists",
- "stats",
- "scheduler",
- "config",
- "users",
- "audit",
- "settings",
- "feed",
- ];
+ // also exercises the admin pages. Since E31 those sit behind ONE rail row ("Administration",
+ // `nav-admin`) that opens a panel of them — the spec still listed them as rail rows of their own,
+ // and it failed on every run from E31 to E32 without anyone seeing it, because e2e was in no
+ // required gate (it is now part of `release`).
+ const RAIL = ["channels", "playlists", "stats", "settings", "feed"];
+ const ADMIN = ["scheduler", "config", "users", "audit"];
test("each rail item activates its page", async ({ page }) => {
await openApp(page);
@@ -27,6 +21,22 @@ test.describe("page switching", () => {
}
});
+ test("each admin page opens from the Administration panel and marks it current", async ({
+ page,
+ }) => {
+ await openApp(page);
+ for (const id of ADMIN) {
+ await page.getByTestId("nav-admin").click();
+ await page.getByTestId(`nav-admin-${id}`).click();
+ // The panel closes on a pick; the group row carries the current page…
+ await expect(page.getByTestId("nav-admin")).toHaveAttribute("aria-current", "page");
+ // …and, reopened, the panel marks which one.
+ await page.getByTestId("nav-admin").click();
+ await expect(page.getByTestId(`nav-admin-${id}`)).toHaveAttribute("aria-current", "page");
+ await page.keyboard.press("Escape");
+ }
+ });
+
test("browser Back steps through visited pages", async ({ page }) => {
await openApp(page);
From ca9ca85b18f21659fd31ce56032f0c3c148e83ca Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 19:06:47 +0200
Subject: [PATCH 13/54] test: no test throws a response away; the stream
session is narrowed, not baselined
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 7 baselined Optional accesses in plex/stream.py were one fact the type did not carry: the
initial spawn (replace_only=None) always supersedes, so only the fallback respawn can get None.
An assert at the call site says so; the pyright baseline drops 195 -> 188.
test_abuse_limits: the public-URL and YouTube enqueue tests stopped at the profile lookup
(400 no_profile) and passed on that unrelated refusal. They now send an inline spec and assert
the job is queued. The same shape — a request whose response is discarded — sat at 53 more
places across the suite (all returned what they should, measured with a TestClient probe);
each now asserts its status, and test_response_checked keeps it that way (seen red).
---
backend/.basedpyright/baseline.json | 58 -------------------
backend/app/plex/stream.py | 4 +-
backend/tests/test_abuse_limits.py | 27 +++++++--
backend/tests/test_debug_sessions.py | 38 +++++++-----
backend/tests/test_diagnostics.py | 37 ++++++++----
backend/tests/test_email_canonical.py | 2 +-
backend/tests/test_last_login.py | 7 ++-
backend/tests/test_logs.py | 10 ++--
backend/tests/test_mail_language.py | 44 +++++++++++---
backend/tests/test_playlist_add_position.py | 2 +-
backend/tests/test_plex_image_sizes.py | 2 +-
.../tests/test_plex_playlist_add_positions.py | 7 ++-
backend/tests/test_plex_subtitle_prepare.py | 2 +-
backend/tests/test_reset_link_states.py | 7 ++-
backend/tests/test_response_checked.py | 58 +++++++++++++++++++
backend/tests/test_review_round.py | 51 +++++++++++-----
backend/tests/test_saved_views_source.py | 4 +-
backend/tests/test_set_password_link.py | 8 ++-
backend/tests/test_welcome_mail.py | 7 ++-
19 files changed, 246 insertions(+), 129 deletions(-)
create mode 100644 backend/tests/test_response_checked.py
diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json
index 5ea56f47..a669dffb 100644
--- a/backend/.basedpyright/baseline.json
+++ b/backend/.basedpyright/baseline.json
@@ -548,64 +548,6 @@
}
}
],
- "./app/plex/stream.py": [
- {
- "code": "reportOptionalMemberAccess",
- "range": {
- "startColumn": 51,
- "endColumn": 55,
- "lineCount": 1
- }
- },
- {
- "code": "reportOptionalMemberAccess",
- "range": {
- "startColumn": 23,
- "endColumn": 25,
- "lineCount": 1
- }
- },
- {
- "code": "reportOptionalMemberAccess",
- "range": {
- "startColumn": 18,
- "endColumn": 31,
- "lineCount": 1
- }
- },
- {
- "code": "reportOptionalMemberAccess",
- "range": {
- "startColumn": 75,
- "endColumn": 79,
- "lineCount": 1
- }
- },
- {
- "code": "reportOptionalMemberAccess",
- "range": {
- "startColumn": 10,
- "endColumn": 23,
- "lineCount": 1
- }
- },
- {
- "code": "reportOptionalMemberAccess",
- "range": {
- "startColumn": 10,
- "endColumn": 12,
- "lineCount": 1
- }
- },
- {
- "code": "reportOptionalMemberAccess",
- "range": {
- "startColumn": 10,
- "endColumn": 17,
- "lineCount": 1
- }
- }
- ],
"./app/plex/sync.py": [
{
"code": "reportArgumentType",
diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py
index b25d1e24..f9ac6caa 100644
--- a/backend/app/plex/stream.py
+++ b/backend/app/plex/stream.py
@@ -745,8 +745,7 @@ def start_session(
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
# seek-restart) installed between the fallback's decision and here is never clobbered. When the
# registered session is no longer `replace_only`, we abort and return None (the caller yields to
- # whoever is registered now). The INITIAL spawn passes replace_only=None and always supersedes —
- # that is the seek-restart's intended last-writer-wins.
+ # whoever is registered now). The INITIAL spawn never gets None — see its call site.
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
@@ -823,6 +822,7 @@ def start_session(
return s
s = _spawn(*_select_encode(db, item, transcode, src))
+ assert s is not None # replace_only=None always supersedes: a seek-restart's last-writer-wins
# Wait for the first video segment outside the lock (this blocks on ffmpeg, and we must not hold up
# other sessions). Pass the proc so a HW session that dies at VAAPI init fails fast (not after 25s).
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
diff --git a/backend/tests/test_abuse_limits.py b/backend/tests/test_abuse_limits.py
index 6ff1ca84..0593cacb 100644
--- a/backend/tests/test_abuse_limits.py
+++ b/backend/tests/test_abuse_limits.py
@@ -36,9 +36,14 @@ def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch):
"getaddrinfo",
lambda *a, **kw: [(None, None, None, "", ("93.184.216.34", 0))],
)
- r = as_user(user).post("/api/downloads", json={"source": "https://example.com/v"})
- detail = r.json().get("detail") if r.status_code >= 400 else None
- assert not (isinstance(detail, dict) and detail.get("code") == "url_blocked")
+ # An inline spec, so nothing ELSE refuses it: without one the request stopped at the
+ # profile lookup (400 no_profile) and "not url_blocked" held for a request the guard never
+ # had to wave through (E32 S2).
+ r = as_user(user).post(
+ "/api/downloads", json={"source": "https://example.com/v", "spec": {"format": "best"}}
+ )
+ assert r.status_code == 200, r.text
+ assert r.json()["status"] == "queued"
def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeypatch):
@@ -50,7 +55,13 @@ def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeyp
user = make_user()
db.commit()
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
- as_user(user).post("/api/downloads", json={"source": "dQw4w9WgXcQ"})
+ # The job must actually be created: a request refused for any other reason would never reach
+ # the guard either, and the test would pass without proving anything (E32 S2).
+ r = as_user(user).post(
+ "/api/downloads", json={"source": "dQw4w9WgXcQ", "spec": {"format": "best"}}
+ )
+ assert r.status_code == 200, r.text
+ assert r.json()["source_kind"] == "youtube"
def test_manual_subscription_sync_goes_on_cooldown(db, as_user, make_user, monkeypatch):
@@ -81,8 +92,12 @@ def test_the_cooldown_is_per_user_not_global(db, as_user, make_user, monkeypatch
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
ca = as_user(a)
- for _ in range(4):
- ca.post("/api/sync/subscriptions")
+ assert [ca.post("/api/sync/subscriptions").status_code for _ in range(4)] == [
+ 200,
+ 200,
+ 200,
+ 429,
+ ]
assert ca.post("/api/sync/subscriptions").status_code == 429
assert as_user(b).post("/api/sync/subscriptions").status_code == 200
diff --git a/backend/tests/test_debug_sessions.py b/backend/tests/test_debug_sessions.py
index 6a791922..30f20bc8 100644
--- a/backend/tests/test_debug_sessions.py
+++ b/backend/tests/test_debug_sessions.py
@@ -151,7 +151,7 @@ def test_nobody_reaches_another_users_session(db, make_user, as_user):
def test_the_list_is_the_users_own_newest_first(db, make_user, as_user):
client = as_user(make_user())
first = _start(client)["id"]
- client.post(f"/api/debug-sessions/{first}/stop")
+ assert client.post(f"/api/debug-sessions/{first}/stop").status_code == 200
second = _start(client)["id"]
assert [s["id"] for s in client.get("/api/debug-sessions").json()["items"]] == [second, first]
@@ -199,10 +199,13 @@ def test_an_erased_account_takes_its_sessions_with_it(db, make_user, as_user):
user = make_user()
client = as_user(user)
sid = _start(client)["id"]
- client.post(
- f"/api/debug-sessions/{sid}/screenshots",
- content=_PNG,
- headers={"content-type": "image/png"},
+ assert (
+ client.post(
+ f"/api/debug-sessions/{sid}/screenshots",
+ content=_PNG,
+ headers={"content-type": "image/png"},
+ ).status_code
+ == 200
)
purge_user(db, user, BackgroundTasks())
assert db.get(DebugSession, sid) is None
@@ -250,7 +253,7 @@ def test_a_request_naming_the_own_open_session_is_tagged_and_raised(gated, db, c
_signed_in(client, make_user)
sid = _start(client, "DEBUG")["id"]
gated.records.clear()
- client.get("/api/debug-sessions", headers={"x-debug-session": sid})
+ assert client.get("/api/debug-sessions", headers={"x-debug-session": sid}).status_code == 200
(line,) = _request_lines(gated)
assert getattr(line, "debug_session_id", None) == sid
# Raised to the session's level: the line is kept for storage though the user's own is Alap.
@@ -263,7 +266,7 @@ def test_a_header_naming_anything_else_is_ignored_not_refused(gated, db, client,
db.commit()
_signed_in(client, make_user)
own = _start(client)["id"]
- client.post(f"/api/debug-sessions/{own}/stop")
+ assert client.post(f"/api/debug-sessions/{own}/stop").status_code == 200
for value in (foreign, own, "not-a-session", "f" * 16, "A" * 16):
gated.records.clear()
# Not `/api/me`: a polled route logs at DEBUG, and an un-raised request would drop the very
@@ -280,7 +283,7 @@ def test_a_header_naming_the_own_session_past_its_cap_is_ignored(gated, db, clie
sid = _start(client)["id"]
_expire(db, sid) # past its cap, not yet swept: still open in the table
gated.records.clear()
- client.get("/api/debug-sessions", headers={"x-debug-session": sid})
+ assert client.get("/api/debug-sessions", headers={"x-debug-session": sid}).status_code == 200
(line,) = _request_lines(gated)
assert getattr(line, "debug_session_id", None) is None
assert getattr(line, "elevated", False) is False
@@ -616,7 +619,7 @@ def test_a_users_screenshots_are_capped_across_their_sessions(recording, monkeyp
client, sid = recording
monkeypatch.setattr(debugsession, "SCREENSHOT_USER_MAX_BYTES", len(_PNG) * 2)
assert _upload(client, sid, _PNG).status_code == 200
- client.post(f"/api/debug-sessions/{sid}/stop")
+ assert client.post(f"/api/debug-sessions/{sid}/stop").status_code == 200
other = _start(client)["id"]
assert _upload(client, other, _PNG).status_code == 200
r = _upload(client, other, _PNG)
@@ -631,7 +634,7 @@ def test_new_sessions_are_paced_but_joining_one_is_not(db, make_user, as_user, m
client = as_user(make_user())
sid = _start(client)["id"]
assert _start(client)["id"] == sid # another tab joining
- client.post(f"/api/debug-sessions/{sid}/stop")
+ assert client.post(f"/api/debug-sessions/{sid}/stop").status_code == 200
r = client.post("/api/debug-sessions", json={"level": "DEBUG"})
assert r.status_code == 429 and r.json()["detail"]["reason"] == "debug_session_rate_limited"
@@ -745,9 +748,16 @@ def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make
db.commit()
_upload(client, sid, _PNG)
_upload(client, sid, _JPEG)
- client.put(
- f"/api/debug-sessions/{sid}/draft",
- json={"title": "Spinner stays", "body": "It spins.", "excluded": [dropped_web.id, 999999]},
+ assert (
+ client.put(
+ f"/api/debug-sessions/{sid}/draft",
+ json={
+ "title": "Spinner stays",
+ "body": "It spins.",
+ "excluded": [dropped_web.id, 999999],
+ },
+ ).status_code
+ == 200
)
r = client.get(f"/api/debug-sessions/{sid}/export")
@@ -1144,7 +1154,7 @@ def test_new_issues_are_paced_per_user(gitea_fake, db, make_user, as_user, monke
first = _start(client)["id"]
_ready(client, first)
assert client.post(f"/api/debug-sessions/{first}/issue").status_code == 200
- client.post(f"/api/debug-sessions/{first}/stop")
+ assert client.post(f"/api/debug-sessions/{first}/stop").status_code == 200
second = _start(client)["id"]
_ready(client, second)
r = client.post(f"/api/debug-sessions/{second}/issue")
diff --git a/backend/tests/test_diagnostics.py b/backend/tests/test_diagnostics.py
index a64b4712..93ea40ce 100644
--- a/backend/tests/test_diagnostics.py
+++ b/backend/tests/test_diagnostics.py
@@ -212,7 +212,12 @@ def test_a_timestamp_outside_the_consent_window_is_clamped(elevated):
now = datetime.now(timezone.utc)
future = (now + timedelta(days=365)).isoformat()
past = (now - timedelta(days=30)).isoformat()
- client.post("/api/diagnostics/events", json={"events": [_event(ts=future), _event(ts=past)]})
+ assert (
+ client.post(
+ "/api/diagnostics/events", json={"events": [_event(ts=future), _event(ts=past)]}
+ ).status_code
+ == 200
+ )
late, early = rows()
assert late.ts <= datetime.now(timezone.utc)
assert early.ts >= now - timedelta(hours=diagnostics.ELEVATION_HOURS, minutes=1)
@@ -342,15 +347,15 @@ def _app() -> TestClient:
def test_the_account_reaches_the_handlers_own_lines(gated):
- _app().get("/api/thing/41")
+ assert _app().get("/api/thing/41").status_code == 200
line = next(r for r in gated.records if r.getMessage() == "an ordinary line")
assert getattr(line, "user_id", None) == 41
def test_one_users_elevation_logs_their_requests_in_detail_and_nobody_elses(gated):
client = _app()
- client.get("/api/thing/7", params={"level": "DEBUG"})
- client.get("/api/thing/8")
+ assert client.get("/api/thing/7", params={"level": "DEBUG"}).status_code == 200
+ assert client.get("/api/thing/8").status_code == 200
detail = [r for r in gated.records if r.getMessage().startswith("detail only")]
assert [r.user_id for r in detail] == [7]
assert getattr(detail[0], "elevated", False) is True
@@ -363,9 +368,9 @@ def test_one_users_elevation_logs_their_requests_in_detail_and_nobody_elses(gate
def test_the_root_level_returns_to_the_base_when_the_elevation_ends(gated):
client = _app()
- client.get("/api/thing/7", params={"level": "TRACE"})
+ assert client.get("/api/thing/7", params={"level": "TRACE"}).status_code == 200
assert logging.getLogger().level == logs.TRACE
- client.get("/api/thing/7") # the same user, back at the base
+ assert client.get("/api/thing/7").status_code == 200 # the same user, back at the base
assert logging.getLogger().level == logging.INFO
logs._track_elevation(9, logging.DEBUG, datetime.now(timezone.utc) - timedelta(seconds=1))
assert logging.getLogger().level == logging.INFO # an already-expired one is pruned at once
@@ -373,15 +378,25 @@ def test_the_root_level_returns_to_the_base_when_the_elevation_ends(gated):
def test_the_query_string_is_logged_only_for_a_traced_request_and_redacted(gated):
client = _app()
- client.get("/api/thing/7", params={"level": "DEBUG", "q": "lofi"})
- client.get("/api/thing/7", params={"level": "TRACE", "q": "lofi", "token": "SECRET"})
+ assert client.get("/api/thing/7", params={"level": "DEBUG", "q": "lofi"}).status_code == 200
+ assert (
+ client.get(
+ "/api/thing/7", params={"level": "TRACE", "q": "lofi", "token": "SECRET"}
+ ).status_code
+ == 200
+ )
lines = [r for r in gated.records if getattr(r, "event", None) == "http.request"]
assert "query" not in lines[0].attrs
assert "q=lofi" in lines[1].attrs["query"] and "SECRET" not in lines[1].attrs["query"]
def test_an_address_typed_into_a_search_is_redacted_though_the_browser_encodes_it(gated):
- _app().get("/api/thing/7?level=TRACE&q=someone%40example.com&new_password=hunter2")
+ assert (
+ _app()
+ .get("/api/thing/7?level=TRACE&q=someone%40example.com&new_password=hunter2")
+ .status_code
+ == 200
+ )
(line,) = [r for r in gated.records if getattr(r, "event", None) == "http.request"]
assert line.attrs["query"] == "level=TRACE&q=[email]&new_password=[redacted]"
@@ -400,7 +415,7 @@ def test_a_credential_param_goes_whole_however_its_value_decodes(raw):
def test_an_admin_level_of_trace_does_not_log_other_users_query_strings(gated):
logs.set_level("TRACE")
- _app().get("/api/thing/7", params={"q": "something private"})
+ assert _app().get("/api/thing/7", params={"q": "something private"}).status_code == 200
(line,) = [r for r in gated.records if getattr(r, "event", None) == "http.request"]
assert "query" not in line.attrs
@@ -488,7 +503,7 @@ def test_a_real_sign_in_carries_the_consented_level_into_the_process(gated, clie
user = _signed_in(client, make_user)
assert client.put("/api/me/diagnostics", json={"level": "DEBUG"}).status_code == 200
logs._elevations.clear() # only a request resolving the account through auth may put it back
- client.get("/api/me")
+ assert client.get("/api/me").status_code == 200
assert logs._elevations[user.id][0] == logging.DEBUG
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
assert user.id not in logs._elevations
diff --git a/backend/tests/test_email_canonical.py b/backend/tests/test_email_canonical.py
index 1fd45233..71d49f9c 100644
--- a/backend/tests/test_email_canonical.py
+++ b/backend/tests/test_email_canonical.py
@@ -334,7 +334,7 @@ def test_me_reports_the_google_address_only_when_it_differs(client, db, make_use
)
assert client.get("/api/me").json()["google_email"] is None
- client.post("/auth/logout")
+ assert client.post("/auth/logout").status_code == 200
tagged = make_user(email="me+siftlode@gmail.com", password=_UAT_PW)
tagged.google_email = "me@gmail.com"
db.commit()
diff --git a/backend/tests/test_last_login.py b/backend/tests/test_last_login.py
index 02256d04..9b27aaff 100644
--- a/backend/tests/test_last_login.py
+++ b/backend/tests/test_last_login.py
@@ -100,7 +100,12 @@ def test_the_admin_user_list_carries_last_login(as_user, db, make_user):
def test_the_account_sees_its_own_last_sign_in(client, db, make_user):
# Settings → Account shows it (prod UAT 2026-09-19: only the admin's user list did).
make_user(email="me@example.com", password=_PW)
- client.post("/auth/password-login", json={"email": "me@example.com", "password": _PW})
+ assert (
+ client.post(
+ "/auth/password-login", json={"email": "me@example.com", "password": _PW}
+ ).status_code
+ == 200
+ )
stamp = client.get("/api/me").json()["last_login_at"]
diff --git a/backend/tests/test_logs.py b/backend/tests/test_logs.py
index 86363f67..0d77f296 100644
--- a/backend/tests/test_logs.py
+++ b/backend/tests/test_logs.py
@@ -277,7 +277,7 @@ def test_a_signed_in_request_is_never_capped_and_names_its_account(sink):
client = TestClient(app)
for _ in range(logs.ANONYMOUS_LINES_PER_MINUTE + 3):
- client.get("/api/me")
+ assert client.get("/api/me").status_code == 200
records = sink.records[-(logs.ANONYMOUS_LINES_PER_MINUTE + 3) :]
assert all(getattr(r, logs.PERSIST, True) for r in records)
assert sink.objects()[-1]["user_id"] == 42 and "user_id" not in sink.objects()[-1]["attrs"]
@@ -325,9 +325,9 @@ def test_the_log_consoles_own_reads_are_debug_but_its_failures_are_not(sink):
http = logging.getLogger("siftlode.http")
http.setLevel(logs.TRACE)
client = _client()
- client.get("/api/admin/logs/facets")
+ assert client.get("/api/admin/logs/facets").status_code == 200
assert sink.objects()[-1]["level"] == "DEBUG"
- client.get("/api/admin/logs/facets?fail=1")
+ assert client.get("/api/admin/logs/facets?fail=1").status_code == 400
assert sink.objects()[-1]["level"] == "WARNING"
@@ -344,7 +344,7 @@ def test_a_polled_read_is_debug_a_write_to_the_same_route_is_not(sink):
r = client.post("/api/messages/unread_count")
assert sink.objects()[-1]["level"] == "INFO"
assert "X-Log-Quiet" not in r.headers
- client.get("/api/videos/abc")
+ assert client.get("/api/videos/abc").status_code == 200
assert sink.objects()[-1]["level"] == "INFO"
@@ -360,7 +360,7 @@ def test_every_polled_route_is_a_route_the_app_has():
def test_a_failing_request_is_logged_by_status_and_an_unhandled_error_is_logged_at_all(sink):
client = _client()
- client.get("/api/videos/missing/deeper") # 404 from the router
+ assert client.get("/api/videos/missing/deeper").status_code == 404 # 404 from the router
assert sink.objects()[-1]["level"] == "WARNING"
client.get("/boom")
payload = sink.objects()[-1]
diff --git a/backend/tests/test_mail_language.py b/backend/tests/test_mail_language.py
index ce1c38df..f5ef1b7d 100644
--- a/backend/tests/test_mail_language.py
+++ b/backend/tests/test_mail_language.py
@@ -154,8 +154,18 @@ def test_an_access_request_records_the_language_it_was_made_in(db, client):
def test_asking_again_from_another_language_updates_the_row(db, client):
- client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
- client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
+ assert (
+ client.post(
+ "/auth/request-access", json={"email": "asking@example.com", "lang": "en"}
+ ).status_code
+ == 200
+ )
+ assert (
+ client.post(
+ "/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
+ ).status_code
+ == 200
+ )
db.expire_all()
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
@@ -165,7 +175,12 @@ def test_asking_again_from_another_language_updates_the_row(db, client):
def test_an_unsupported_language_is_ignored_rather_than_stored(db, client):
- client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"})
+ assert (
+ client.post(
+ "/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"}
+ ).status_code
+ == 200
+ )
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
assert inv.lang is None
@@ -194,7 +209,12 @@ def test_the_confirmation_mail_is_written_in_the_accounts_language(db, client, m
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
- client.post("/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"})
+ assert (
+ client.post(
+ "/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"}
+ ).status_code
+ == 200
+ )
(_, subject, body) = sent[0]
assert subject == mailtext.MAIL_TEXT["verify_email.subject"]["hu"]
@@ -209,7 +229,12 @@ def test_the_approval_mail_answers_in_the_language_the_request_was_made_in(
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
- client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
+ assert (
+ client.post(
+ "/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
+ ).status_code
+ == 200
+ )
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
sent.clear() # drop the admin notification; this test is about the reply to the requester
@@ -227,11 +252,16 @@ def test_a_denial_answers_in_that_language_too(db, client, make_user, as_user, m
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
- client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
+ assert (
+ client.post(
+ "/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
+ ).status_code
+ == 200
+ )
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
sent.clear()
- as_user(admin).post(f"/api/admin/invites/{inv.id}/deny")
+ assert as_user(admin).post(f"/api/admin/invites/{inv.id}/deny").status_code == 200
assert sent[0][1] == mailtext.MAIL_TEXT["access_denied.subject"]["hu"]
diff --git a/backend/tests/test_playlist_add_position.py b/backend/tests/test_playlist_add_position.py
index 994774cb..d351b0f2 100644
--- a/backend/tests/test_playlist_add_position.py
+++ b/backend/tests/test_playlist_add_position.py
@@ -97,7 +97,7 @@ def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user,
pl.synced_fingerprint = current_fingerprint(db, pl)
pl.dirty = False
db.commit()
- client.delete(f"/api/playlists/{pl.id}/items/b")
+ assert client.delete(f"/api/playlists/{pl.id}/items/b").status_code == 200
db.expire_all()
assert db.get(Playlist, pl.id).dirty is True
_add(client, pl.id, {"video_id": "b", "position": 1})
diff --git a/backend/tests/test_plex_image_sizes.py b/backend/tests/test_plex_image_sizes.py
index 6e3cdaad..3c250296 100644
--- a/backend/tests/test_plex_image_sizes.py
+++ b/backend/tests/test_plex_image_sizes.py
@@ -67,7 +67,7 @@ def test_each_variant_is_cut_at_its_own_size(db, make_user, monkeypatch, tmp_pat
]
# Each cut is cached under its own name: asking again reaches Plex no more.
for variant in ("thumb", "thumb_sm", "art", "art_sm"):
- client.get(f"/api/plex/image/pm1?variant={variant}")
+ assert client.get(f"/api/plex/image/pm1?variant={variant}").status_code == 200
assert len(_FakePlex.calls) == 4
diff --git a/backend/tests/test_plex_playlist_add_positions.py b/backend/tests/test_plex_playlist_add_positions.py
index 4da18967..3d8f970a 100644
--- a/backend/tests/test_plex_playlist_add_positions.py
+++ b/backend/tests/test_plex_playlist_add_positions.py
@@ -105,8 +105,11 @@ def test_remove_then_put_back_is_no_change(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c", "d"], extra=())
client = as_user(user)
- client.post(
- f"/api/plex/playlists/{pl.id}/items/remove-bulk", json={"item_rating_keys": ["b", "d"]}
+ assert (
+ client.post(
+ f"/api/plex/playlists/{pl.id}/items/remove-bulk", json={"item_rating_keys": ["b", "d"]}
+ ).status_code
+ == 200
)
assert _order(db, pl.id)[0] == ["a", "c"]
_add(client, pl.id, ["b", "d"], [1, 3])
diff --git a/backend/tests/test_plex_subtitle_prepare.py b/backend/tests/test_plex_subtitle_prepare.py
index f0a7c50a..87677ab8 100644
--- a/backend/tests/test_plex_subtitle_prepare.py
+++ b/backend/tests/test_plex_subtitle_prepare.py
@@ -208,7 +208,7 @@ def test_an_unshowable_track_says_why_and_maps_to_a_status(client, monkeypatch):
raise subtitles.SubtitleUnavailable("bitmap")
monkeypatch.setattr(subtitles, "_extract_and_publish", _bitmap)
- client.get("/api/plex/subtitle/98614/0/status")
+ assert client.get("/api/plex/subtitle/98614/0/status").status_code == 200
pool.run_all()
st = client.get("/api/plex/subtitle/98614/0/status").json()
assert st["state"] == "failed" and st["code"] == "bitmap"
diff --git a/backend/tests/test_reset_link_states.py b/backend/tests/test_reset_link_states.py
index a11c03d4..30f0e7c7 100644
--- a/backend/tests/test_reset_link_states.py
+++ b/backend/tests/test_reset_link_states.py
@@ -51,7 +51,12 @@ def test_a_link_whose_account_was_deleted_is_reported_dead(client, db, make_user
def test_a_spent_link_is_reported_dead(client, db, make_user):
raw = _issue(db, make_user(email="spent@example.com", password=_PW))
- client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
+ assert (
+ client.post(
+ "/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
+ ).status_code
+ == 200
+ )
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
diff --git a/backend/tests/test_response_checked.py b/backend/tests/test_response_checked.py
new file mode 100644
index 00000000..655c8a23
--- /dev/null
+++ b/backend/tests/test_response_checked.py
@@ -0,0 +1,58 @@
+"""No test sends a request and throws the response away (E32 S2).
+
+A discarded `client.post(...)` passes whatever it got back. As a SETUP step, a failure leaves the
+test asserting on a state it never reached; as the step UNDER test, the test proves nothing. E32
+found `test_youtube_sources_never_consult_the_guard` passing on a 400 that stopped the request
+before the code it meant to exercise, and 53 more discarded responses across the suite. Each one
+now states the status it expects; this keeps it that way.
+"""
+
+import ast
+from pathlib import Path
+
+TESTS = Path(__file__).resolve().parent
+VERBS = {"get", "post", "put", "patch", "delete", "head", "options", "request"}
+
+
+def discarded_requests(source: str) -> list[int]:
+ """Line numbers of the statements that are a bare request call on an app path."""
+ hits = []
+ for node in ast.walk(ast.parse(source)):
+ if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)):
+ continue
+ func = node.value.func
+ if not (isinstance(func, ast.Attribute) and func.attr in VERBS):
+ continue
+ path = node.value.args[0] if node.value.args else None
+ literal = path.value if isinstance(path, ast.Constant) else None
+ if isinstance(path, ast.JoinedStr):
+ first = path.values[0] if path.values else None
+ literal = first.value if isinstance(first, ast.Constant) else None
+ if isinstance(literal, str) and literal.startswith(("/api", "/auth")):
+ hits.append(node.lineno)
+ return hits
+
+
+def test_the_detector_sees_a_discarded_request_and_passes_a_checked_one():
+ src = (
+ "client.post('/api/x')\n"
+ "client.get(f'/auth/{u}')\n"
+ "assert client.post('/api/x').status_code == 200\n"
+ "r = client.get('/api/y')\n"
+ "items.get('/api/not-a-request-but-a-key')\n"
+ )
+ # The last line is a false positive by design: a dict keyed by an app path is not a thing the
+ # suite does, and a narrower match would miss a client held under any other name.
+ assert discarded_requests(src) == [1, 2, 5]
+
+
+def test_no_test_discards_a_response():
+ offenders = [
+ f"{p.name}:{line}"
+ for p in sorted(TESTS.rglob("test_*.py"))
+ for line in discarded_requests(p.read_text(encoding="utf-8"))
+ ]
+ assert offenders == [], (
+ "assert the status these requests expect (`assert client.post(...).status_code == 200`), "
+ f"or keep the response and check it: {offenders}"
+ )
diff --git a/backend/tests/test_review_round.py b/backend/tests/test_review_round.py
index 5f1e6f39..6574e296 100644
--- a/backend/tests/test_review_round.py
+++ b/backend/tests/test_review_round.py
@@ -100,15 +100,22 @@ def test_a_later_registration_never_touches_an_existing_password(db, client, smt
have installed a password where the whole point is that there is none. Reverted; the owner's
recourse is password-reset, which proves the mailbox. Residual logged as SEC-16.
"""
- client.post(
- "/auth/register", json={"email": "victim@example.com", "password": "the first password"}
+ assert (
+ client.post(
+ "/auth/register", json={"email": "victim@example.com", "password": "the first password"}
+ ).status_code
+ == 200
)
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
before = user.password_hash
- client.post(
- "/auth/register", json={"email": "victim@example.com", "password": "the second password"}
+ assert (
+ client.post(
+ "/auth/register",
+ json={"email": "victim@example.com", "password": "the second password"},
+ ).status_code
+ == 200
)
db.expire_all()
@@ -116,8 +123,11 @@ def test_a_later_registration_never_touches_an_existing_password(db, client, smt
def test_a_later_registration_leaves_a_link_already_in_flight_alive(db, client, smtp_on, wire):
- client.post(
- "/auth/register", json={"email": "victim@example.com", "password": "the first password"}
+ assert (
+ client.post(
+ "/auth/register", json={"email": "victim@example.com", "password": "the first password"}
+ ).status_code
+ == 200
)
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
@@ -126,8 +136,12 @@ def test_a_later_registration_leaves_a_link_already_in_flight_alive(db, client,
# would go green while asserting nothing the day registration stops issuing a token.
assert len(first) == 1
- client.post(
- "/auth/register", json={"email": "victim@example.com", "password": "the second password"}
+ assert (
+ client.post(
+ "/auth/register",
+ json={"email": "victim@example.com", "password": "the second password"},
+ ).status_code
+ == 200
)
db.expire_all()
@@ -148,8 +162,12 @@ def test_a_stripped_account_keeps_its_recovery_stamp(db, client, smtp_on, make_u
password_revoked_at=datetime.now(timezone.utc),
)
- client.post(
- "/auth/register", json={"email": "stripped@example.com", "password": "an attacker password"}
+ assert (
+ client.post(
+ "/auth/register",
+ json={"email": "stripped@example.com", "password": "an attacker password"},
+ ).status_code
+ == 200
)
db.expire_all()
@@ -169,7 +187,9 @@ def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db, client)
)
db.commit()
- client.post("/auth/request-access", json={"email": "pairone@gmail.com"})
+ assert (
+ client.post("/auth/request-access", json={"email": "pairone@gmail.com"}).status_code == 200
+ )
db.expire_all()
rows = {i.email: i.status for i in db.execute(select(Invite)).scalars().all()}
@@ -195,7 +215,7 @@ def test_an_env_granted_address_cannot_be_shadowed_by_a_stranger(db, client, mon
# env grant. With the env list matched canonically the request never opens a row.
monkeypatch.setattr(settings, "allowed_emails", "bob@gmail.com")
- client.post("/auth/request-access", json={"email": "b.ob@gmail.com"})
+ assert client.post("/auth/request-access", json={"email": "b.ob@gmail.com"}).status_code == 200
assert db.execute(select(Invite)).scalars().all() == []
assert auth_core.is_allowed(db, "bob@gmail.com") is True
@@ -331,7 +351,12 @@ def test_two_admins_two_languages_two_mails(db, client, make_user, smtp_on, wire
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
- client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
+ assert (
+ client.post(
+ "/auth/request-access", json={"email": "asking@example.com", "lang": "en"}
+ ).status_code
+ == 200
+ )
notices = [w for w in wire if "asking@example.com" in w[1]]
assert len(notices) == 2
diff --git a/backend/tests/test_saved_views_source.py b/backend/tests/test_saved_views_source.py
index 215c2642..f7e078e3 100644
--- a/backend/tests/test_saved_views_source.py
+++ b/backend/tests/test_saved_views_source.py
@@ -53,8 +53,8 @@ def test_default_is_per_source(db, monkeypatch, override_dep):
pid = c.post("/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"}).json()[
"id"
]
- c.patch(f"/api/saved-views/{fid}", json={"is_default": True})
- c.patch(f"/api/saved-views/{pid}", json={"is_default": True})
+ assert c.patch(f"/api/saved-views/{fid}", json={"is_default": True}).status_code == 200
+ assert c.patch(f"/api/saved-views/{pid}", json={"is_default": True}).status_code == 200
# Making the Plex view default must NOT clear the feed view's default (different source).
assert c.get("/api/saved-views?source=feed").json()[0]["is_default"] is True
assert c.get("/api/saved-views?source=plex").json()[0]["is_default"] is True
diff --git a/backend/tests/test_set_password_link.py b/backend/tests/test_set_password_link.py
index 2e7f196f..2c037e24 100644
--- a/backend/tests/test_set_password_link.py
+++ b/backend/tests/test_set_password_link.py
@@ -68,8 +68,12 @@ def test_using_the_link_evicts_whoever_held_the_old_password(db, as_user, make_u
admin = make_user(role="admin")
db.commit()
token = _link(as_user(admin), target.id)
- client.post(
- "/auth/password-reset/confirm", json={"token": token, "password": "the owner's own choice"}
+ assert (
+ client.post(
+ "/auth/password-reset/confirm",
+ json={"token": token, "password": "the owner's own choice"},
+ ).status_code
+ == 200
)
db.refresh(target)
diff --git a/backend/tests/test_welcome_mail.py b/backend/tests/test_welcome_mail.py
index 4d92acfe..4ce4f99d 100644
--- a/backend/tests/test_welcome_mail.py
+++ b/backend/tests/test_welcome_mail.py
@@ -229,7 +229,12 @@ def test_registering_again_does_not_create_a_second_account(db, client, monkeypa
_smtp_on(monkeypatch)
_capture(monkeypatch, "send_verify_email")
for _ in range(2):
- client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
+ assert (
+ client.post(
+ "/auth/register", json={"email": "twice@example.com", "password": _PW}
+ ).status_code
+ == 200
+ )
db.expire_all()
rows = db.execute(select(User).where(User.email == "twice@example.com")).scalars().all()
From c73761ae39d0fb0ed9318a6f1cfa4a721dbb42c1 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 19:12:17 +0200
Subject: [PATCH 14/54] refactor(plex): one cast row for a show's page and a
title's info
CastStrip (a show's page) and PlexInfo (a title's page and the player's overlay) each drew the
cast carousel by hand, and the copies had drifted: the gap, lazy pictures, the name's tooltip, the
initial's size. Both now render CastRow, which takes the overlay's white-on-art palette as a tone;
each caller keeps its own frame and heading. A DOM test holds the palette per variant (seen red
with the tone wired to "panel"). jscpd over the shipped code: 1 clone >= 10 lines -> 0.
---
frontend/src/components/PlexInfo.dom.test.tsx | 11 ++
frontend/src/components/PlexInfo.tsx | 74 +---------
frontend/src/components/plexCards.tsx | 136 +++++++++++-------
3 files changed, 100 insertions(+), 121 deletions(-)
diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx
index 737e7f4d..70a55c88 100644
--- a/frontend/src/components/PlexInfo.dom.test.tsx
+++ b/frontend/src/components/PlexInfo.dom.test.tsx
@@ -300,6 +300,17 @@ describe("PlexInfo — the hero's second column (page)", () => {
expect(screen.getByTestId("detail-aside").textContent).toContain(`Person ${CREW_SHOWN + 1}`);
expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull();
});
+
+ // E32 S2: the cast row is the shared CastRow now; the page and the player's overlay hand it their
+ // own palette. Only the overlay sits on the dark art, so only it may carry the white text.
+ it.each([
+ ["page", false],
+ ["overlay", true],
+ ] as const)("the %s variant's cast row takes its own palette", (variant, white) => {
+ wrap( );
+ expect(screen.getByText("An Actor").className.includes("text-white/90")).toBe(white);
+ expect(screen.getByText("Hero").className.includes("text-muted")).toBe(!white);
+ });
});
// E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index 7d9d57f6..1242b324 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -19,7 +19,6 @@ import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
-import { navItemAttrs } from "../lib/listNav";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
@@ -27,7 +26,7 @@ import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
-import { PlexPosterCard } from "./plexCards";
+import { CastRow, PlexPosterCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { usePlexCardActions } from "./usePlexCardActions";
@@ -594,71 +593,12 @@ export default function PlexInfo({
{t("plex.info.cast")}
-
- {cast.map((c, i) => {
- const clickable = !!(onOpenPerson || onFilter);
- const inner = (
- <>
-
- {c.thumb ? (
-
- ) : (
-
- {c.name.charAt(0)}
-
- )}
-
-
- {c.name}
-
- {c.role && (
-
- {c.role}
-
- )}
- >
- );
- return clickable ? (
-
- onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
- }
- title={
- onOpenPerson
- ? t("plex.info.viewPerson", { name: c.name })
- : t("plex.info.filterActor", { name: c.name })
- }
- className="group/cast w-20 shrink-0"
- >
- {inner}
-
- ) : (
-
- {inner}
-
- );
- })}
-
+
);
if (overlay)
diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx
index d580e6cc..ed1e769e 100644
--- a/frontend/src/components/plexCards.tsx
+++ b/frontend/src/components/plexCards.tsx
@@ -528,69 +528,97 @@ export function CastStrip({
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
- const clickable = !!(onOpenPerson || onFilter);
return (
{t("plex.info.cast")}
-
- {cast.map((c, i) => {
- const inner = (
- <>
-
- {c.thumb ? (
-
- ) : (
-
- {c.name.charAt(0)}
-
- )}
-
- {/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
- same text height and the row of circles sits on one baseline regardless of name length. */}
-
- {c.name}
-
- {c.role && (
-
- {c.role}
+
+
+ );
+}
+
+// The ONE cast carousel: a show's page (CastStrip) and a title's info (PlexInfo, page or the
+// player's overlay) each frame it with their own heading. Two hand copies of it had drifted apart
+// (gap, lazy pictures, the name's tooltip — E32 S2). `tone="overlay"` is the white-on-art palette.
+export function CastRow({
+ cast,
+ tone = "panel",
+ onFilter,
+ onOpenPerson,
+}: {
+ cast: PlexCastMember[];
+ tone?: "panel" | "overlay";
+ onFilter?: (patch: Partial
) => void;
+ onOpenPerson?: (name: string) => void;
+}) {
+ const { t } = useTranslation();
+ const clickable = !!(onOpenPerson || onFilter);
+ const overlay = tone === "overlay";
+ return (
+
+ {cast.map((c, i) => {
+ const inner = (
+ <>
+
+ {c.thumb ? (
+
+ ) : (
+
+ {c.name.charAt(0)}
)}
- >
- );
- return clickable ? (
-
- onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
- }
- title={
- onOpenPerson
- ? t("plex.info.viewPerson", { name: c.name })
- : t("plex.info.filterActor", { name: c.name })
- }
- className="group/cast w-20 shrink-0"
- >
- {inner}
-
- ) : (
-
- {inner}
- );
- })}
-
-
+ {/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
+ same text height and the row of circles sits on one baseline regardless of name length. */}
+
+ {c.name}
+
+ {c.role && (
+
+ {c.role}
+
+ )}
+ >
+ );
+ return clickable ? (
+
(onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] }))}
+ title={
+ onOpenPerson
+ ? t("plex.info.viewPerson", { name: c.name })
+ : t("plex.info.filterActor", { name: c.name })
+ }
+ className="group/cast w-20 shrink-0"
+ >
+ {inner}
+
+ ) : (
+
+ {inner}
+
+ );
+ })}
+
);
}
From 5806adda7ec5d968ddfed2ee929f7e95b4e60711 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 19:33:30 +0200
Subject: [PATCH 15/54] fix(plex): a listing cut short no longer clears watch
state or Hungarian text
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The daily watch reconcile reads "absent from Plex's scan" as "Plex un-watched it" and deletes the
row — but it walked the sections without asking _paginate whether the walk reached the server's
total, so a Plex hiccup mid-walk (an empty page early) un-watched every title it failed to reach
until the next complete run. The scan now reports completeness; an incomplete one still pulls in
what it saw, re-pushes dirty rows, and clears nothing (logged as plex.watch.reconcile_incomplete).
The HU overlay had the same shape: clear every row's HU text, then refill from the listing. It now
reads the listing first and clears only after a complete one. The overlay moved to plex/overlay.py
(plex/sync.py drops under the 800-line limit and off the size ratchet's list).
Tests: plex/paths (23% -> 100%: prefix boundary, longest prefix, dead mount), the reconcile's
clear/keep/re-push rules, the cut-short scan and listing (each seen red with the guard removed).
---
backend/app/plex/overlay.py | 150 ++++++++++++++++++
backend/app/plex/sync.py | 122 ++-------------
backend/app/plex/watch_sync.py | 55 ++++---
backend/tests/test_plex_overlay.py | 63 ++++++--
backend/tests/test_plex_paths.py | 82 ++++++++++
backend/tests/test_plex_watch_reconcile.py | 167 +++++++++++++++++++++
backend/tests/test_source_size.py | 1 -
7 files changed, 499 insertions(+), 141 deletions(-)
create mode 100644 backend/app/plex/overlay.py
create mode 100644 backend/tests/test_plex_paths.py
create mode 100644 backend/tests/test_plex_watch_reconcile.py
diff --git a/backend/app/plex/overlay.py b/backend/app/plex/overlay.py
new file mode 100644
index 00000000..7676a118
--- /dev/null
+++ b/backend/app/plex/overlay.py
@@ -0,0 +1,150 @@
+"""The HU overlay: a Hungarian Plex section written onto its English twin's rows (E30), split out of
+`plex/sync.py` (E32 S2), which runs it once per paired section during the full sync.
+
+The pairing is admin-configured (`plex_overlay_map`, ``huKey:enKey,...``). An overlay section creates
+no browsable rows of its own; it only fills the `*_hu` columns of the EN rows it matches by guid.
+`plex/sync.py` and this module import each other as MODULES (attribute access at call time), which is
+what lets either be imported first.
+"""
+
+from collections.abc import Iterable
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app import logs, sysconfig
+from app.models import PlexItem, PlexLibrary, PlexShow
+from app.plex import sync as plex_sync
+from app.plex.client import PlexClient
+from app.plex.genres import hu_genres
+
+log = logs.get_logger(__name__)
+
+
+def overlay_map(db: Session) -> dict[str, str]:
+ """The configured HU-overlay pairing: {huSectionKey: enSectionKey} from `plex_overlay_map`
+ ('huKey:enKey,...'). Each HU section is synced as a non-browsable overlay onto its EN twin.
+ Malformed pairs are skipped."""
+ raw = sysconfig.get_str(db, "plex_overlay_map")
+ out: dict[str, str] = {}
+ for pair in raw.split(","):
+ hu, sep, en = pair.partition(":")
+ hu, en = hu.strip(), en.strip()
+ if sep and hu and en:
+ out[hu] = en
+ return out
+
+
+def sync_overlay(
+ db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict
+) -> None:
+ """Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates
+ NO browsable rows — an overlay library is never listed on its own. Only title/summary/tagline/genres
+ differ by language; the language-neutral people_text feeds both search vectors. A show section's
+ episodes are overlaid too (E30): the HU section lists every episode with its Hungarian title and
+ summary, and the episode rows live in the EN show library.
+
+ This is a full RECONCILE: after a COMPLETE HU listing every guid-bearing EN row's HU fields are
+ cleared, then the matches are repopulated — so an EN row whose HU twin vanished (or was re-guid'd)
+ falls back to EN instead of keeping stale Hungarian metadata. A listing cut short only adds. HU
+ items with no EN match are skipped."""
+ if lib.kind == "movie":
+ passes = [
+ (
+ plex_sync._TYPE_MOVIE,
+ select(PlexItem).where(
+ PlexItem.library_id == en_lib_id,
+ PlexItem.kind == "movie",
+ PlexItem.guid.is_not(None),
+ ),
+ )
+ ]
+ else:
+ passes = [
+ (
+ plex_sync._TYPE_SHOW,
+ select(PlexShow).where(
+ PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None)
+ ),
+ ),
+ (
+ plex_sync._TYPE_EPISODE,
+ select(PlexItem).where(
+ PlexItem.library_id == en_lib_id,
+ PlexItem.kind == "episode",
+ PlexItem.guid.is_not(None),
+ ),
+ ),
+ ]
+ for item_type, rows_q in passes:
+ _overlay_pass(db, plex, lib, item_type, db.execute(rows_q).scalars(), stats)
+ db.flush()
+
+
+def _overlay_pass(
+ db: Session,
+ plex: PlexClient,
+ lib: PlexLibrary,
+ item_type: int,
+ rows: Iterable[PlexItem] | Iterable[PlexShow],
+ stats: dict,
+) -> None:
+ """One kind of the overlay (movies, shows or episodes) — see `sync_overlay`."""
+ # guid -> the EN rows carrying it (a list: two editions of one film can share a guid; both overlay).
+ by_guid: dict[str, list[PlexItem | PlexShow]] = {}
+ for r in rows:
+ if r.guid: # the passes select guid-bearing rows only
+ by_guid.setdefault(r.guid, []).append(r)
+ # The HU listing is read first and applied after, so the clear can wait for its verdict.
+ hu_by_guid, complete = _read_hu_listing(plex, lib, item_type, by_guid)
+ episodes = item_type == plex_sync._TYPE_EPISODE
+ if complete:
+ _clear_hu(by_guid, episodes)
+ else:
+ stats["overlay_incomplete"] = stats.get("overlay_incomplete", 0) + 1
+ log.warning(
+ "the HU listing ended before the server's total; existing HU text is kept",
+ extra=logs.event("plex.sync.overlay_incomplete", library=lib.plex_key),
+ )
+ for g, meta in hu_by_guid.items():
+ targets = by_guid[g]
+ title_hu = (meta.get("title") or "").strip() or None
+ summary_hu = meta.get("summary")
+ tagline_hu = (meta.get("tagline") or "").strip() or None
+ genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
+ for row in targets:
+ row.title_hu = title_hu
+ row.summary_hu = summary_hu
+ if not episodes:
+ row.tagline_hu = tagline_hu
+ row.genres_hu = genres_hu or row.genres_hu
+ stats["overlaid"] = stats.get("overlaid", 0) + 1
+
+
+def _read_hu_listing(
+ plex: PlexClient, lib: PlexLibrary, item_type: int, by_guid: dict
+) -> tuple[dict[str, dict], bool]:
+ """The HU section's entries for the guids the EN side carries, and whether the walk reached the
+ server's total. The first HU entry for a guid wins; a dup HU item can't overwrite or double-count."""
+ progress: dict = {}
+ hu_by_guid: dict[str, dict] = {}
+ for meta in plex_sync._paginate(plex, lib.plex_key, item_type, progress):
+ g = plex_sync._canonical_guid(meta)
+ if g and g in by_guid and g not in hu_by_guid:
+ hu_by_guid[g] = meta
+ return hu_by_guid, bool(progress.get("complete"))
+
+
+def _clear_hu(by_guid: dict, episodes: bool) -> None:
+ """Reconcile step: clear HU fields on all guid-bearing rows; the matches are re-set after. Only
+ rows that actually held HU data become dirty (SQLAlchemy skips a no-net-change UPDATE), so this is
+ cheap. The genres are the exception: every row gets Hungarian ones, from its HU twin or, lacking
+ one, from its own English list — otherwise the HU genre list mixed "Drama" (no twin) with "Dráma".
+ Only after a COMPLETE listing: one a Plex hiccup cut short would clear the HU text of every title
+ it failed to reach, until the next full sync (E32 S2, the watch reconcile's twin)."""
+ for group in by_guid.values():
+ for row in group:
+ row.title_hu = row.summary_hu = None
+ if not episodes:
+ row.tagline_hu = None
+ row.genres_hu = hu_genres(row.genres)
diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py
index 1dbd038e..c84a053f 100644
--- a/backend/app/plex/sync.py
+++ b/backend/app/plex/sync.py
@@ -15,7 +15,6 @@ dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are
item-detail/player path (P2), not here.
"""
-from collections.abc import Iterable
from datetime import datetime, timezone
from sqlalchemy import func, select
@@ -23,7 +22,7 @@ from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
-from app.plex import detail, people
+from app.plex import detail, overlay, people
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.genres import hu_genres
from app.plex.lookups import by_rating_key
@@ -194,20 +193,6 @@ def _enabled_section_keys(db: Session) -> set[str] | None:
return keys or None
-def _overlay_map(db: Session) -> dict[str, str]:
- """The configured HU-overlay pairing: {huSectionKey: enSectionKey} from `plex_overlay_map`
- ('huKey:enKey,...'). Each HU section is synced as a non-browsable overlay onto its EN twin.
- Malformed pairs are skipped."""
- raw = sysconfig.get_str(db, "plex_overlay_map")
- out: dict[str, str] = {}
- for pair in raw.split(","):
- hu, sep, en = pair.partition(":")
- hu, en = hu.strip(), en.strip()
- if sep and hu and en:
- out[hu] = en
- return out
-
-
def sync(db: Session) -> dict:
"""Full reconcile of the enabled movie/show sections. Returns a small stats dict."""
if not sysconfig.get_bool(db, "plex_enabled"):
@@ -223,7 +208,7 @@ def sync(db: Session) -> dict:
"pruned": 0,
}
wanted = _enabled_section_keys(db)
- overlay_map = _overlay_map(db) # {huKey: enKey}
+ overlay_map = overlay.overlay_map(db) # {huKey: enKey}
overlay_keys = set(overlay_map)
try:
with PlexClient(db) as plex:
@@ -267,7 +252,7 @@ def sync(db: Session) -> dict:
lib.metadata_lang = "hu"
lib.overlay_of = en_lib.id
db.flush()
- _sync_overlay(db, plex, lib, en_lib.id, stats)
+ overlay.sync_overlay(db, plex, lib, en_lib.id, stats)
db.commit()
# Rich-fetch a bounded batch of not-yet-enriched titles' FULL cast, so the whole library
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
@@ -661,98 +646,6 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
_prune_stale(db, shows, shows_seen, stats, complete=shows_progress.get("complete", False))
-def _sync_overlay(
- db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict
-) -> None:
- """Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates
- NO browsable rows — an overlay library is never listed on its own. Only title/summary/tagline/genres
- differ by language; the language-neutral people_text feeds both search vectors. A show section's
- episodes are overlaid too (E30): the HU section lists every episode with its Hungarian title and
- summary, and the episode rows live in the EN show library.
-
- This is a full RECONCILE: every guid-bearing EN row's HU fields are cleared first, then the HU
- section repopulates the matches — so an EN row whose HU twin vanished (or was re-guid'd) falls back
- to EN instead of keeping stale Hungarian metadata. HU items with no EN match are skipped."""
- if lib.kind == "movie":
- passes = [
- (
- _TYPE_MOVIE,
- select(PlexItem).where(
- PlexItem.library_id == en_lib_id,
- PlexItem.kind == "movie",
- PlexItem.guid.is_not(None),
- ),
- )
- ]
- else:
- passes = [
- (
- _TYPE_SHOW,
- select(PlexShow).where(
- PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None)
- ),
- ),
- (
- _TYPE_EPISODE,
- select(PlexItem).where(
- PlexItem.library_id == en_lib_id,
- PlexItem.kind == "episode",
- PlexItem.guid.is_not(None),
- ),
- ),
- ]
- for item_type, rows_q in passes:
- _overlay_pass(db, plex, lib, item_type, db.execute(rows_q).scalars(), stats)
- db.flush()
-
-
-def _overlay_pass(
- db: Session,
- plex: PlexClient,
- lib: PlexLibrary,
- item_type: int,
- rows: Iterable[PlexItem] | Iterable[PlexShow],
- stats: dict,
-) -> None:
- """One kind of the overlay (movies, shows or episodes) — see `_sync_overlay`."""
- # guid -> the EN rows carrying it (a list: two editions of one film can share a guid; both overlay).
- by_guid: dict[str, list[PlexItem | PlexShow]] = {}
- for r in rows:
- if r.guid: # the passes select guid-bearing rows only
- by_guid.setdefault(r.guid, []).append(r)
- # Reconcile step: clear HU fields on all guid-bearing rows. Only rows that actually held HU data
- # become dirty (SQLAlchemy skips a no-net-change UPDATE), so this is cheap; matches are re-set below.
- # The genres are the exception: every row gets Hungarian ones, from its HU twin or, lacking one, from
- # its own English list — otherwise the HU genre list mixed "Drama" (no twin) with "Dráma".
- episodes = item_type == _TYPE_EPISODE
- for group in by_guid.values():
- for row in group:
- row.title_hu = row.summary_hu = None
- if not episodes:
- row.tagline_hu = None
- row.genres_hu = hu_genres(row.genres)
- seen: set[str] = (
- set()
- ) # first HU entry for a guid wins; a dup HU item can't overwrite or double-count
- for meta in _paginate(plex, lib.plex_key, item_type):
- g = _canonical_guid(meta)
- targets = by_guid.get(g) if g else None
- if not targets or g in seen:
- continue
- seen.add(g)
- title_hu = (meta.get("title") or "").strip() or None
- summary_hu = meta.get("summary")
- tagline_hu = (meta.get("tagline") or "").strip() or None
- genres_hu = hu_genres(_tags(meta, "Genre"))
- for row in targets:
- row.title_hu = title_hu
- row.summary_hu = summary_hu
- if not episodes:
- row.tagline_hu = tagline_hu
- row.genres_hu = genres_hu or row.genres_hu
- stats["overlaid"] = stats.get("overlaid", 0) + 1
-
-
def _upsert_collection(
db: Session, existing: PlexCollection | None, rk: str, lib_id: int, meta: dict
) -> PlexCollection:
@@ -815,6 +708,15 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di
select(PlexCollection).where(PlexCollection.library_id == lib.id)
).scalars()
}
+ if not cols and existing:
+ # The catalog's own rule (see the module doc): an empty listing while we hold rows is a Plex
+ # glitch, not a library emptied of collections. Reconciling on it dropped every collection
+ # (its user-set `editable` flag with it) and every member's keys (E32 S2).
+ log.warning(
+ "Plex listed no collections for a library that has some; keeping them this run",
+ extra=logs.event("plex.sync.collections_empty", library=lib.plex_key),
+ )
+ return
seen: set[str] = set()
membership: dict[str, list[str]] = {} # member rating_key -> [collection rating_key, ...]
for meta in cols:
diff --git a/backend/app/plex/watch_sync.py b/backend/app/plex/watch_sync.py
index 47c014d3..e6043bbd 100644
--- a/backend/app/plex/watch_sync.py
+++ b/backend/app/plex/watch_sync.py
@@ -72,13 +72,17 @@ def _rk_to_id(db: Session) -> dict[str, int]:
def _scan_plex_states(
db: Session, plex: PlexClient
-) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int]:
+) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int, bool]:
"""Scan the enabled movie/show sections and return ``{item_id: (status, pos, watched_at,
- prog_at)}`` for every mirrored leaf Plex holds a watch signal for, plus the scanned-leaf count.
- Shared by the one-time import and the full reconcile (both need Plex's whole current picture)."""
+ prog_at)}`` for every mirrored leaf Plex holds a watch signal for, the scanned-leaf count, and
+ whether EVERY section's walk reached the server's total. Shared by the one-time import and the
+ full reconcile (both need Plex's whole current picture). The last one matters to the reconcile
+ alone: it reads "absent from the scan" as "Plex un-watched it", which a walk cut short by a
+ Plex hiccup does not mean (see `_paginate`)."""
item_id_by_rk = _rk_to_id(db)
wanted = _enabled_section_keys(db)
scanned = 0
+ complete = True
out: dict[int, tuple[str, int, datetime | None, datetime | None]] = {}
for s in plex.sections():
stype = s.get("type")
@@ -88,7 +92,8 @@ def _scan_plex_states(
if wanted is not None and key not in wanted:
continue
item_type = _TYPE_MOVIE if stype == "movie" else _TYPE_EPISODE
- for meta in _paginate(plex, key, item_type):
+ progress: dict = {}
+ for meta in _paginate(plex, key, item_type, progress):
scanned += 1
item_id = item_id_by_rk.get(str(meta.get("ratingKey") or ""))
if item_id is None:
@@ -98,7 +103,8 @@ def _scan_plex_states(
)
if res is not None:
out[item_id] = res
- return out, scanned
+ complete = complete and bool(progress.get("complete"))
+ return out, scanned, complete
def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
@@ -111,7 +117,8 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
stats = {"watched": 0, "in_progress": 0, "scanned": 0}
try:
with PlexClient(db) as plex:
- scanned_states, stats["scanned"] = _scan_plex_states(db, plex)
+ # A partial scan is still safe here: the import only ever ADDS what Plex reports.
+ scanned_states, stats["scanned"], _complete = _scan_plex_states(db, plex)
# One row per item (keyed by item_id → inherently de-duped) then bulk-UPSERT. An upsert is
# idempotent AND concurrency-safe: a repeated/overlapping enable (the toggle firing twice
# while the multi-second import runs) can't raise a duplicate-key error — it re-updates.
@@ -590,6 +597,20 @@ def run_plex_watch_sync(db: Session) -> dict:
return total
+def _clear_unwatched(db: Session, user_id: int, plex_states: dict) -> int:
+ """Clear the user's rows Plex no longer has a record for — the mirrored ones (Plex un-watched
+ them); hidden and dirty rows stay (a dirty one is re-pushed by `_repush_dirty`). Called only
+ after a COMPLETE scan: a walk a Plex hiccup cut short leaves out rows it merely failed to reach,
+ and clearing those un-watched them here until the next complete run (E32 S2)."""
+ n = 0
+ for st in db.execute(select(PlexState).where(PlexState.user_id == user_id)).scalars().all():
+ if st.item_id in plex_states or st.status == "hidden" or not st.synced_to_plex:
+ continue
+ db.delete(st)
+ n += 1
+ return n
+
+
def run_plex_watch_reconcile(db: Session) -> dict:
"""Scheduler job (default daily) — the authoritative full reconcile. Rescans every section and,
per item, uses `synced_to_plex` to settle what the incremental feed can't:
@@ -608,7 +629,7 @@ def run_plex_watch_reconcile(db: Session) -> dict:
try:
with PlexClient(db) as plex:
_resolve_account_id(db, plex, link)
- plex_states, scanned = _scan_plex_states(db, plex)
+ plex_states, scanned, complete = _scan_plex_states(db, plex)
total["scanned"] += scanned
for item_id, (status, pos, watched_at, prog_at) in plex_states.items():
@@ -616,18 +637,14 @@ def run_plex_watch_reconcile(db: Session) -> dict:
if ch:
total["applied"] += 1
- # Siftlode rows Plex no longer has a record for: clear the ones that were mirrored
- # (Plex un-watched them); leave hidden + dirty (dirty gets re-pushed just below).
- for st in (
- db.execute(select(PlexState).where(PlexState.user_id == link.user_id))
- .scalars()
- .all()
- ):
- if st.item_id in plex_states or st.status == "hidden":
- continue
- if st.synced_to_plex:
- db.delete(st)
- total["cleared"] += 1
+ if complete:
+ total["cleared"] += _clear_unwatched(db, link.user_id, plex_states)
+ else:
+ total["incomplete"] = total.get("incomplete", 0) + 1
+ log.warning(
+ "the Plex scan ended before the server's total; nothing is cleared this run",
+ extra=logs.event("plex.watch.reconcile_incomplete", user_id=link.user_id),
+ )
total["repushed"] += _repush_dirty(db, plex, link)
link.last_watch_sync_at = datetime.now(timezone.utc)
diff --git a/backend/tests/test_plex_overlay.py b/backend/tests/test_plex_overlay.py
index bf70ca34..3497da53 100644
--- a/backend/tests/test_plex_overlay.py
+++ b/backend/tests/test_plex_overlay.py
@@ -16,18 +16,21 @@ from app import sysconfig
from app.db import engine
from app.models import PlexItem, PlexLibrary, PlexShow
from app.plex.genres import hu_genre, hu_genres
-from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay
+from app.plex.overlay import sync_overlay
+from app.plex.sync import _apply_item, _canonical_guid
from app.plex.query import content_libs
class _StubPlex:
- """Minimal PlexClient stand-in: _sync_overlay only calls section_items via _paginate."""
+ """Minimal PlexClient stand-in: sync_overlay only calls section_items via _paginate. `total`
+ over the item count is a listing cut short (the server said more than it handed over)."""
- def __init__(self, items: list[dict]):
+ def __init__(self, items: list[dict], total: int | None = None):
self._items = items
+ self._total = len(items) if total is None else total
def section_items(self, key, item_type, start, size):
- return self._items[start : start + size], len(self._items)
+ return self._items[start : start + size], self._total
def _hu_movie(rk: str, title: str, summary: str, genres: list[str], guid: str | None) -> dict:
@@ -71,7 +74,7 @@ def test_sync_overlay_writes_hu_onto_matched_en_rows(db):
]
)
stats = {"overlaid": 0}
- _sync_overlay(db, stub, hu_lib, en_lib.id, stats)
+ sync_overlay(db, stub, hu_lib, en_lib.id, stats)
db.commit()
db.refresh(matched)
@@ -185,13 +188,51 @@ def test_overlay_reconcile_clears_stale_hu_when_twin_vanishes(db):
)
db.add(row)
db.commit()
- # The HU section no longer lists this film (empty overlay).
- _sync_overlay(db, _StubPlex([]), hu_lib, en_lib.id, {"overlaid": 0})
+ # The HU section, read in full, no longer lists this film (only another one).
+ other = _hu_movie("h9", "Más", "más", [], "tmdb://99")
+ sync_overlay(db, _StubPlex([other]), hu_lib, en_lib.id, {"overlaid": 0})
db.commit()
db.refresh(row)
assert row.title_hu is None and row.summary_hu is None and row.genres_hu is None
+def test_a_listing_cut_short_keeps_the_hungarian_text_it_did_not_reach(db):
+ """E32 S2: the reconcile clears HU text only after a COMPLETE listing. A walk a Plex hiccup cut
+ short (an empty page before the server's total), or an empty section, says nothing about the
+ titles it did not reach — clearing them dropped their Hungarian text until the next full sync."""
+ en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
+ hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
+ db.add_all([en_lib, hu_lib])
+ db.flush()
+ hu_lib.overlay_of = en_lib.id
+ kept = PlexItem(
+ rating_key="m1",
+ library_id=en_lib.id,
+ kind="movie",
+ title="Heat",
+ guid="tmdb:1",
+ title_hu="Szemtől szemben",
+ summary_hu="régi",
+ genres_hu=["Akció"],
+ )
+ fresh = PlexItem(rating_key="m2", library_id=en_lib.id, kind="movie", title="Up", guid="tmdb:2")
+ db.add_all([kept, fresh])
+ db.commit()
+ stats: dict = {}
+ reached = _hu_movie("h2", "Fel", "fel", [], "tmdb://2")
+ sync_overlay(db, _StubPlex([reached], total=2), hu_lib, en_lib.id, stats)
+ db.commit()
+ db.refresh(kept)
+ db.refresh(fresh)
+ assert kept.title_hu == "Szemtől szemben" and kept.summary_hu == "régi"
+ assert fresh.title_hu == "Fel" # what the listing did reach is still applied
+ assert stats["overlay_incomplete"] == 1
+ sync_overlay(db, _StubPlex([]), hu_lib, en_lib.id, {}) # an empty section: the same answer
+ db.commit()
+ db.refresh(kept)
+ assert kept.title_hu == "Szemtől szemben"
+
+
def test_apply_item_keeps_prior_guid_when_listing_lacks_guid():
row = PlexItem(rating_key="m1", kind="movie", guid="tmdb:1")
_apply_item(row, lib_id=7, kind="movie", meta={"title": "Heat"}) # no Guid array
@@ -225,7 +266,7 @@ def test_overlay_applies_to_all_editions_sharing_a_guid(db):
]
)
stats = {"overlaid": 0}
- _sync_overlay(db, stub, hu_lib, en_lib.id, stats)
+ sync_overlay(db, stub, hu_lib, en_lib.id, stats)
db.commit()
db.refresh(ed1)
db.refresh(ed2)
@@ -291,7 +332,7 @@ def test_a_show_overlay_writes_hungarian_episode_titles_and_summaries(db):
],
}
)
- _sync_overlay(db, stub, hu_lib, en_lib.id, {})
+ sync_overlay(db, stub, hu_lib, en_lib.id, {})
db.commit()
db.refresh(ep)
db.refresh(show)
@@ -328,7 +369,7 @@ def test_the_overlay_gives_every_row_one_hungarian_word_per_genre(db):
"h1", "Szemtől szemben", "HU.", ["Action", "Bűnügyi", "Drama", "Dráma"], "tmdb://1"
)
meta["tagline"] = "Magyar mottó."
- _sync_overlay(db, _StubPlex([meta]), hu_lib, en_lib.id, {})
+ sync_overlay(db, _StubPlex([meta]), hu_lib, en_lib.id, {})
db.commit()
db.refresh(twin)
db.refresh(lone)
@@ -353,7 +394,7 @@ def test_a_twin_without_genres_keeps_the_rows_own_hungarian_list(db):
)
db.add(row)
db.commit()
- _sync_overlay(
+ sync_overlay(
db,
_StubPlex([_hu_movie("h1", "Szemtől szemben", "HU.", [], "tmdb://1")]),
hu_lib,
diff --git a/backend/tests/test_plex_paths.py b/backend/tests/test_plex_paths.py
new file mode 100644
index 00000000..446e8bd7
--- /dev/null
+++ b/backend/tests/test_plex_paths.py
@@ -0,0 +1,82 @@
+"""plex/paths — the Plex-side path → local mount translation (E32 S2: 23% covered, and a wrong answer
+here reads as "media file not found" for a whole library at once, with nothing else failing).
+
+Pure logic: the admin map is read through sysconfig, stubbed per test.
+"""
+
+from pathlib import Path
+
+import pytest
+
+from app.plex import paths
+
+
+@pytest.fixture
+def path_map(monkeypatch):
+ def use(raw: str) -> None:
+ monkeypatch.setattr(paths.sysconfig, "get_str", lambda db, key: raw)
+
+ return use
+
+
+def test_the_map_parses_both_separators_drops_junk_and_puts_the_longest_prefix_first():
+ raw = " /data=/mnt/a ,\n/data/movies = /mnt/m\nnot-a-pair,=/x,/y=, /data/tv=/mnt/tv "
+ assert paths.parse_map(raw) == [
+ ("/data/movies", "/mnt/m"),
+ ("/data/tv", "/mnt/tv"),
+ ("/data", "/mnt/a"),
+ ]
+ assert paths.parse_map("") == []
+
+
+def test_the_longest_matching_prefix_wins(path_map):
+ path_map("/data=/mnt/a,/data/movies=/mnt/m")
+ assert paths.map_path(None, "/data/movies/Foo.mkv") == str(Path("/mnt/m") / "Foo.mkv")
+ assert paths.map_path(None, "/data/tv/Bar.mkv") == str(Path("/mnt/a") / "tv/Bar.mkv")
+
+
+def test_a_prefix_matches_only_at_a_path_boundary(path_map):
+ # "/data/movies2" shares the characters of "/data/movies" but is another folder.
+ path_map("/data/movies=/mnt/m")
+ assert paths.map_path(None, "/data/movies2/Foo.mkv") == "/data/movies2/Foo.mkv"
+
+
+def test_the_prefix_itself_and_a_trailing_slash_in_the_map(path_map):
+ path_map("/data/movies/=/mnt/m")
+ assert paths.map_path(None, "/data/movies/Foo.mkv") == str(Path("/mnt/m") / "Foo.mkv")
+ path_map("/data/movies=/mnt/m")
+ assert paths.map_path(None, "/data/movies") == "/mnt/m"
+
+
+def test_no_map_leaves_the_path_as_plex_sees_it(path_map):
+ path_map("")
+ assert paths.map_path(None, "/data/movies/Foo.mkv") == "/data/movies/Foo.mkv"
+
+
+def test_the_local_file_must_exist(path_map, tmp_path):
+ (tmp_path / "movies").mkdir()
+ (tmp_path / "movies" / "Foo.mkv").write_bytes(b"x")
+ path_map(f"/data={tmp_path}")
+ assert (
+ paths.local_media_path(None, "/data/movies/Foo.mkv")
+ == (tmp_path / "movies" / "Foo.mkv").resolve()
+ )
+ assert paths.local_media_path(None, "/data/movies/Missing.mkv") is None
+ assert paths.local_media_path(None, "/data/movies") is None # a folder is not a file
+ assert paths.local_media_path(None, None) is None
+ assert paths.local_media_path(None, "") is None
+
+
+def test_a_path_that_cannot_be_resolved_is_logged_and_answered_none(path_map, monkeypatch, caplog):
+ path_map("")
+
+ def broken(self, strict=False):
+ raise OSError("dead mount")
+
+ monkeypatch.setattr(paths.Path, "resolve", broken)
+ assert paths.local_media_path(None, "/data/movies/Foo.mkv") is None
+ rec = next(
+ r for r in caplog.records if getattr(r, "event", None) == "plex.paths.resolve_failed"
+ )
+ # The title stays out of the log line: the path carries it.
+ assert "Foo" not in rec.getMessage() and rec.attrs == {"reason": "OSError"}
diff --git a/backend/tests/test_plex_watch_reconcile.py b/backend/tests/test_plex_watch_reconcile.py
new file mode 100644
index 00000000..08afa814
--- /dev/null
+++ b/backend/tests/test_plex_watch_reconcile.py
@@ -0,0 +1,167 @@
+"""The daily full watch reconcile (`run_plex_watch_reconcile`) — the one pass that DELETES watch state:
+a mirrored row Plex no longer has a record for is read as "Plex un-watched it" and cleared. E32 S2
+found it trusting a scan a Plex hiccup had cut short; these hold what it may and may not clear.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from app import sysconfig
+from app.models import PlexItem, PlexLibrary, PlexLink, PlexState, User
+from app.plex import watch_sync
+
+
+class _FakePlex:
+ """A PlexClient stand-in: one movie section whose listing reports `total` (more than it hands
+ over = a walk cut short), and a record of every write the reconcile sends back to Plex."""
+
+ def __init__(self, leaves: list[dict], total: int | None = None):
+ self.leaves = leaves
+ self.total = len(leaves) if total is None else total
+ self.calls: list[tuple] = []
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+ def sections(self):
+ return [{"type": "movie", "key": "1"}]
+
+ def section_items(self, key, item_type, start, size):
+ return self.leaves[start : start + size], self.total
+
+ def scrobble(self, rk):
+ self.calls.append(("scrobble", rk))
+
+ def unscrobble(self, rk):
+ self.calls.append(("unscrobble", rk))
+
+ def set_timeline(self, rk, time_ms, duration_ms):
+ self.calls.append(("timeline", rk, time_ms))
+
+
+@pytest.fixture
+def world(db):
+ """Five mirrored movies and the owner's link; each test sets the local states it needs."""
+ sysconfig.set_value(db, "plex_enabled", True)
+ lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
+ user = User(email="reconcile@example.com")
+ db.add_all([lib, user])
+ db.flush()
+ items = {}
+ for rk in ("m1", "m2", "m3", "m4", "m5"):
+ items[rk] = PlexItem(
+ rating_key=rk, library_id=lib.id, kind="movie", title=rk, duration_s=6000
+ )
+ db.add(items[rk])
+ db.add(
+ PlexLink(
+ user_id=user.id,
+ uses_admin=True,
+ sync_enabled=True,
+ initial_import_done=True,
+ plex_account_id=1,
+ )
+ )
+ db.flush()
+
+ def state(rk, **kw):
+ db.add(PlexState(user_id=user.id, item_id=items[rk].id, **kw))
+
+ def rows():
+ db.expire_all()
+ by_id = {i.id: rk for rk, i in items.items()}
+ return {
+ by_id[s.item_id]: (s.status, s.position_seconds, s.synced_to_plex)
+ for s in db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars()
+ }
+
+ return state, rows
+
+
+def _run(db, monkeypatch, plex):
+ monkeypatch.setattr(watch_sync, "PlexClient", lambda _db: plex)
+ return watch_sync.run_plex_watch_reconcile(db)
+
+
+def _local_states(state):
+ state("m1", status="watched", synced_to_plex=True) # mirrored; Plex has no record now
+ state("m2", status="watched", synced_to_plex=False) # local change that never reached Plex
+ state("m3", status="hidden", synced_to_plex=True) # Siftlode-only, never cleared
+ state("m5", status="new", position_seconds=300, synced_to_plex=True) # Plex says otherwise
+
+
+def test_a_complete_scan_clears_what_plex_unwatched_and_keeps_the_rest(db, monkeypatch, world):
+ state, rows = world
+ _local_states(state)
+ db.commit()
+ plex = _FakePlex(
+ [
+ {"ratingKey": "m3"}, # listed, no watch signal
+ {"ratingKey": "m4", "viewCount": 1, "lastViewedAt": 1_700_000_000},
+ {"ratingKey": "m5", "viewOffset": 120_000, "lastViewedAt": 1_700_000_000},
+ ]
+ )
+ out = _run(db, monkeypatch, plex)
+ assert rows() == {
+ "m2": ("watched", 0, True), # re-pushed, then settled
+ "m3": ("hidden", 0, True),
+ "m4": ("watched", 0, True), # pulled in
+ "m5": ("new", 120, True), # Plex's position wins
+ }
+ assert ("scrobble", "m2") in plex.calls
+ assert out["cleared"] == 1 and out["repushed"] == 1 and "incomplete" not in out
+
+
+def test_a_scan_cut_short_clears_nothing(db, monkeypatch, world):
+ state, rows = world
+ _local_states(state)
+ db.commit()
+ # The server said 5, the walk got 3: m1 may simply not have been reached.
+ plex = _FakePlex(
+ [
+ {"ratingKey": "m3"},
+ {"ratingKey": "m4", "viewCount": 1, "lastViewedAt": 1_700_000_000},
+ {"ratingKey": "m5", "viewOffset": 120_000, "lastViewedAt": 1_700_000_000},
+ ],
+ total=5,
+ )
+ out = _run(db, monkeypatch, plex)
+ after = rows()
+ assert after["m1"] == ("watched", 0, True) # NOT un-watched on a partial picture
+ assert after["m4"] == ("watched", 0, True) and after["m5"] == ("new", 120, True) # pulls stand
+ assert after["m2"] == ("watched", 0, True) # the dirty row is still pushed up
+ assert out["cleared"] == 0 and out["incomplete"] == 1
+
+
+def test_an_empty_listing_is_not_a_library_of_unwatched_titles(db, monkeypatch, world):
+ state, rows = world
+ state("m1", status="watched", synced_to_plex=True)
+ db.commit()
+ out = _run(db, monkeypatch, _FakePlex([]))
+ assert rows() == {"m1": ("watched", 0, True)}
+ assert out["cleared"] == 0
+
+
+def test_a_dirty_resume_is_re_pushed_as_a_timeline_and_a_dirty_reset_as_an_unscrobble(
+ db, monkeypatch, world
+):
+ state, rows = world
+ state("m1", status="new", position_seconds=300, synced_to_plex=False)
+ state("m2", status="new", position_seconds=2, synced_to_plex=False) # under the 5 s floor
+ db.commit()
+ plex = _FakePlex([{"ratingKey": rk} for rk in ("m1", "m2", "m3", "m4", "m5")])
+ _run(db, monkeypatch, plex)
+ assert ("timeline", "m1", 300_000) in plex.calls
+ assert ("unscrobble", "m2") in plex.calls
+ assert {rk: synced for rk, (_s, _p, synced) in rows().items()} == {"m1": True, "m2": True}
+
+
+def test_watch_fields_map_to_a_state_only_when_plex_holds_a_signal():
+ to = watch_sync._plex_watch_to_state
+ assert to(None, None, None) is None
+ assert to(0, 4_000, None) is None # a stray few seconds is not "in progress"
+ assert to(0, 90_000, 1_700_000_000)[:2] == ("new", 90)
+ assert to("2", 90_000, 1_700_000_000)[:2] == ("watched", 0) # watched wins over an offset
diff --git a/backend/tests/test_source_size.py b/backend/tests/test_source_size.py
index 467f3e1e..f0388bcf 100644
--- a/backend/tests/test_source_size.py
+++ b/backend/tests/test_source_size.py
@@ -32,7 +32,6 @@ BIG_MODULES: dict[str, int] = {
"app/models.py": 1607,
"app/plex/stream.py": 1128,
"app/plex/subtitles.py": 1092,
- "app/plex/sync.py": 841,
"app/routes/admin.py": 864,
"app/routes/auth.py": 1353,
"app/routes/downloads.py": 1237,
From 43e4929b4528c1ccf9c005e0b160a86fc3072574 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 19:33:38 +0200
Subject: [PATCH 16/54] fix(sync): an empty answer no longer empties a mirror;
maintenance checks each video once a run
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The catalog prune and the subscription import have long refused to reconcile against an empty
listing that contradicts what we hold (should_prune: a 200 with no items is a blip far more often
than a user who removed everything). Two mirrors did not: the YouTube playlist mirrors (dropped
every mirror) and the Plex collections (dropped every collection with its user-set `editable`
flag, and every member's keys — the guard landed in plex/sync.py with the previous commit). Both
now hold the same rule; test_empty_listing_guards covers each way (seen red).
sync/maintenance (19% covered) gets a lifecycle test: recover, grace, delete + one notification
per user, live stream kept, abandoned premiere. It found the rolling revalidation re-fetching the
same videos until `checked` reached the batch (15000 by default) whenever fewer videos were
available — quota spent on answers it already had; a run now skips what it has checked.
---
backend/app/sync/maintenance.py | 6 +-
backend/app/sync/playlists.py | 9 +-
backend/tests/test_empty_listing_guards.py | 111 +++++++++++++++
backend/tests/test_maintenance_lifecycle.py | 147 ++++++++++++++++++++
4 files changed, 271 insertions(+), 2 deletions(-)
create mode 100644 backend/tests/test_empty_listing_guards.py
create mode 100644 backend/tests/test_maintenance_lifecycle.py
diff --git a/backend/app/sync/maintenance.py b/backend/app/sync/maintenance.py
index 680a63cc..2548d7dd 100644
--- a/backend/app/sync/maintenance.py
+++ b/backend/app/sync/maintenance.py
@@ -22,7 +22,7 @@ backfill reserve as the other jobs so interactive syncs never starve.
from datetime import timedelta
-from sqlalchemy import select
+from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app import logs, progress, quota, sysconfig
@@ -190,6 +190,10 @@ def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
db.execute(
select(Video)
.where(Video.unavailable_since.is_(None))
+ # Not one this run already checked: with fewer available videos than the batch, the
+ # loop used to wrap around and re-fetch the same ones until `checked` reached the
+ # batch, spending quota on answers it already had (E32 S2).
+ .where(or_(Video.last_checked_at.is_(None), Video.last_checked_at < now))
# NULLS FIRST = never-checked first, then least-recently-checked; equivalent to the old
# `(last_checked_at IS NULL) DESC, last_checked_at ASC` but in the exact shape of the
# partial ix_videos_recheck_order (migration 0069, PERF-18) so it rides that index.
diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py
index 4c16e8e8..3c02ff58 100644
--- a/backend/app/sync/playlists.py
+++ b/backend/app/sync/playlists.py
@@ -22,6 +22,7 @@ from app import logs, progress, quota
from app.auth import has_read_scope
from app.models import Channel, Playlist, PlaylistItem, User, Video
from app.sync.runner import token_users
+from app.sync.subscriptions import should_prune
from app.sync.videos import apply_video_details, parse_dt
from app.youtube.client import YouTubeClient, YouTubeError
@@ -342,7 +343,13 @@ def pull_user_playlists(db: Session, user: User) -> dict:
.all()
)
# Drop mirrors whose YouTube playlist no longer exists — locked first, as every item write.
- gone = [pl for ytid, pl in existing.items() if ytid not in yt_ids]
+ # Never on an empty answer that contradicts the mirrors we hold: the same 200-with-no-items
+ # blip `should_prune` guards the subscriptions against would drop every mirror (E32 S2).
+ gone = (
+ [pl for ytid, pl in existing.items() if ytid not in yt_ids]
+ if should_prune(len(yt_ids), len(existing))
+ else []
+ )
if gone:
lock_playlists(db, *(pl.id for pl in gone))
for pl in gone:
diff --git a/backend/tests/test_empty_listing_guards.py b/backend/tests/test_empty_listing_guards.py
new file mode 100644
index 00000000..8c3942c7
--- /dev/null
+++ b/backend/tests/test_empty_listing_guards.py
@@ -0,0 +1,111 @@
+"""An empty answer is not an emptied library (E32 S2).
+
+Every mirror here reconciles: what the remote no longer lists is removed locally. The catalog
+(`_prune_stale`) and the subscriptions (`should_prune`) had long refused to do that on an empty
+listing that contradicts what we hold — a 200 with no items is a blip far more often than a user
+who removed everything. Two mirrors did not: the Plex collections (a row carries the user-set
+`editable` flag, and every member its keys) and the YouTube playlist mirrors. These hold both.
+"""
+
+from sqlalchemy import select
+
+from app.models import Playlist, PlexCollection, PlexItem, PlexLibrary
+from app.plex import sync as plex_sync
+from app.sync import playlists as plsync
+
+
+class _Plex:
+ def __init__(self, collections: list[dict], children: dict[str, list[dict]]):
+ self._collections = collections
+ self._children = children
+
+ def collections(self, section_key):
+ return self._collections
+
+ def collection_children(self, rk):
+ return self._children.get(rk, [])
+
+
+def _library_with_a_collection(db):
+ lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
+ db.add(lib)
+ db.flush()
+ db.add(PlexCollection(rating_key="c1", library_id=lib.id, title="Bond", editable=True))
+ movie = PlexItem(
+ rating_key="m1", library_id=lib.id, kind="movie", title="Dr. No", collection_keys=["c1"]
+ )
+ db.add(movie)
+ db.commit()
+ return lib, movie
+
+
+def test_an_empty_collection_listing_keeps_the_collections_and_their_members(db):
+ lib, movie = _library_with_a_collection(db)
+ stats = {"collections": 0}
+ plex_sync._sync_collections(db, _Plex([], {}), lib, stats)
+ db.commit()
+ db.expire_all()
+ col = db.execute(select(PlexCollection).where(PlexCollection.rating_key == "c1")).scalar_one()
+ assert col.editable is True
+ assert db.get(PlexItem, movie.id).collection_keys == ["c1"]
+
+
+def test_a_listing_with_something_in_it_still_prunes_what_it_no_longer_has(db):
+ lib, movie = _library_with_a_collection(db)
+ stats = {"collections": 0}
+ other = {"ratingKey": "c2", "title": "Alien"}
+ plex_sync._sync_collections(db, _Plex([other], {"c2": []}), lib, stats)
+ db.commit()
+ db.expire_all()
+ keys = set(db.execute(select(PlexCollection.rating_key)).scalars())
+ assert keys == {"c2"}
+ assert db.get(PlexItem, movie.id).collection_keys is None
+
+
+class _YT:
+ def __init__(self, playlists: list[dict]):
+ self._playlists = playlists
+
+ def __call__(self, *_):
+ return self
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ return False
+
+ def iter_my_playlists(self):
+ return self._playlists
+
+ def iter_playlist_items_with_ids(self, _ytid):
+ return []
+
+ def iter_my_playlist_video_ids(self, _ytid):
+ return []
+
+
+def test_an_empty_playlist_answer_keeps_the_youtube_mirrors(db, make_user, monkeypatch):
+ user = make_user()
+ db.add(Playlist(user_id=user.id, name="Mixes", source="youtube", yt_playlist_id="PL1"))
+ db.commit()
+ monkeypatch.setattr(plsync, "YouTubeClient", _YT([]))
+ plsync.pull_user_playlists(db, user)
+ db.expire_all()
+ names = db.execute(select(Playlist.name).where(Playlist.user_id == user.id)).scalars().all()
+ assert "Mixes" in names
+
+
+def test_a_playlist_answer_with_something_in_it_still_drops_a_vanished_mirror(
+ db, make_user, monkeypatch
+):
+ user = make_user()
+ db.add(Playlist(user_id=user.id, name="Mixes", source="youtube", yt_playlist_id="PL1"))
+ db.commit()
+ monkeypatch.setattr(plsync, "YouTubeClient", _YT([{"id": "PL2", "title": "New"}]))
+ plsync.pull_user_playlists(db, user)
+ db.expire_all()
+ ytids = set(
+ db.execute(select(Playlist.yt_playlist_id).where(Playlist.source == "youtube")).scalars()
+ )
+ assert "PL1" not in ytids
diff --git a/backend/tests/test_maintenance_lifecycle.py b/backend/tests/test_maintenance_lifecycle.py
new file mode 100644
index 00000000..ec725568
--- /dev/null
+++ b/backend/tests/test_maintenance_lifecycle.py
@@ -0,0 +1,147 @@
+"""sync/maintenance — the job that HARD-DELETES videos (E32 S2: 19% covered).
+
+Its lifecycle: a video missing from videos.list is flagged and hidden; after the grace period a
+still-missing one is deleted (cascading to every playlist that held it) and each affected user
+gets one notification; a video that answers again is unhidden. A live broadcast is never a delete
+trigger, and an upcoming premiere that never went live is. YouTube is a fake that answers
+videos.list from a dict.
+"""
+
+from datetime import timedelta
+
+import pytest
+from sqlalchemy import select
+
+from app.models import Channel, Notification, Playlist, PlaylistItem, Video
+from app.sync import maintenance
+from app.utils import now_utc
+
+
+class _YT:
+ def __init__(self, items: dict[str, dict]):
+ self.items = items
+ self.asked: list[list[str]] = []
+
+ def get_videos(self, ids):
+ self.asked.append(list(ids))
+ return [self.items[i] for i in ids if i in self.items]
+
+
+def _item(vid: str, **snippet) -> dict:
+ return {"id": vid, "snippet": {"title": vid, **snippet}}
+
+
+@pytest.fixture
+def channel(db):
+ ch = Channel(id="UC_maint", title="Maint")
+ db.add(ch)
+ db.flush()
+ return ch
+
+
+def test_a_flagged_video_that_answers_again_is_unhidden(db, channel):
+ v = Video(
+ id="v1", channel_id=channel.id, unavailable_since=now_utc(), unavailable_reason="removed"
+ )
+ db.add(v)
+ db.commit()
+ out = maintenance._recheck_flagged(db, _YT({"v1": _item("v1")}))
+ db.refresh(v)
+ assert v.unavailable_since is None and v.unavailable_reason is None
+ assert out == {"checked": 1, "recovered": 1, "deleted": 0, "notified": 0}
+
+
+def test_a_video_still_missing_inside_its_grace_period_is_kept_hidden(db, channel):
+ v = Video(
+ id="v1",
+ channel_id=channel.id,
+ unavailable_since=now_utc() - timedelta(days=maintenance.DEFAULT_GRACE_DAYS - 1),
+ unavailable_reason="removed",
+ )
+ db.add(v)
+ db.commit()
+ out = maintenance._recheck_flagged(db, _YT({}))
+ assert db.get(Video, "v1") is not None
+ assert out["deleted"] == 0
+
+
+def test_past_the_grace_period_it_is_deleted_and_each_user_who_kept_it_told_once(
+ db, channel, make_user
+):
+ a, b = make_user(), make_user()
+ gone_since = now_utc() - timedelta(days=maintenance.DEFAULT_GRACE_DAYS + 1)
+ for vid in ("v1", "v2"):
+ db.add(
+ Video(
+ id=vid,
+ channel_id=channel.id,
+ title=f"T {vid}",
+ unavailable_since=gone_since,
+ unavailable_reason="removed",
+ )
+ )
+ pa = Playlist(user_id=a.id, name="A")
+ pb = Playlist(user_id=b.id, name="B")
+ db.add_all([pa, pb])
+ db.flush()
+ db.add_all(
+ [
+ PlaylistItem(playlist_id=pa.id, video_id="v1", position=0),
+ PlaylistItem(playlist_id=pa.id, video_id="v2", position=1),
+ PlaylistItem(playlist_id=pb.id, video_id="v2", position=0),
+ ]
+ )
+ db.commit()
+ out = maintenance._recheck_flagged(db, _YT({}))
+ db.expire_all()
+ assert db.get(Video, "v1") is None and db.get(Video, "v2") is None
+ assert db.execute(select(PlaylistItem)).scalars().all() == [] # the cascade
+ notes = {
+ n.user_id: n.data["count"]
+ for n in db.execute(select(Notification).where(Notification.type == "maintenance"))
+ .scalars()
+ .all()
+ }
+ assert notes == {a.id: 2, b.id: 1} # one per user, not one per video
+ assert out == {"checked": 2, "recovered": 0, "deleted": 2, "notified": 2}
+
+
+def test_rolling_revalidation_flags_the_missing_and_keeps_a_live_stream(db, channel, monkeypatch):
+ monkeypatch.setattr(maintenance.sysconfig, "get_int", lambda db, key: 10)
+ monkeypatch.setattr(maintenance.quota, "backfill_reserve_reached", lambda: False)
+ db.add_all(
+ [
+ Video(id="ok", channel_id=channel.id),
+ Video(id="gone", channel_id=channel.id),
+ Video(id="live", channel_id=channel.id),
+ ]
+ )
+ db.commit()
+ yt = _YT({"ok": _item("ok"), "live": _item("live", liveBroadcastContent="live")})
+ out = maintenance._revalidate_rolling(db, yt)
+ db.expire_all()
+ assert db.get(Video, "gone").unavailable_reason == "removed"
+ assert db.get(Video, "ok").unavailable_since is None
+ assert db.get(Video, "live").unavailable_since is None
+ assert out == {"checked": 3, "flagged": 1}
+ assert all(db.get(Video, v).last_checked_at is not None for v in ("ok", "gone", "live"))
+
+
+def test_an_upcoming_premiere_counts_as_abandoned_only_when_it_never_went_live():
+ past = (now_utc() - timedelta(days=3)).isoformat()
+ soon = (now_utc() - timedelta(days=1)).isoformat()
+
+ def item(**live):
+ return {"snippet": {"liveBroadcastContent": "upcoming"}, "liveStreamingDetails": live}
+
+ assert maintenance._is_abandoned_upcoming(item(scheduledStartTime=past)) is True
+ assert maintenance._is_abandoned_upcoming(item(scheduledStartTime=soon)) is False
+ assert (
+ maintenance._is_abandoned_upcoming(item(scheduledStartTime=past, actualStartTime=past))
+ is False
+ )
+ assert (
+ maintenance._is_abandoned_upcoming({"snippet": {"liveBroadcastContent": "none"}}) is False
+ )
+ # …and an abandoned premiere has no grace period: the confirming sweep deletes it.
+ assert maintenance._grace_days("abandoned") == 0
From 328d415cfe1084fa84e13d8070e221ed04143eba Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 19:35:36 +0200
Subject: [PATCH 17/54] test(e2ee): the DM crypto round-trips, refuses the
wrong key, and keeps its nonces apart
lib/e2ee was 14% covered and fails silently in every way it can break. Real WebCrypto and an
in-memory IndexedDB: a message decrypts for its partner and nobody else, every message gets its own
IV, a new device unlocks with the passphrase and refuses a wrong one, the key survives a reload
until reset, a partner's rotated key is used only after forgetConv, and the key check never shares
the wrap IV (GCM nonce reuse). Mutations seen red: the shared IV, a no-op forgetConv, no persist.
---
frontend/src/lib/e2ee.test.ts | 180 ++++++++++++++++++++++++++++++++++
1 file changed, 180 insertions(+)
create mode 100644 frontend/src/lib/e2ee.test.ts
diff --git a/frontend/src/lib/e2ee.test.ts b/frontend/src/lib/e2ee.test.ts
new file mode 100644
index 00000000..c7b3ac01
--- /dev/null
+++ b/frontend/src/lib/e2ee.test.ts
@@ -0,0 +1,180 @@
+// E32 S2: lib/e2ee was 14% covered, and every way it can break is silent — a message that will not
+// decrypt, a passphrase that unlocks nothing, a key that does not survive a reload. Real WebCrypto
+// (Node's), a minimal in-memory IndexedDB (the module uses open / one store / get / put / delete),
+// and a fresh module per "device", because the unlocked key is module state.
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { MyKeyBundle } from "./api";
+
+type E2ee = typeof import("./e2ee");
+
+function memoryIndexedDb() {
+ const stores = new Map>();
+ const later = (fn: () => void) => setTimeout(fn, 0);
+ const request = (run: () => T) => {
+ const r: { result?: T; onsuccess?: () => void; onerror?: () => void } = {};
+ later(() => {
+ r.result = run();
+ r.onsuccess?.();
+ });
+ return r;
+ };
+ const db = {
+ createObjectStore: (name: string) => stores.set(name, new Map()),
+ transaction: (name: string) => {
+ const tx: { oncomplete?: () => void; onerror?: () => void; objectStore: () => unknown } = {
+ objectStore: () => {
+ const s = stores.get(name)!;
+ const done = (v: T) => (later(() => tx.oncomplete?.()), v);
+ return {
+ get: (k: unknown) => request(() => s.get(k)),
+ put: (v: unknown, k: unknown) => done(s.set(k, v)),
+ delete: (k: unknown) => done(s.delete(k)),
+ };
+ },
+ };
+ return tx;
+ },
+ };
+ return {
+ open: () => {
+ const r: { result?: unknown; onupgradeneeded?: () => void; onsuccess?: () => void } = {};
+ later(() => {
+ r.result = db;
+ if (stores.size === 0) r.onupgradeneeded?.();
+ r.onsuccess?.();
+ });
+ return r;
+ },
+ };
+}
+
+async function freshDevice(): Promise {
+ vi.resetModules();
+ return import("./e2ee");
+}
+
+const bundleOf = (k: Awaited>): MyKeyBundle =>
+ ({ ...k, has_key: true }) as unknown as MyKeyBundle;
+
+beforeEach(() => {
+ vi.stubGlobal("indexedDB", memoryIndexedDb());
+});
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("e2ee", () => {
+ it("a message one user encrypts, the other decrypts — and nobody else", async () => {
+ const alice = await freshDevice();
+ const aliceKey = await alice.setup(1, "alice passphrase");
+ const bob = await freshDevice();
+ const bobKey = await bob.setup(2, "bob passphrase");
+
+ const msg = await alice.encryptFor(2, bobKey.public_key, "szia, Bob — ő 🎬");
+ expect(msg.ciphertext).not.toContain("Bob");
+ expect(await bob.decryptFrom(1, aliceKey.public_key, msg.ciphertext, msg.iv)).toBe(
+ "szia, Bob — ő 🎬",
+ );
+
+ const eve = await freshDevice();
+ await eve.setup(3, "eve passphrase");
+ await expect(eve.decryptFrom(1, aliceKey.public_key, msg.ciphertext, msg.iv)).rejects.toThrow();
+ }, 30_000);
+
+ it("each message gets its own IV", async () => {
+ const alice = await freshDevice();
+ await alice.setup(1, "p");
+ const bob = await freshDevice();
+ const bobKey = await bob.setup(2, "q");
+ const a = await alice.encryptFor(2, bobKey.public_key, "same");
+ const b = await alice.encryptFor(2, bobKey.public_key, "same");
+ expect(a.iv).not.toBe(b.iv);
+ expect(a.ciphertext).not.toBe(b.ciphertext);
+ }, 30_000);
+
+ it("a new device unlocks with the passphrase and refuses a wrong one", async () => {
+ const first = await freshDevice();
+ const bundle = bundleOf(await first.setup(1, "the right one"));
+
+ vi.stubGlobal("indexedDB", memoryIndexedDb()); // another browser: nothing stored
+ const second = await freshDevice();
+ expect(await second.loadFromDevice(1)).toBe(false);
+ await expect(second.unlock(1, "a wrong one", bundle)).rejects.toThrow();
+ expect(second.isUnlocked()).toBe(false);
+ await second.unlock(1, "the right one", bundle);
+ expect(second.isUnlocked()).toBe(true);
+ }, 30_000);
+
+ it("the unlocked key survives a reload on the same device, until it is reset", async () => {
+ const before = await freshDevice();
+ await before.setup(7, "p");
+ const after = await freshDevice(); // a reload: module state gone, IndexedDB kept
+ expect(after.isUnlocked()).toBe(false);
+ const seen = vi.fn();
+ after.subscribeUnlock(seen);
+ expect(await after.loadFromDevice(7)).toBe(true);
+ expect(after.isUnlocked()).toBe(true);
+ expect(seen).toHaveBeenCalled();
+
+ await after.resetLocal(7);
+ expect(after.isUnlocked()).toBe(false);
+ const again = await freshDevice();
+ expect(await again.loadFromDevice(7)).toBe(false);
+ }, 30_000);
+
+ it("an incomplete bundle and a locked device are refused, not guessed around", async () => {
+ const e = await freshDevice();
+ await expect(e.unlock(1, "p", { has_key: true } as unknown as MyKeyBundle)).rejects.toThrow(
+ "incomplete key bundle",
+ );
+ await expect(e.encryptFor(2, "AAAA", "x")).rejects.toThrow("locked");
+ });
+
+ it("the key check uses its own nonce, never the wrap IV (GCM nonce reuse)", async () => {
+ const e = await freshDevice();
+ const k = await e.setup(1, "p");
+ // Re-derive the wrap key the way the module does; then the key check must NOT open under the
+ // wrap IV (it would, were the nonce reused), while the wrapped key does.
+ const raw = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
+ const base = await crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode("p"),
+ "PBKDF2",
+ false,
+ ["deriveKey"],
+ );
+ const wrapKey = await crypto.subtle.deriveKey(
+ { name: "PBKDF2", salt: raw(k.salt), iterations: 600_000, hash: "SHA-256" },
+ base,
+ { name: "AES-GCM", length: 256 },
+ false,
+ ["decrypt"],
+ );
+ const open = (ct: string) =>
+ crypto.subtle.decrypt({ name: "AES-GCM", iv: raw(k.wrap_iv) }, wrapKey, raw(ct));
+ await expect(open(k.wrapped_private_key)).resolves.toBeDefined();
+ await expect(open(k.key_check)).rejects.toThrow();
+ }, 30_000);
+
+ it("a partner's rotated key is used only after forgetConv", async () => {
+ const alice = await freshDevice();
+ const aliceKey = await alice.setup(1, "p");
+ const bobOld = await freshDevice();
+ const oldKey = await bobOld.setup(2, "q");
+ await alice.encryptFor(2, oldKey.public_key, "warm the cache");
+
+ const bobNew = await freshDevice(); // Bob reset his passphrase: a new keypair
+ const newKey = await bobNew.setup(2, "r");
+ // Cached by partner id alone: without forgetConv the OLD conversation key is still used…
+ const stale = await alice.encryptFor(2, newKey.public_key, "after the reset");
+ await expect(
+ bobNew.decryptFrom(1, aliceKey.public_key, stale.ciphertext, stale.iv),
+ ).rejects.toThrow();
+ // …and after it, the new one.
+ alice.forgetConv(2);
+ const fresh = await alice.encryptFor(2, newKey.public_key, "after the reset");
+ expect(await bobNew.decryptFrom(1, aliceKey.public_key, fresh.ciphertext, fresh.iv)).toBe(
+ "after the reset",
+ );
+ }, 60_000);
+});
From 4fc307c42673b190cd1cc35b64e9b20a568dc594 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 19:37:24 +0200
Subject: [PATCH 18/54] fix(autotag): the orphan cleanup keeps a system tag a
saved view filters on
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Feed views store tag ids. A system tag no channel carried for a while was deleted and, once a
channel earned it again, recreated under a new id — the view's filter then pointed at nothing, the
feed dropped the unknown id, and the view showed the whole feed under its old name. The cleanup now
skips tags a saved feed view references (test seen red without it).
---
backend/app/sync/autotag.py | 14 +++++++++--
backend/tests/test_autotag_orphans.py | 36 +++++++++++++++++++++++++++
2 files changed, 48 insertions(+), 2 deletions(-)
create mode 100644 backend/tests/test_autotag_orphans.py
diff --git a/backend/app/sync/autotag.py b/backend/app/sync/autotag.py
index 39ce4ac9..5ce0ba07 100644
--- a/backend/app/sync/autotag.py
+++ b/backend/app/sync/autotag.py
@@ -12,7 +12,7 @@ from sqlalchemy import and_, exists, func, select
from sqlalchemy.orm import Session
from app import logs, progress, sysconfig
-from app.models import Channel, ChannelTag, Tag, Video
+from app.models import Channel, ChannelTag, SavedView, Tag, Video
log = logs.get_logger(__name__)
@@ -289,12 +289,22 @@ def run_autotag_all(db: Session, only_missing: bool = False) -> dict:
def _cleanup_orphan_system_tags(db: Session) -> int:
- """Delete system tags that no longer link to any channel (e.g. after a mapping change)."""
+ """Delete system tags that no longer link to any channel (e.g. after a mapping change) — but not
+ one a saved feed view filters on. Views hold tag IDS, and a deleted tag comes back under a new id
+ when a channel earns it again, so the view's filter would point at nothing; the feed drops an
+ unknown id, and the view showed the whole feed under its old name (E32 S2)."""
+ in_views = {
+ t
+ for (filters,) in db.execute(select(SavedView.filters).where(SavedView.source == "feed"))
+ for t in ((filters or {}).get("tags") or [])
+ if isinstance(t, int)
+ }
orphans = (
db.execute(
select(Tag).where(
Tag.user_id.is_(None),
~exists().where(ChannelTag.tag_id == Tag.id),
+ Tag.id.notin_(in_views),
)
)
.scalars()
diff --git a/backend/tests/test_autotag_orphans.py b/backend/tests/test_autotag_orphans.py
new file mode 100644
index 00000000..ef2b2759
--- /dev/null
+++ b/backend/tests/test_autotag_orphans.py
@@ -0,0 +1,36 @@
+"""sync/autotag's orphan cleanup (E32 S2: the module was 32% covered).
+
+A system tag no channel carries any more is deleted — unless a saved feed view filters on it. The
+views hold tag ids; a deleted tag returns under a NEW id when a channel earns it again, and the feed
+drops an unknown id, so the view silently showed the whole feed under its old name.
+"""
+
+from sqlalchemy import select
+
+from app.models import Channel, ChannelTag, SavedView, Tag
+from app.sync import autotag
+
+
+def test_an_orphan_system_tag_goes_but_one_a_saved_view_filters_on_stays(db, make_user):
+ user = make_user()
+ used = Tag(user_id=None, name="Gaming", category="topic")
+ orphan = Tag(user_id=None, name="Cooking", category="topic")
+ in_view = Tag(user_id=None, name="Music", category="topic")
+ own = Tag(user_id=user.id, name="Mine", category="other")
+ ch = Channel(id="UC_tags", title="T")
+ db.add_all([used, orphan, in_view, own, ch])
+ db.flush()
+ db.add(ChannelTag(channel_id=ch.id, tag_id=used.id, user_id=None, source="auto"))
+ db.add(
+ SavedView(
+ user_id=user.id, source="feed", name="Music", filters={"tags": [in_view.id], "q": ""}
+ )
+ )
+ # A Plex view's filters are another vocabulary; they must not break the scan.
+ db.add(SavedView(user_id=user.id, source="plex", name="P", filters={"genres": ["Drama"]}))
+ db.commit()
+
+ assert autotag._cleanup_orphan_system_tags(db) == 1
+ db.expire_all()
+ names = set(db.execute(select(Tag.name)).scalars())
+ assert names == {"Gaming", "Music", "Mine"} # only the unreferenced system orphan went
From c4abbe48c14c5eb61bae9d4cddbd37f1a943cb74 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 19:45:14 +0200
Subject: [PATCH 19/54] refactor(sync): the empty-answer rule for a mirror is
one function, vanished()
The YouTube playlist guard from the previous commit put sync/playlists.py over its size ceiling;
the rule now lives beside should_prune as vanished(existing, listed), which the mirror calls in
place of its own list. The pyright baseline also drops by one (187), from the plex/overlay split.
---
backend/.basedpyright/baseline.json | 8 --------
backend/app/sync/playlists.py | 16 ++++------------
backend/app/sync/subscriptions.py | 11 ++++++++++-
3 files changed, 14 insertions(+), 21 deletions(-)
diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json
index a669dffb..2a942acc 100644
--- a/backend/.basedpyright/baseline.json
+++ b/backend/.basedpyright/baseline.json
@@ -580,14 +580,6 @@
"endColumn": 89,
"lineCount": 1
}
- },
- {
- "code": "reportArgumentType",
- "range": {
- "startColumn": 17,
- "endColumn": 18,
- "lineCount": 1
- }
}
],
"./app/plex/tmdb.py": [
diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py
index 3c02ff58..bde36501 100644
--- a/backend/app/sync/playlists.py
+++ b/backend/app/sync/playlists.py
@@ -22,7 +22,7 @@ from app import logs, progress, quota
from app.auth import has_read_scope
from app.models import Channel, Playlist, PlaylistItem, User, Video
from app.sync.runner import token_users
-from app.sync.subscriptions import should_prune
+from app.sync.subscriptions import vanished
from app.sync.videos import apply_video_details, parse_dt
from app.youtube.client import YouTubeClient, YouTubeError
@@ -338,18 +338,10 @@ def pull_user_playlists(db: Session, user: User) -> dict:
Playlist.source == "local",
Playlist.yt_playlist_id.is_not(None),
)
- )
- .scalars()
- .all()
- )
- # Drop mirrors whose YouTube playlist no longer exists — locked first, as every item write.
- # Never on an empty answer that contradicts the mirrors we hold: the same 200-with-no-items
- # blip `should_prune` guards the subscriptions against would drop every mirror (E32 S2).
- gone = (
- [pl for ytid, pl in existing.items() if ytid not in yt_ids]
- if should_prune(len(yt_ids), len(existing))
- else []
+ ).scalars()
)
+ # Drop vanished mirrors (none on an empty answer) — locked first, as every item write.
+ gone = vanished(existing, yt_ids)
if gone:
lock_playlists(db, *(pl.id for pl in gone))
for pl in gone:
diff --git a/backend/app/sync/subscriptions.py b/backend/app/sync/subscriptions.py
index ae3cbe92..4621dbfa 100644
--- a/backend/app/sync/subscriptions.py
+++ b/backend/app/sync/subscriptions.py
@@ -1,6 +1,6 @@
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
-from collections.abc import Iterable
+from collections.abc import Collection, Iterable
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, select
@@ -119,6 +119,15 @@ def should_prune(fetched_count: int, existing_count: int) -> bool:
return fetched_count > 0 or existing_count == 0
+def vanished[K, V](existing: dict[K, V], listed: Collection[K]) -> list[V]:
+ """The local mirrors whose key the remote listing no longer has — none at all when the listing
+ is an empty answer that contradicts them (`should_prune`). The YouTube playlist mirrors use it:
+ without it, one blip dropped every mirror (E32 S2)."""
+ if not should_prune(len(listed), len(existing)):
+ return []
+ return [row for key, row in existing.items() if key not in listed]
+
+
def import_subscriptions(db: Session, user: User) -> dict:
"""Pull all subscriptions for `user`, upsert channels + subscription links, prune
channels the user has unsubscribed from on YouTube, and fetch channel details for
From 52f12e5331db9ae08cbb3709387bbf5cae53bae2 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 21:10:27 +0200
Subject: [PATCH 20/54] test(e2e): click only a channel link that is the
element hit at its centre
A card inside the viewport can still sit under the chrome above the scroller (the update
banner); the covered click made Playwright scroll and retry, moving the feed the scroll-restore
test then measured. webkit failed 3 in 24 before, 0 in 48 after.
---
frontend/e2e/helpers.ts | 21 +++++++++++++--------
1 file changed, 13 insertions(+), 8 deletions(-)
diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts
index 0b1b3c6c..78dd0c80 100644
--- a/frontend/e2e/helpers.ts
+++ b/frontend/e2e/helpers.ts
@@ -13,19 +13,24 @@ export async function gotoPage(page: Page, id: string): Promise {
}
/**
- * Click a channel link that is currently WITHIN the viewport — what a real user does. Never use
- * `.first()` for this after scrolling: the first rendered card lives in the virtualizer's overscan
- * ABOVE the viewport, so Playwright would scroll it into view to click it, moving the scroll
- * position — fatal to any test that then asserts on where the feed was. Returns false if none found.
+ * Click a channel link a real user can actually SEE and hit. Never use `.first()` for this after
+ * scrolling: the first rendered card lives in the virtualizer's overscan ABOVE the viewport, so
+ * Playwright would scroll it into view to click it, moving the scroll position — fatal to any test
+ * that then asserts on where the feed was. Being inside the viewport is not enough either: a card
+ * can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
+ * covered click makes Playwright scroll and retry — the same move (E32 S2: webkit failed 3 in 24).
+ * So the link must be the element hit at its own centre. Returns false if none found.
*/
export async function clickVisibleChannelLink(page: Page): Promise {
const links = page.getByTestId("video-card-channel").filter({ visible: true });
- const viewport = page.viewportSize();
- const height = viewport?.height ?? 800;
const count = await links.count();
for (let i = 0; i < count; i++) {
- const box = await links.nth(i).boundingBox();
- if (box && box.y >= 0 && box.y + box.height <= height) {
+ const hittable = await links.nth(i).evaluate((el) => {
+ const r = el.getBoundingClientRect();
+ const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
+ return hit !== null && el.contains(hit);
+ });
+ if (hittable) {
await links.nth(i).click();
return true;
}
From 8f1208fdfeea78f1971f3bd6fee8cba5cbcd97aa Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 21:53:05 +0200
Subject: [PATCH 21/54] fix(feed): a filter on a deleted tag matches nothing
and its chip says so
A saved view or the stored filter outlives its tag. feedquery dropped the unknown id, so the
view showed the whole feed under its old name. The id now lands in a MISSING_TAG_CATEGORY that
filters to nothing in the SQL and in feed_summary's mirror alike; the feed chip reads "Deleted
tag" (a placeholder while the tag list loads, the id if it failed). Channels' persisted tag
filter gets the same chip, first in the row so the +N overflow cannot fold it away.
---
backend/app/feedquery.py | 13 ++++-
backend/tests/test_feed_views.py | 21 ++++++++
frontend/src/components/Channels.tsx | 33 +++++-------
.../components/channelColumns.dom.test.tsx | 37 ++++++++++++-
frontend/src/components/channelColumns.tsx | 40 +++++++++++++-
.../src/components/tagChipNames.dom.test.tsx | 52 +++++++++++++++++++
frontend/src/i18n/locales/en/channels.json | 1 +
frontend/src/i18n/locales/en/feed.json | 1 +
frontend/src/i18n/locales/hu/channels.json | 1 +
frontend/src/i18n/locales/hu/feed.json | 1 +
frontend/src/lib/useActiveFilters.ts | 16 ++++--
11 files changed, 190 insertions(+), 26 deletions(-)
create mode 100644 frontend/src/components/tagChipNames.dom.test.tsx
diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py
index 84cfe50f..6e6d8714 100644
--- a/backend/app/feedquery.py
+++ b/backend/app/feedquery.py
@@ -106,15 +106,26 @@ def _tag_visible(user: User):
return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
+# The pseudo-category of a selected tag id no Tag row answers (a saved view or the feed state outlives
+# a deleted tag). It filters like a category whose tags no channel carries — to nothing, in the SQL and
+# in feed_summary's mirror alike — instead of the id being dropped, which showed the WHOLE feed under
+# the view's old name (E32 S2). No real category is called this (language/topic/other).
+MISSING_TAG_CATEGORY = "missing"
+
+
def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]:
"""Group tag ids by their category (language/topic/other). The single source for the category split
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
- two can't disagree on which selected tag belongs to which category."""
+ two can't disagree on which selected tag belongs to which category. An unknown id lands in
+ MISSING_TAG_CATEGORY."""
if not tag_ids:
return {} # skip the pointless `WHERE id IN ()` round-trip on the no-tag-filter hot path
out: dict[str, list[int]] = {}
for tid, cat in db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tag_ids))).all():
out.setdefault(cat, []).append(tid)
+ known = {tid for ids in out.values() for tid in ids}
+ if missing := [tid for tid in tag_ids if tid not in known]:
+ out[MISSING_TAG_CATEGORY] = missing
return out
diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py
index af190e8c..9b647160 100644
--- a/backend/tests/test_feed_views.py
+++ b/backend/tests/test_feed_views.py
@@ -168,9 +168,30 @@ _SUMMARY_CASES = [
"show=all&untagged=true&tags={L1}",
"show=all&q=clip",
"show=unwatched",
+ # A deleted tag's id (a saved view or the feed state still holds it) matches nothing.
+ "show=all&tags=987654",
+ "show=all&tags={L1}&tags=987654",
]
+def test_a_deleted_tags_id_filters_to_nothing_not_to_everything(db, as_user, make_user):
+ """A saved view or the feed's filter state keeps a tag id after the tag is deleted. The feed used
+ to drop an unknown id, so that view silently showed the WHOLE feed under its old name (E32 S2).
+ A filter on a tag that no longer exists matches nothing; the chip says the tag is gone."""
+ user, tag_ids = _seed_tagged(db, make_user)
+ client = as_user(user)
+ everything = client.get("/api/feed/count?show=all")
+ assert everything.status_code == 200
+ assert everything.json()["count"] == 8
+ for qs in ("show=all&tags=987654", f"show=all&tags={tag_ids['L1']}&tags=987654"):
+ r = client.get(f"/api/feed/count?{qs}")
+ assert r.status_code == 200
+ assert r.json()["count"] == 0, qs
+ r = client.get(f"/api/feed?{qs}")
+ assert r.status_code == 200
+ assert r.json()["items"] == [], qs
+
+
def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user, make_user):
"""The "No tag" filter (item B): `untagged=true` keeps only channels with NO tag the USER personally
attached (no user-owned ChannelTag). System/auto tags (user_id NULL) don't count, and another user's
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index 9048789f..e520b459 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -16,7 +16,7 @@ import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { Sparkles } from "lucide-react";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
-import { buildChannelColumns, applySyncFilter } from "./channelColumns";
+import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
import { notify } from "../lib/notifications";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
@@ -39,7 +39,7 @@ import { focusNavItem } from "../lib/listNav";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ui/ColumnSortControl";
import FilterChipGroup from "./ui/FilterChipGroup";
-import TagFilterChips, { type FilterChip } from "./TagFilterChips";
+import TagFilterChips from "./TagFilterChips";
import ActionMenu, { type ActionMenuItem } from "./ui/ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./ui/Pager";
@@ -345,22 +345,17 @@ export default function Channels() {
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
const toggleNoTagFilter = () =>
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
- // The tag-filter chips (user tags + the "No tag" sentinel), fed to TagFilterChips which folds any
- // overflow into a "+N" pill so the row stays one line and the trailing "…" actions can't wrap.
- const tagFilterChips: FilterChip[] = [
- ...userTags.map((tg) => ({
- key: `t${tg.id}`,
- label: tg.name,
- active: tagFilter.includes(String(tg.id)),
- onToggle: () => toggleTagFilter(tg.id),
- })),
- {
- key: NO_TAG_SENTINEL,
- label: t("channels.tags.noTag"),
- active: noTagOn,
- onToggle: toggleNoTagFilter,
- },
- ];
+ // The tag-filter chips, fed to TagFilterChips which folds any overflow into a "+N" pill so the row
+ // stays one line and the trailing "…" actions can't wrap.
+ const tagChips = tagFilterChips({
+ userTags,
+ allTags,
+ picked: tagFilter,
+ noTagOn,
+ toggle: toggleTagFilter,
+ toggleNoTag: toggleNoTagFilter,
+ t,
+ });
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
// payload, so it is read through an effect event. Naming it would re-seed the box on any render that
@@ -735,7 +730,7 @@ export default function Channels() {
- {!fold.folded && }
+ {!fold.folded && }
{!fold.folded && tagFilter.length > 0 && (
): ManagedChannel => ({
id: "c",
@@ -72,6 +78,33 @@ describe("tagsColumnTest (item B — 'No tag')", () => {
});
});
+describe("tagFilterChips — a pick whose tag is gone (E32 S2)", () => {
+ const mine = { id: 1, name: "Music", system: false } as Tag;
+ const t = ((k: string) => k) as unknown as TFunction;
+ const chips = (allTags: Tag[] | undefined, picked: string[]) =>
+ tagFilterChips({
+ userTags: allTags ? [mine] : [],
+ allTags,
+ picked,
+ noTagOn: false,
+ toggle: () => {},
+ toggleNoTag: () => {},
+ t,
+ });
+
+ it("stays a visible, active chip instead of an empty table with no reason", () => {
+ expect(chips([mine], ["1", "77"]).map((c) => [c.key, c.label, c.active])).toEqual([
+ ["t77", "channels.tags.deleted", true], // first: the "+N" overflow must not fold it away
+ ["t1", "Music", true],
+ [NO_TAG_SENTINEL, "channels.tags.noTag", false],
+ ]);
+ });
+
+ it("is not called deleted before the tag list has loaded", () => {
+ expect(chips(undefined, ["77"]).map((c) => c.key)).toEqual([NO_TAG_SENTINEL]);
+ });
+});
+
describe("buildChannelColumns — the row's end", () => {
it('ends every row in the channel\'s "…", with no action buttons before it', () => {
const more = (c: ManagedChannel) => `more:${c.id}`;
diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx
index 32e4bc1e..c8cd4c61 100644
--- a/frontend/src/components/channelColumns.tsx
+++ b/frontend/src/components/channelColumns.tsx
@@ -1,6 +1,6 @@
import type { ComponentProps, ReactNode } from "react";
import type { TFunction } from "i18next";
-import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
+import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../lib/api";
import { formatCountOrDash, formatTotalHours } from "../lib/format";
import type { Column } from "./ui/DataTable";
import { PriorityStepper } from "./ui/PriorityStepper";
@@ -10,6 +10,7 @@ import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import { StoredCell } from "./channelCells";
import { isFullySynced } from "./channelSync";
import { RelativeTime } from "./ui/RelativeTime";
+import type { FilterChip } from "./TagFilterChips";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
@@ -49,6 +50,43 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
return values.some((v) => c.tag_ids.includes(Number(v)));
}
+/** The manager's tag-filter chip row (the Tags column filter's values): any picked id whose tag no
+ * longer exists, then each user tag, then the "No tag" sentinel. A stored pick can outlive its tag
+ * (deleted here or on another device) and still narrows the table to nothing, so it stays a visible,
+ * removable chip — FIRST, so the row's "+N" overflow never folds the reason away (E32 S2). */
+export function tagFilterChips(o: {
+ userTags: Tag[];
+ allTags: Tag[] | undefined;
+ picked: string[];
+ noTagOn: boolean;
+ toggle: (tagId: number) => void;
+ toggleNoTag: () => void;
+ t: TFunction;
+}): FilterChip[] {
+ const known = new Set((o.allTags ?? []).map((tg) => String(tg.id)));
+ const gone = o.allTags ? o.picked.filter((id) => id !== NO_TAG_SENTINEL && !known.has(id)) : [];
+ return [
+ ...gone.map((id) => ({
+ key: `t${id}`,
+ label: o.t("channels.tags.deleted"),
+ active: true,
+ onToggle: () => o.toggle(Number(id)),
+ })),
+ ...o.userTags.map((tg) => ({
+ key: `t${tg.id}`,
+ label: tg.name,
+ active: o.picked.includes(String(tg.id)),
+ onToggle: () => o.toggle(tg.id),
+ })),
+ {
+ key: NO_TAG_SENTINEL,
+ label: o.t("channels.tags.noTag"),
+ active: o.noTagOn,
+ onToggle: o.toggleNoTag,
+ },
+ ];
+}
+
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
* this factory just wires them into `Column` render/filter/sort defs so the ~150-line
* column block lives outside the page component (its cell components are in channelCells). */
diff --git a/frontend/src/components/tagChipNames.dom.test.tsx b/frontend/src/components/tagChipNames.dom.test.tsx
new file mode 100644
index 00000000..75dceb13
--- /dev/null
+++ b/frontend/src/components/tagChipNames.dom.test.tsx
@@ -0,0 +1,52 @@
+// @vitest-environment jsdom
+//
+// A saved view or the stored feed filter can outlive its tag. The feed then matches nothing (E32 S2),
+// so the chip must say the tag is gone, not read as a bare id; while the tag list loads it is a
+// placeholder, and if the list fails it falls back to the id rather than claiming "deleted".
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createElement, type ReactNode } from "react";
+import { api, type FeedFilters, type Tag } from "../lib/api";
+import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
+ };
+});
+
+const filters: FeedFilters = { ...clearedFilters({ q: "" } as FeedFilters), tags: [1, 99] };
+let qc: QueryClient;
+const wrapper = ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client: qc }, children);
+const chips = () => renderHook(() => useActiveFilters(filters, () => {}), { wrapper }).result;
+
+beforeEach(() => {
+ qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe("a tag filter's chip names", () => {
+ it("name a live tag, and say a deleted one is gone", async () => {
+ vi.spyOn(api, "tags").mockResolvedValue([{ id: 1, name: "Music", category: "topic" }] as Tag[]);
+ const result = chips();
+ const chip = (id: number) => result.current.find((f) => f.key === `tag:${id}`)!;
+ expect(chip(99).pending).toBe(true);
+ await waitFor(() => expect(chip(1)).toMatchObject({ label: "Music", pending: false }));
+ expect(chip(99)).toMatchObject({ label: "feed.chips.deletedTag", pending: false });
+ });
+
+ it("fall back to the id when the tag list fails, never claiming the tag was deleted", async () => {
+ vi.spyOn(api, "tags").mockRejectedValue(new Error("down"));
+ const result = chips();
+ const chip = (id: number) => result.current.find((f) => f.key === `tag:${id}`)!;
+ await waitFor(() => expect(chip(99).pending).toBe(false));
+ expect(chip(99).label).toBe("99");
+ });
+});
diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json
index 3d9b956b..13ab3c9c 100644
--- a/frontend/src/i18n/locales/en/channels.json
+++ b/frontend/src/i18n/locales/en/channels.json
@@ -57,6 +57,7 @@
"createFailed": "Couldn't create the tag",
"updateFailed": "Couldn't update the tag",
"noTag": "No tag",
+ "deleted": "Deleted tag",
"moreCount_one": "{{count}} more tag",
"moreCount_other": "{{count}} more tags",
"suggestedHint": "Suggested from your tagging pattern ({{pct}}% match) — click to add",
diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json
index 80f14326..638037b5 100644
--- a/frontend/src/i18n/locales/en/feed.json
+++ b/frontend/src/i18n/locales/en/feed.json
@@ -4,6 +4,7 @@
"remove": "Remove filter: {{name}}",
"contentNone": "No content types",
"untagged": "Untagged",
+ "deletedTag": "Deleted tag",
"count_one": "{{count}} filter",
"count_other": "{{count}} filters",
"moreCount_one": "{{count}} more filter",
diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json
index f73a6dd6..db3f6bf8 100644
--- a/frontend/src/i18n/locales/hu/channels.json
+++ b/frontend/src/i18n/locales/hu/channels.json
@@ -57,6 +57,7 @@
"createFailed": "Nem sikerült létrehozni a címkét",
"updateFailed": "Nem sikerült frissíteni a címkét",
"noTag": "Nincs címke",
+ "deleted": "Törölt címke",
"moreCount_one": "még {{count}} címke",
"moreCount_other": "még {{count}} címke",
"suggestedHint": "A címkézési mintád alapján javasolt ({{pct}}% egyezés) — kattints a hozzáadáshoz",
diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json
index 38a7c63d..7b970023 100644
--- a/frontend/src/i18n/locales/hu/feed.json
+++ b/frontend/src/i18n/locales/hu/feed.json
@@ -4,6 +4,7 @@
"remove": "Szűrő eltávolítása: {{name}}",
"contentNone": "Nincs tartalomtípus",
"untagged": "Címkézetlen",
+ "deletedTag": "Törölt címke",
"count_one": "{{count}} szűrő",
"count_other": "{{count}} szűrő",
"moreCount_one": "még {{count}} szűrő",
diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts
index 44a5520b..94b59cf6 100644
--- a/frontend/src/lib/useActiveFilters.ts
+++ b/frontend/src/lib/useActiveFilters.ts
@@ -1,5 +1,6 @@
+import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
-import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
+import { DEFAULT_TAG_MODES, type FeedFilters, type Tag } from "./api";
import { useChannelNames } from "./useChannelNames";
import { parseSort } from "./feedSort";
import { useTags } from "./useTags";
@@ -35,6 +36,15 @@ export function clearedFilters(current: FeedFilters): FeedFilters {
return { ...DEFAULT_SIDEBAR_FILTERS, q: current.q, scope: "my" };
}
+// A tag chip's name. A saved view or the stored filter can outlive its tag: the feed then matches
+// nothing (E32 S2), and the chip says why instead of reading as a bare id — but only once the tag
+// list has loaded; while it loads (a placeholder) or if it failed, the id, never a guessed "deleted".
+function tagLabel(id: number, tags: { data?: Tag[]; isSuccess: boolean }, t: TFunction): string {
+ const tag = tags.data?.find((x) => x.id === id);
+ if (tag) return tag.name;
+ return tags.isSuccess ? t("feed.chips.deletedTag") : String(id);
+}
+
/**
* The single source of truth for "what is currently narrowing the feed" — it backs BOTH the side
* panel's active-count badge and the removable chip row above the feed, so the two can never
@@ -56,10 +66,10 @@ export function useActiveFilters(
// Tags: one chip each — removing one shouldn't drop the rest.
for (const id of filters.tags) {
- const tag = (tagsQuery.data ?? []).find((x) => x.id === id);
out.push({
key: `tag:${id}`,
- label: tag?.name ?? String(id),
+ label: tagLabel(id, tagsQuery, t),
+ pending: tagsQuery.isPending,
remove: () => setFilters({ ...filters, tags: filters.tags.filter((x) => x !== id) }),
});
}
From 378c4daf0fa19a62550964b5a60d1214cf056366 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 21:53:05 +0200
Subject: [PATCH 22/54] fix(youtube): the scrape search never pins a stale key,
and an unknown shape is an error
A failed config fetch or a keyless consent page cached the fallback InnerTube key for the life
of the process; once it went stale, search returned nothing with no error. The fallback is now
used for that call only. A 200 JSON without a result frame returned an empty page that read as
no results; it is a ScrapeError now. Parser, cursor and Shorts-probe tests added.
---
backend/app/youtube/search_scrape.py | 42 ++++---
backend/tests/test_search_scrape.py | 178 +++++++++++++++++++++++++++
backend/tests/test_shorts_probe.py | 44 +++++++
3 files changed, 250 insertions(+), 14 deletions(-)
create mode 100644 backend/tests/test_search_scrape.py
create mode 100644 backend/tests/test_shorts_probe.py
diff --git a/backend/app/youtube/search_scrape.py b/backend/app/youtube/search_scrape.py
index cfdbc95e..391416c8 100644
--- a/backend/app/youtube/search_scrape.py
+++ b/backend/app/youtube/search_scrape.py
@@ -42,6 +42,9 @@ _HEADERS = {
"Accept-Language": "en-US,en;q=0.9",
}
+# The top-level keys a first page (contents) and a continuation page (onResponseReceivedCommands)
+# put their result list under.
+_RESULT_FRAMES = {"contents", "onResponseReceivedCommands"}
_KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"')
_VER_RE = re.compile(r'"INNERTUBE_CLIENT_VERSION":"([^"]+)"')
@@ -65,29 +68,36 @@ def _runs_text(node: dict | None) -> str | None:
def _get_config(client: httpx.Client) -> tuple[str, str]:
- """The current InnerTube (api_key, client_version), cached after the first fetch."""
+ """The current InnerTube (api_key, client_version), cached after the first fetch that FOUND them.
+
+ The fallback pair is never cached: it used to be, so one failed fetch (or a consent page with no
+ keys on it) at the wrong moment pinned every later search to a hard-coded key and version for
+ the life of the process — and when those went stale, search returned nothing, with no error
+ anywhere (E32 S2). Now the next search simply tries the live pair again."""
global _config
if _config is not None:
return _config
try:
- resp = client.get(_RESULTS_URL, params={"search_query": "music"})
- html = resp.text
- key = _KEY_RE.search(html)
- ver = _VER_RE.search(html)
- _config = (
- key.group(1) if key else _FALLBACK_KEY,
- ver.group(1) if ver else _FALLBACK_CLIENT_VERSION,
- )
+ html = client.get(_RESULTS_URL, params={"search_query": "music"}).text
except httpx.HTTPError as exc:
- # The fallback pair is CACHED in `_config` for the life of the process, so one failed
- # fetch at the wrong moment pins every later search to a hard-coded key and version —
- # and when those go stale, search returns nothing at all, with no error anywhere.
log.warning(
"the InnerTube config could not be fetched; falling back to the pinned key",
extra=logs.event("youtube.scrape.config_fallback", reason=type(exc).__name__),
)
- _config = (_FALLBACK_KEY, _FALLBACK_CLIENT_VERSION)
- return _config
+ return (_FALLBACK_KEY, _FALLBACK_CLIENT_VERSION)
+ key = _KEY_RE.search(html)
+ ver = _VER_RE.search(html)
+ if key and ver:
+ _config = (key.group(1), ver.group(1))
+ return _config
+ log.warning(
+ "the results page carried no InnerTube config; falling back to the pinned key",
+ extra=logs.event("youtube.scrape.config_fallback", reason="not_on_page"),
+ )
+ return (
+ key.group(1) if key else _FALLBACK_KEY,
+ ver.group(1) if ver else _FALLBACK_CLIENT_VERSION,
+ )
def _video_item(vr: dict) -> dict | None:
@@ -182,6 +192,10 @@ def search(q: str, continuation: str | None = None) -> dict:
except ValueError as exc: # non-JSON body (blocked / consent / captcha)
raise ScrapeError("InnerTube returned a non-JSON response") from exc
+ # Even a search with no results carries its result list's frame. Without one this is not "no
+ # results" but a shape we do not know (YouTube moved, or a block page in JSON) — say so.
+ if not isinstance(data, dict) or not _RESULT_FRAMES & data.keys():
+ raise ScrapeError("InnerTube returned an unexpected shape")
items: list[dict] = []
_collect_videos(data, items, set())
return {"items": items, "next_page_token": _find_continuation(data)}
diff --git a/backend/tests/test_search_scrape.py b/backend/tests/test_search_scrape.py
new file mode 100644
index 00000000..b2dc0d79
--- /dev/null
+++ b/backend/tests/test_search_scrape.py
@@ -0,0 +1,178 @@
+"""youtube/search_scrape — the zero-quota InnerTube search (E32 S2: the module was 20% covered).
+
+Everything here breaks silently when YouTube moves: a parser that stops finding videos, a cursor that
+stops paging, a fallback key that outlives its usefulness. The network is an httpx MockTransport.
+"""
+
+import json
+
+import httpx
+import pytest
+
+from app.youtube import search_scrape
+
+RESULTS_HTML = (
+ ''
+)
+
+
+def _video(vid: str, title: str, *, simple: bool = False) -> dict:
+ return {
+ "videoRenderer": {
+ "videoId": vid,
+ "title": {"simpleText": title}
+ if simple
+ else {"runs": [{"text": title[:3]}, {"text": title[3:]}]},
+ "ownerText": {
+ "runs": [
+ {
+ "text": "Chan",
+ "navigationEndpoint": {"browseEndpoint": {"browseId": "UCchan"}},
+ }
+ ]
+ },
+ "thumbnail": {"thumbnails": [{"url": "small.jpg"}, {"url": "big.jpg"}]},
+ }
+ }
+
+
+PAGE = {
+ "contents": {
+ "sectionListRenderer": {
+ "contents": [
+ {
+ "itemSectionRenderer": {
+ "contents": [
+ _video("v1", "First video"),
+ {"channelRenderer": {"channelId": "UCx"}}, # not a video: skipped
+ {
+ "reelShelfRenderer": {
+ "items": [{"reelItemRenderer": {"videoId": "s1"}}]
+ }
+ },
+ _video("v2", "Second", simple=True),
+ _video("v1", "First video"), # a repeat in another shelf: once
+ {"videoRenderer": {"title": {"simpleText": "no id"}}},
+ ]
+ }
+ },
+ {
+ "continuationItemRenderer": {
+ "continuationEndpoint": {"continuationCommand": {"token": "NEXT"}}
+ }
+ },
+ ]
+ }
+ }
+}
+
+
+@pytest.fixture
+def net(monkeypatch):
+ """Route every httpx.Client the module opens through a handler the test sets."""
+ calls: list[httpx.Request] = []
+ state: dict = {"handler": None}
+
+ def transport(request: httpx.Request) -> httpx.Response:
+ calls.append(request)
+ return state["handler"](request)
+
+ real = httpx.Client
+ monkeypatch.setattr(
+ search_scrape.httpx,
+ "Client",
+ lambda **kw: real(transport=httpx.MockTransport(transport), **kw),
+ )
+ monkeypatch.setattr(search_scrape, "_config", None)
+ state["calls"] = calls
+ return state
+
+
+def _serve(results_html=RESULTS_HTML, search=PAGE, status=200):
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path == "/results":
+ if isinstance(results_html, Exception):
+ raise results_html
+ return httpx.Response(200, text=results_html)
+ if isinstance(search, str):
+ return httpx.Response(status, text=search)
+ return httpx.Response(status, json=search)
+
+ return handler
+
+
+def test_a_page_flattens_its_videos_in_order_and_hands_back_the_cursor(net):
+ net["handler"] = _serve()
+ page = search_scrape.search("cats")
+ assert page["next_page_token"] == "NEXT"
+ assert [i["id"] for i in page["items"]] == ["v1", "v2"]
+ assert page["items"][0] == {
+ "id": "v1",
+ "channel_id": "UCchan",
+ "channel_title": "Chan",
+ "title": "First video",
+ "published_at": None,
+ "thumbnail_url": "big.jpg",
+ "live_broadcast": "none",
+ }
+ assert page["items"][1]["title"] == "Second"
+ post = net["calls"][-1]
+ assert post.url.params["key"] == "live-key"
+ body = json.loads(post.content)
+ assert body["query"] == "cats" and body["params"] == search_scrape._TYPE_VIDEO
+ assert body["context"]["client"]["clientVersion"] == "2.20261001"
+
+
+def test_the_next_page_sends_the_cursor_not_the_query_and_the_end_has_none(net):
+ net["handler"] = _serve(search={"onResponseReceivedCommands": [_video("v9", "Last")]})
+ page = search_scrape.search("cats", continuation="NEXT")
+ body = json.loads(net["calls"][-1].content)
+ assert body["continuation"] == "NEXT" and "query" not in body
+ assert [i["id"] for i in page["items"]] == ["v9"]
+ assert page["next_page_token"] is None
+
+
+@pytest.mark.parametrize(
+ ("search", "status"),
+ [
+ ("consent", 200),
+ ({"error": "blocked"}, 429),
+ ({"responseContext": {}, "alerts": ["unusual traffic"]}, 200),
+ ([], 200),
+ ],
+ ids=["not-json", "http-error", "unknown-shape", "not-an-object"],
+)
+def test_a_blocked_or_garbled_answer_is_an_error_not_an_empty_page(net, search, status):
+ net["handler"] = _serve(search=search, status=status)
+ with pytest.raises(search_scrape.ScrapeError):
+ search_scrape.search("cats")
+
+
+def test_a_search_with_no_results_is_an_empty_page_not_an_error(net):
+ net["handler"] = _serve(search={"contents": {"sectionListRenderer": {"contents": []}}})
+ assert search_scrape.search("zzzzqqq") == {"items": [], "next_page_token": None}
+
+
+def test_the_live_key_is_fetched_once_and_cached(net):
+ net["handler"] = _serve()
+ search_scrape.search("a")
+ search_scrape.search("b")
+ assert [c.url.path for c in net["calls"]].count("/results") == 1
+
+
+@pytest.mark.parametrize(
+ "results_html",
+ [httpx.ConnectError("down"), "a consent page, no keys"],
+ ids=["fetch-failed", "no-key-on-page"],
+)
+def test_a_failed_key_fetch_falls_back_for_now_and_tries_again_next_time(net, results_html):
+ """The fallback pair used to be CACHED for the life of the process: one failed fetch pinned
+ every later search to a hard-coded key, and once that went stale search returned nothing,
+ with no error anywhere (E32 S2)."""
+ net["handler"] = _serve(results_html=results_html)
+ search_scrape.search("a")
+ assert net["calls"][-1].url.params["key"] == search_scrape._FALLBACK_KEY
+
+ net["handler"] = _serve()
+ search_scrape.search("b")
+ assert net["calls"][-1].url.params["key"] == "live-key"
diff --git a/backend/tests/test_shorts_probe.py b/backend/tests/test_shorts_probe.py
new file mode 100644
index 00000000..a05cc7b1
--- /dev/null
+++ b/backend/tests/test_shorts_probe.py
@@ -0,0 +1,44 @@
+"""youtube/shorts.probe_is_short — three answers, and the third is not the second (E32 S2: 33% covered).
+
+A Short answers 200 at /shorts/; a regular video redirects to /watch. Anything else — an error
+page, a network failure — is "undetermined, ask again", never "not a Short": that would file a Short
+in the normal feed for good.
+"""
+
+import httpx
+import pytest
+
+from app.youtube import shorts
+
+
+def _client(respond) -> httpx.Client:
+ return httpx.Client(transport=httpx.MockTransport(respond))
+
+
+@pytest.mark.parametrize(
+ ("status", "expected"),
+ [(200, True), (301, False), (302, False), (303, False), (307, False), (308, False)]
+ + [(404, None), (429, None), (500, None)],
+)
+def test_the_status_decides_short_regular_or_undetermined(status, expected):
+ seen: list[httpx.Request] = []
+
+ def respond(request: httpx.Request) -> httpx.Response:
+ seen.append(request)
+ return httpx.Response(status, headers={"location": "https://www.youtube.com/watch?v=x"})
+
+ assert shorts.probe_is_short(_client(respond), "abc123") is expected
+ assert str(seen[0].url) == "https://www.youtube.com/shorts/abc123"
+ assert len(seen) == 1 # a redirect is the answer, never followed
+
+
+def test_a_network_failure_is_undetermined_not_regular():
+ def respond(request: httpx.Request) -> httpx.Response:
+ raise httpx.ConnectTimeout("slow", request=request)
+
+ assert shorts.probe_is_short(_client(respond), "abc123") is None
+
+
+def test_the_client_skips_the_consent_interstitial():
+ with shorts.make_client() as c:
+ assert "SOCS=" in c.headers["cookie"]
From 2ab9ff6a42d51bfce1d00429bdfa00cfff409b2d Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 21:53:06 +0200
Subject: [PATCH 23/54] fix(plex): a server without a machine id refuses the
collection write by name
The missing identifier became server:/// in the write URI. Client tests over a MockTransport
(errors, paging, cutoff, write URIs, image transcode) and admin-route tests (connection test,
enabled-section sampling, no server URL in a 422, the one-time watch import).
---
backend/app/plex/client.py | 9 +-
backend/tests/test_plex_admin_routes.py | 154 +++++++++++++++++++
backend/tests/test_plex_client.py | 196 ++++++++++++++++++++++++
3 files changed, 357 insertions(+), 2 deletions(-)
create mode 100644 backend/tests/test_plex_admin_routes.py
create mode 100644 backend/tests/test_plex_client.py
diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py
index 8701b6ce..50365f94 100644
--- a/backend/app/plex/client.py
+++ b/backend/app/plex/client.py
@@ -136,9 +136,14 @@ class PlexClient:
# --- Collection editing (P2 — write-back to Plex; all shared/global, admin-gated in the routes) ---
@property
def machine_id(self) -> str:
- """Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached."""
+ """Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached.
+ Without one the uri would read `server:///…`, a malformed write Plex answers in its own way;
+ it is refused here, by name (E32 S2)."""
if not getattr(self, "_machine_id", None):
- self._machine_id = self._get("/identity").get("machineIdentifier") or ""
+ mid = self._get("/identity").get("machineIdentifier")
+ if not mid:
+ raise PlexError("Plex did not report its machine identifier")
+ self._machine_id = mid
return self._machine_id
def _write(self, method: str, path: str) -> dict:
diff --git a/backend/tests/test_plex_admin_routes.py b/backend/tests/test_plex_admin_routes.py
new file mode 100644
index 00000000..6de5f0aa
--- /dev/null
+++ b/backend/tests/test_plex_admin_routes.py
@@ -0,0 +1,154 @@
+"""routes/plex/admin — the connection test and the owner's watch-sync link (E32 S2: 37% covered).
+
+The connection test is the admin's one early warning that the media mount or path map is wrong, and
+it must not quote the server's URL back in a toast. The watch link runs the one-time "Plex is master"
+import exactly once. Plex itself is a stub.
+"""
+
+from sqlalchemy import select
+
+import app.routes.plex.admin as admin_routes
+from app import sysconfig
+from app.models import PlexLink
+from app.plex.client import PlexError, PlexNotConfigured
+
+
+class _Plex:
+ def __init__(self, *, sections=None, leaf=None, fail=None):
+ self._sections = sections or []
+ self._leaf = leaf
+ self._fail = fail
+ self.section_items_calls: list[str] = []
+
+ def __enter__(self):
+ if self._fail:
+ raise self._fail
+ return self
+
+ def __exit__(self, *exc):
+ return False
+
+ def server_info(self):
+ return {"friendlyName": "Basement", "version": "1.40"}
+
+ def sections(self):
+ return self._sections
+
+ def section_items(self, key, *_args):
+ self.section_items_calls.append(key)
+ return ([self._leaf] if self._leaf else []), 1
+
+
+SECTIONS = [
+ {"key": 1, "title": "Movies", "type": "movie", "count": 10},
+ {"key": 2, "title": "Shows", "type": "show", "count": 3},
+ {"key": 3, "title": "Music", "type": "artist", "count": 99},
+]
+LEAF = {"Media": [{"Part": [{"file": "/data/movies/a.mkv"}]}]}
+
+
+def _use(monkeypatch, plex):
+ monkeypatch.setattr(admin_routes, "PlexClient", lambda db: plex)
+
+
+def test_the_connection_test_lists_movie_and_show_sections_and_probes_one_file(
+ db, as_user, make_user, monkeypatch, tmp_path
+):
+ (tmp_path / "a.mkv").write_bytes(b"x")
+ sysconfig.set_value(db, "plex_path_map", f"/data/movies={tmp_path}")
+ sysconfig.set_value(db, "plex_libraries", "4")
+ db.commit()
+ # Only section 4 is enabled; the movie section listed first (1) must not be the one sampled.
+ other = {"key": 4, "title": "Kids", "type": "movie", "count": 2}
+ plex = _Plex(sections=[*SECTIONS, other], leaf=LEAF)
+ _use(monkeypatch, plex)
+ r = as_user(make_user(role="admin")).post("/api/plex/test")
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["server_name"] == "Basement"
+ assert [s["key"] for s in body["sections"]] == ["1", "2", "4"] # music is not ours
+ assert body["media_check"] == {
+ "checked": True,
+ "ok": True,
+ "plex_path": "/data/movies/a.mkv",
+ "local_path": str(tmp_path / "a.mkv"),
+ }
+ assert plex.section_items_calls == ["4"] # only an enabled movie section is sampled
+
+
+def test_a_wrong_mount_is_reported_and_an_empty_library_is_not_checked(
+ db, as_user, make_user, monkeypatch, tmp_path
+):
+ sysconfig.set_value(db, "plex_path_map", f"/data/movies={tmp_path}")
+ db.commit()
+ client = as_user(make_user(role="admin"))
+ _use(monkeypatch, _Plex(sections=SECTIONS, leaf=LEAF)) # the file is not on the mount
+ check = client.post("/api/plex/test").json()["media_check"]
+ assert check["checked"] is True and check["ok"] is False
+
+ _use(monkeypatch, _Plex(sections=SECTIONS))
+ assert client.post("/api/plex/test").json()["media_check"] == {"checked": False}
+
+
+def test_a_failed_connection_says_so_without_quoting_the_server(
+ db, as_user, make_user, monkeypatch
+):
+ client = as_user(make_user(role="admin"))
+ _use(monkeypatch, _Plex(fail=PlexNotConfigured()))
+ r = client.post("/api/plex/test")
+ assert r.status_code == 400
+ assert r.json()["detail"]["reason"] == "plex_not_configured"
+
+ _use(monkeypatch, _Plex(fail=PlexError("GET http://10.0.0.5:32400/secret refused")))
+ r = client.post("/api/plex/test")
+ assert r.status_code == 422
+ assert r.json()["detail"]["reason"] == "plex_unreachable"
+ assert "10.0.0.5" not in r.text
+
+
+def test_plex_admin_routes_refuse_a_regular_user(db, as_user, make_user, monkeypatch):
+ _use(monkeypatch, _Plex(sections=SECTIONS))
+ client = as_user(make_user())
+ for path in ("/api/plex/test", "/api/plex/sync", "/api/plex/watch/import"):
+ assert client.post(path).status_code == 403, path
+ assert client.post("/api/plex/watch/link", json={"enabled": True}).status_code == 403
+
+
+def test_the_watch_link_imports_once_and_needs_plex_on(db, as_user, make_user, monkeypatch):
+ imports: list[int] = []
+
+ def fake_import(db_, link):
+ imports.append(link.user_id)
+ link.initial_import_done = True
+ db_.commit()
+ return {"imported": 7}
+
+ monkeypatch.setattr(admin_routes.plex_watch, "import_owner_watch_state", fake_import)
+ owner = make_user(role="admin")
+ client = as_user(owner)
+
+ r = client.post("/api/plex/watch/link", json={"enabled": True})
+ assert r.status_code == 409 and r.json()["detail"]["reason"] == "plex_disabled"
+ assert client.post("/api/plex/watch/import").json()["detail"]["reason"] == "watch_sync_off"
+
+ sysconfig.set_value(db, "plex_enabled", True)
+ db.commit()
+ first = client.post("/api/plex/watch/link", json={"enabled": True})
+ assert first.status_code == 200
+ assert first.json()["import"] == {"imported": 7}
+ assert first.json()["linked"] and first.json()["sync_enabled"] and first.json()["uses_admin"]
+
+ assert client.post("/api/plex/watch/link", json={"enabled": False}).json()["import"] is None
+ again = client.post("/api/plex/watch/link", json={"enabled": True})
+ assert again.json()["import"] is None # the one-time import does not run twice
+ assert imports == [owner.id]
+
+ status = client.get("/api/plex/watch/link")
+ assert status.status_code == 200
+ assert status.json()["initial_import_done"] is True
+ links = db.execute(select(PlexLink).where(PlexLink.user_id == owner.id)).scalars().all()
+ assert len(links) == 1 # toggling never creates a second link
+
+ rerun = client.post("/api/plex/watch/import")
+ assert rerun.status_code == 200 and rerun.json()["import"] == {"imported": 7}
+ assert imports == [owner.id, owner.id]
diff --git a/backend/tests/test_plex_client.py b/backend/tests/test_plex_client.py
new file mode 100644
index 00000000..94262c1a
--- /dev/null
+++ b/backend/tests/test_plex_client.py
@@ -0,0 +1,196 @@
+"""plex/client — the one door to the Plex server (E32 S2: 31% covered).
+
+What matters here is what the callers cannot see: a failure must surface as PlexError (never as an
+empty answer), the paged reads must stop where they should, and the write URIs must be well formed.
+The network is an httpx MockTransport; the configuration is stubbed.
+"""
+
+import httpx
+import pytest
+
+from app.plex import client as plex_client
+from app.plex.client import PlexClient, PlexError, PlexNotConfigured
+
+
+@pytest.fixture
+def plex(monkeypatch):
+ """A PlexClient on a stub server; set `routes[path] = handler-or-json` per test."""
+ routes: dict = {}
+ seen: list[httpx.Request] = []
+
+ def transport(request: httpx.Request) -> httpx.Response:
+ seen.append(request)
+ answer = routes.get(request.url.path)
+ if answer is None:
+ return httpx.Response(404)
+ if callable(answer):
+ return answer(request)
+ return httpx.Response(200, json={"MediaContainer": answer})
+
+ real = httpx.Client
+ monkeypatch.setattr(
+ plex_client.httpx,
+ "Client",
+ lambda **kw: real(transport=httpx.MockTransport(transport), **kw),
+ )
+ conf = {"plex_server_url": "http://plex.test:32400/", "plex_server_token": "tok"}
+ monkeypatch.setattr(plex_client.sysconfig, "get_str", lambda db, key: conf[key])
+ c = PlexClient(None) # type: ignore[arg-type] # the stubbed config never reads the session
+ c.routes, c.seen = routes, seen # type: ignore[attr-defined]
+ yield c
+ c.close()
+
+
+def test_an_unconfigured_server_is_refused_up_front(monkeypatch):
+ monkeypatch.setattr(plex_client.sysconfig, "get_str", lambda db, key: "")
+ with pytest.raises(PlexNotConfigured):
+ PlexClient(None) # type: ignore[arg-type]
+
+
+def test_reads_carry_the_token_and_unwrap_the_container(plex):
+ plex.routes["/library/sections"] = {"Directory": [{"key": "1", "type": "movie"}]}
+ assert plex.sections() == [{"key": "1", "type": "movie"}]
+ req = plex.seen[-1]
+ assert str(req.url) == "http://plex.test:32400/library/sections"
+ assert req.headers["x-plex-token"] == "tok"
+ assert req.headers["accept"] == "application/json"
+
+
+@pytest.mark.parametrize(
+ "answer",
+ [
+ lambda r: httpx.Response(500),
+ lambda r: httpx.Response(200, text="login"),
+ lambda r: (_ for _ in ()).throw(httpx.ConnectError("down", request=r)),
+ ],
+ ids=["http-500", "not-json", "unreachable"],
+)
+def test_a_failed_read_is_a_plex_error_never_an_empty_answer(plex, answer):
+ plex.routes["/library/sections"] = answer
+ with pytest.raises(PlexError):
+ plex.sections()
+
+
+def test_a_section_page_asks_for_guids_and_reports_the_server_total(plex):
+ plex.routes["/library/sections/7/all"] = {"Metadata": [{"ratingKey": "1"}], "totalSize": 250}
+ items, total = plex.section_items("7", 1, 200, 50)
+ assert items == [{"ratingKey": "1"}] and total == 250
+ params = plex.seen[-1].url.params
+ assert params["includeGuids"] == "1" and params["type"] == "1"
+ assert params["X-Plex-Container-Start"] == "200" and params["X-Plex-Container-Size"] == "50"
+
+ plex.routes["/library/sections/7/all"] = {"Metadata": [], "size": 0}
+ assert plex.section_items("7", 2) == ([], 0)
+
+
+def test_related_flattens_the_hubs_and_degrades_to_empty(plex):
+ plex.routes["/library/metadata/5/related"] = {
+ "Hub": [{"Metadata": [{"ratingKey": "a"}]}, {"Metadata": [{"ratingKey": "b"}]}, {}]
+ }
+ assert [m["ratingKey"] for m in plex.related("5")] == ["a", "b"]
+ plex.routes["/library/metadata/5/related"] = lambda r: httpx.Response(500)
+ assert plex.related("5") == []
+
+
+def test_metadata_is_the_first_item_or_none(plex):
+ plex.routes["/library/metadata/9"] = {"Metadata": [{"ratingKey": "9", "title": "X"}]}
+ assert plex.metadata("9", markers=True) == {"ratingKey": "9", "title": "X"}
+ assert plex.seen[-1].url.params["includeMarkers"] == "1"
+ plex.routes["/library/metadata/9"] = {}
+ assert plex.metadata("9") is None
+
+
+def _history(rows_by_start: dict[int, list[dict]]):
+ def answer(request: httpx.Request) -> httpx.Response:
+ start = int(request.url.params["X-Plex-Container-Start"])
+ return httpx.Response(
+ 200, json={"MediaContainer": {"Metadata": rows_by_start.get(start, [])}}
+ )
+
+ return answer
+
+
+def test_watch_history_pages_until_the_cutoff_and_keeps_only_the_owner(plex):
+ plex.routes["/status/sessions/history/all"] = _history(
+ {
+ 0: [
+ {"ratingKey": "1", "viewedAt": 500, "accountID": 2},
+ {"ratingKey": "2", "viewedAt": 400, "accountID": 1},
+ ],
+ 2: [
+ {"ratingKey": "3", "viewedAt": 300, "accountID": 1},
+ {"ratingKey": "4", "viewedAt": 100, "accountID": 1},
+ ],
+ 4: [{"ratingKey": "never", "viewedAt": 50, "accountID": 1}],
+ }
+ )
+ rows = plex.watch_history(min_viewed_at=200, account_id=1, size=2)
+ assert [r["ratingKey"] for r in rows] == ["2", "3"] # page 2 reached, the cutoff stops it
+ assert [int(r.url.params["X-Plex-Container-Start"]) for r in plex.seen] == [0, 2]
+
+
+def test_watch_history_stops_on_a_short_page_and_at_the_page_cap(plex):
+ plex.routes["/status/sessions/history/all"] = _history({0: [{"ratingKey": "1", "viewedAt": 9}]})
+ assert [r["ratingKey"] for r in plex.watch_history(size=2)] == ["1"]
+ assert len(plex.seen) == 1
+
+ full = [{"ratingKey": "r", "viewedAt": 9}, {"ratingKey": "r", "viewedAt": 9}]
+ plex.routes["/status/sessions/history/all"] = lambda r: httpx.Response(
+ 200, json={"MediaContainer": {"Metadata": full}}
+ )
+ plex.seen.clear()
+ assert len(plex.watch_history(size=2, max_pages=3)) == 6
+ assert len(plex.seen) == 3
+
+
+def test_a_collection_is_created_with_a_well_formed_uri(plex):
+ plex.routes["/identity"] = {"machineIdentifier": "abc"}
+ plex.routes["/library/collections"] = {"Metadata": [{"ratingKey": "c1"}]}
+ assert plex.create_collection("3", "Best of: 90s", "42") == {"ratingKey": "c1"}
+ params = plex.seen[-1].url.params
+ assert plex.seen[-1].method == "POST"
+ assert params["title"] == "Best of: 90s" and params["sectionId"] == "3"
+ assert params["uri"] == "server://abc/com.plexapp.plugins.library/library/metadata/42"
+
+ plex.routes["/library/collections"] = {}
+ with pytest.raises(PlexError):
+ plex.create_collection("3", "Empty", "42")
+ assert [r.url.path for r in plex.seen].count("/identity") == 1 # cached
+
+
+def test_a_server_without_a_machine_id_refuses_the_write(plex):
+ """No machineIdentifier used to become `server:///…` in the write URI — a malformed request
+ Plex answers in its own way. It is refused here, named (E32 S2)."""
+ plex.routes["/identity"] = {}
+ plex.routes["/library/collections"] = {"Metadata": [{"ratingKey": "c1"}]}
+ with pytest.raises(PlexError, match="machine"):
+ plex.create_collection("3", "T", "42")
+ assert all(r.url.path == "/identity" for r in plex.seen)
+
+
+def test_watch_state_writes_target_the_library_item(plex):
+ for path in ("/:/scrobble", "/:/unscrobble", "/:/timeline"):
+ plex.routes[path] = lambda r: httpx.Response(200) # Plex answers with an empty body
+ plex.scrobble("42")
+ assert plex.seen[-1].url.params["key"] == "42"
+ plex.unscrobble("42")
+ assert plex.seen[-1].url.path == "/:/unscrobble"
+ plex.set_timeline("42", 61_500.7, 120_000) # type: ignore[arg-type]
+ p = plex.seen[-1].url.params
+ assert p["key"] == "/library/metadata/42" and p["time"] == "61500" and p["state"] == "stopped"
+ plex.routes["/:/scrobble"] = lambda r: httpx.Response(401)
+ with pytest.raises(PlexError):
+ plex.scrobble("42")
+
+
+def test_an_image_is_resized_by_plex_when_a_box_is_given(plex):
+ plex.routes["/photo/:/transcode"] = lambda r: httpx.Response(
+ 200, content=b"small", headers={"Content-Type": "image/webp"}
+ )
+ plex.routes["/library/metadata/1/thumb/2"] = lambda r: httpx.Response(200, content=b"full")
+ assert plex.image_bytes("/library/metadata/1/thumb/2", 300, 450) == (b"small", "image/webp")
+ assert plex.seen[-1].url.params["url"] == "/library/metadata/1/thumb/2"
+ body, ctype = plex.image_bytes("/library/metadata/1/thumb/2")
+ assert body == b"full" and ctype == "image/jpeg"
+ with pytest.raises(PlexError):
+ plex.raw_get("/library/streams/404")
From 2e2d6f574628411dbe6b9ea3cd17bb347b10a4b0 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 21:53:06 +0200
Subject: [PATCH 24/54] fix(downloads): the quota editor opens at once and says
it is loading or failed
It rendered nothing until its quota arrived and nothing at all when the load failed, so the
click looked dead. The first field still takes focus when the form arrives.
---
.../src/components/downloadAdmin.dom.test.tsx | 75 +++++++++++
frontend/src/components/downloadAdmin.tsx | 124 ++++++++++--------
2 files changed, 145 insertions(+), 54 deletions(-)
create mode 100644 frontend/src/components/downloadAdmin.dom.test.tsx
diff --git a/frontend/src/components/downloadAdmin.dom.test.tsx b/frontend/src/components/downloadAdmin.dom.test.tsx
new file mode 100644
index 00000000..1fcd3a56
--- /dev/null
+++ b/frontend/src/components/downloadAdmin.dom.test.tsx
@@ -0,0 +1,75 @@
+// @vitest-environment jsdom
+//
+// E32 S2: the admin's download-quota editor rendered NOTHING while its quota loaded and nothing when
+// the load failed, so the click looked dead. It now opens at once and says which it is. The save
+// turns the GB field back into bytes; the reset drops the override.
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { api } from "../lib/api";
+import { QuotaModal } from "./downloadAdmin";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
+ };
+});
+
+const GiB = 1024 ** 3;
+const QUOTA = { max_bytes: 5 * GiB, max_jobs: 10, max_concurrent: 2, unlimited: false };
+let qc: QueryClient;
+const wrap = (ui: ReactNode) => {ui} ;
+const open = (onClose = () => {}) =>
+ render(wrap( ));
+
+beforeEach(() => {
+ qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe("the download-quota editor", () => {
+ it("opens at once and says it is loading", () => {
+ vi.spyOn(api, "adminGetDownloadQuota").mockReturnValue(new Promise(() => {}) as never);
+ open();
+ expect(screen.getByText("downloads.admin.quotaTitle")).toBeTruthy();
+ expect(screen.getAllByRole("status").length).toBeGreaterThan(0);
+ });
+
+ it("says a failed load failed, and retries it", async () => {
+ const get = vi
+ .spyOn(api, "adminGetDownloadQuota")
+ .mockRejectedValueOnce(new Error("down"))
+ .mockResolvedValueOnce(QUOTA as never);
+ open();
+ expect((await screen.findByRole("alert")).textContent).toContain("common.loadError");
+ fireEvent.click(screen.getByText("common.retry"));
+ await screen.findByDisplayValue("5");
+ expect(get).toHaveBeenCalledTimes(2);
+ });
+
+ it("saves the GB field as bytes, and the reset drops the override", async () => {
+ vi.spyOn(api, "adminGetDownloadQuota").mockResolvedValue(QUOTA as never);
+ const set = vi.spyOn(api, "adminSetDownloadQuota").mockResolvedValue(QUOTA as never);
+ const reset = vi.spyOn(api, "adminResetDownloadQuota").mockResolvedValue(QUOTA as never);
+ const onClose = vi.fn();
+ open(onClose);
+ fireEvent.change(await screen.findByDisplayValue("5"), { target: { value: "1.5" } });
+ fireEvent.click(screen.getByText("downloads.admin.save"));
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
+ expect(set).toHaveBeenCalledWith(4, {
+ max_bytes: Math.round(1.5 * GiB),
+ max_jobs: 10,
+ max_concurrent: 2,
+ unlimited: false,
+ });
+
+ fireEvent.click(screen.getByText("downloads.admin.reset"));
+ await waitFor(() => expect(reset).toHaveBeenCalledWith(4));
+ });
+});
diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx
index 4ed7ecc9..30af39a8 100644
--- a/frontend/src/components/downloadAdmin.tsx
+++ b/frontend/src/components/downloadAdmin.tsx
@@ -13,6 +13,7 @@ import { jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { CheckRow } from "./ui/NativeControls";
+import { LoadingState, StateMessage } from "./ui/QueryState";
import { SURFACE } from "./ui/surfaces";
import UserPicker from "./UserPicker";
@@ -21,7 +22,8 @@ import UserPicker from "./UserPicker";
const GiB = 1024 ** 3;
-function QuotaModal({
+/** @internal exported for downloadAdmin.dom.test.tsx */
+export function QuotaModal({
userId,
email,
onClose,
@@ -74,66 +76,80 @@ function QuotaModal({
onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
});
- if (!state) return null;
- const upd = (p: Partial) => setForm({ ...state, ...p });
+ const upd = (p: Partial>) => {
+ if (state) setForm({ ...state, ...p });
+ };
return (
-
+ )}
);
}
From fb6b56f1216d640617c2d1883876aa321823b5d6 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 21:53:06 +0200
Subject: [PATCH 25/54] test(api): every filter key the client sends is one the
backend reads
FastAPI ignores an unknown query parameter, so a misspelt or renamed key silently stops a
filter while its chip still shows. Contract tests read routes/feed.py _feed_params and
plex/query.py LibraryFilters. The superseded feedCount/facets calls and keys are removed.
---
frontend/src/lib/api/feed.test.ts | 162 +++++++++++++++++++
frontend/src/lib/api/feed.ts | 8 -
frontend/src/lib/api/plexFilters.dom.test.ts | 48 ++++++
frontend/src/lib/queryKeys.test.ts | 4 -
frontend/src/lib/queryKeys.ts | 6 +-
5 files changed, 211 insertions(+), 17 deletions(-)
create mode 100644 frontend/src/lib/api/feed.test.ts
create mode 100644 frontend/src/lib/api/plexFilters.dom.test.ts
diff --git a/frontend/src/lib/api/feed.test.ts b/frontend/src/lib/api/feed.test.ts
new file mode 100644
index 00000000..fed12e06
--- /dev/null
+++ b/frontend/src/lib/api/feed.test.ts
@@ -0,0 +1,162 @@
+// E32 S2: lib/api/feed was barely covered, and it is where a filter turns into a query string.
+// FastAPI IGNORES a query parameter it does not know, so a misspelt or renamed key does not fail —
+// the filter simply stops applying while its chip still shows. Locked two ways: what each filter
+// emits, and that every key emitted is one the backend's feed endpoints actually read.
+import { readFileSync } from "node:fs";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { beacon, req } from "./core";
+import { feedApi, type FeedFilters } from "./feed";
+
+vi.mock("./core", () => ({ req: vi.fn(() => Promise.resolve({})), beacon: vi.fn() }));
+
+const BASE: FeedFilters = {
+ tags: [],
+ tagModes: { language: "or", topic: "or", other: "or" },
+ q: "",
+ sort: "newest",
+ scope: "my",
+ includeNormal: true,
+ includeShorts: false,
+ includeLive: false,
+ show: "unwatched",
+ channelIds: [],
+};
+
+const lastUrl = () => new URL(vi.mocked(req).mock.lastCall![0] as string, "http://x");
+const sent = (f: Partial, cursor: string | null = null) => {
+ void feedApi.feed({ ...BASE, ...f }, cursor, 30);
+ return lastUrl().searchParams;
+};
+
+beforeEach(() => vi.mocked(req).mockClear());
+
+describe("feed filters on the wire", () => {
+ it("the defaults: scope, content types, show, library source, sort and page size", () => {
+ const p = sent({});
+ expect(Object.fromEntries(p)).toEqual({
+ scope: "my",
+ show_normal: "true",
+ include_shorts: "false",
+ include_live: "false",
+ show: "unwatched",
+ library_source: "organic",
+ sort: "newest",
+ limit: "30",
+ });
+ });
+
+ it("repeats tags and channels, and sends only the categories matched as ALL", () => {
+ const p = sent({
+ tags: [3, 9],
+ tagModes: { language: "or", topic: "and", other: "or" },
+ channelIds: ["UCa", "UCb"],
+ untagged: true,
+ q: "jazz",
+ });
+ expect(p.getAll("tags")).toEqual(["3", "9"]);
+ expect(p.getAll("channel_ids")).toEqual(["UCa", "UCb"]);
+ expect(p.get("tag_mode_topic")).toBe("and");
+ expect(p.has("tag_mode_language") || p.has("tag_mode_other")).toBe(false);
+ expect(p.get("untagged")).toBe("true");
+ expect(p.get("q")).toBe("jazz");
+ });
+
+ it("a year is a whole-year range and wins over the other date narrowings", () => {
+ const p = sent({ year: 2019, maxAgeDays: 7, dateFrom: "2024-01-01", dateTo: "2024-02-01" });
+ expect(p.get("published_after")).toBe("2019-01-01");
+ expect(p.get("published_before")).toBe("2019-12-31");
+ expect(p.has("max_age_days")).toBe(false);
+
+ const q = sent({ maxAgeDays: 7, dateFrom: "2024-01-01", dateTo: "2024-02-01" });
+ expect([q.get("max_age_days"), q.get("published_after"), q.get("published_before")]).toEqual([
+ "7",
+ "2024-01-01",
+ "2024-02-01",
+ ]);
+ });
+
+ it("a zero-second duration bound is still a bound", () => {
+ const p = sent({ minDuration: 0, maxDuration: 60 });
+ expect([p.get("min_duration"), p.get("max_duration")]).toEqual(["0", "60"]);
+ });
+
+ it("the shuffle seed travels only with the shuffle sort; the cursor when paging", () => {
+ expect(sent({ sort: "shuffle", seed: 42 }).get("seed")).toBe("42");
+ expect(sent({ sort: "newest", seed: 42 }).has("seed")).toBe(false);
+ expect(sent({}, "CUR").get("cursor")).toBe("CUR");
+ });
+
+ it("the summary and the year list see the same filters, without the paging", () => {
+ void feedApi.feedSummary({ ...BASE, tags: [5] });
+ expect(lastUrl().pathname).toBe("/api/feed/summary");
+ expect(lastUrl().searchParams.getAll("tags")).toEqual(["5"]);
+ expect(lastUrl().searchParams.has("limit")).toBe(false);
+ void feedApi.feedYears({ ...BASE, year: 2020 });
+ expect(lastUrl().pathname).toBe("/api/feed/years");
+ });
+
+ it("every key it sends is a parameter the backend's feed endpoints read", () => {
+ const routes = readFileSync(
+ new URL("../../../../backend/app/routes/feed.py", import.meta.url),
+ "utf-8",
+ );
+ const block = routes.match(/def _feed_params\(([\s\S]*?)\) -> dict:/);
+ expect(block, "routes/feed.py no longer defines _feed_params").toBeTruthy();
+ const known = new Set(
+ [...(block?.[1] ?? "").matchAll(/^\s{4}(\w+):/gm)].flatMap((m) => (m[1] ? [m[1]] : [])),
+ );
+ known.add("sort").add("seed").add("cursor").add("limit"); // read by /feed itself
+ const everything = sent(
+ {
+ tags: [1],
+ tagModes: { language: "and", topic: "and", other: "and" },
+ channelIds: ["UC"],
+ untagged: true,
+ q: "x",
+ maxAgeDays: 3,
+ dateFrom: "2024-01-01",
+ dateTo: "2024-01-02",
+ minDuration: 1,
+ maxDuration: 2,
+ sort: "shuffle",
+ seed: 1,
+ },
+ "C",
+ );
+ const unknown = [...new Set(everything.keys())].filter((k) => !known.has(k));
+ expect(unknown).toEqual([]);
+ expect(known.has("tag_mode_topic")).toBe(true); // the parse found the real list
+ });
+});
+
+describe("watch state and progress", () => {
+ it("progress is saved in whole seconds, and the unload beacon escapes the id", () => {
+ void feedApi.saveProgress("abc", 61.9, 600.4);
+ const [url, init, opts] = vi.mocked(req).mock.lastCall!;
+ expect(url).toBe("/api/videos/abc/progress");
+ expect(JSON.parse((init as RequestInit).body as string)).toEqual({
+ position_seconds: 61,
+ duration_seconds: 600,
+ });
+ expect(opts).toEqual({ idempotent: true });
+
+ feedApi.saveProgressBeacon("a/b", 10.5, 20.5);
+ expect(vi.mocked(beacon)).toHaveBeenLastCalledWith("/api/videos/a%2Fb/progress", {
+ position_seconds: 10,
+ duration_seconds: 20,
+ });
+ });
+
+ it("the live search is quiet, so its panel can show a quota error inline", () => {
+ void feedApi.searchYoutube("cats & dogs", "NEXT", 10);
+ const [url, , opts] = vi.mocked(req).mock.lastCall!;
+ const u = new URL(url as string, "http://x");
+ expect(u.pathname).toBe("/api/search/youtube");
+ expect(Object.fromEntries(u.searchParams)).toEqual({
+ q: "cats & dogs",
+ cursor: "NEXT",
+ limit: "10",
+ });
+ expect(opts).toEqual({ quiet: true });
+ });
+});
diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts
index dcdf133d..4f0a24b5 100644
--- a/frontend/src/lib/api/feed.ts
+++ b/frontend/src/lib/api/feed.ts
@@ -151,10 +151,6 @@ function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string
export const feedApi = {
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise =>
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
- // SUPERSEDED by feedSummary (PERF-13) — no component calls this anymore; the backend /feed/count
- // endpoint it targets is kept only as the /feed/summary equivalence-test oracle. Retire together.
- feedCount: (f: FeedFilters): Promise<{ count: number }> =>
- req(`/api/feed/count?${filterParams(f).toString()}`),
// Live YouTube search: results are materialised into the catalog and returned as feed cards.
// `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
// popping the global modal. `cursor` is the next-page token (an InnerTube continuation token
@@ -169,10 +165,6 @@ export const feedApi = {
// the now-orphaned, un-kept videos). Returns how many videos were removed.
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }),
- // SUPERSEDED by feedSummary (PERF-13) — no component calls this anymore; the backend /facets
- // endpoint it targets is kept only as the /feed/summary equivalence-test oracle. Retire together.
- facets: (f: FeedFilters): Promise<{ counts: Record }> =>
- req(`/api/facets?${filterParams(f).toString()}`),
// The sidebar's total count + per-tag channel facets in ONE request + ONE grouped query, replacing
// the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13).
feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record }> =>
diff --git a/frontend/src/lib/api/plexFilters.dom.test.ts b/frontend/src/lib/api/plexFilters.dom.test.ts
new file mode 100644
index 00000000..f5bbe656
--- /dev/null
+++ b/frontend/src/lib/api/plexFilters.dom.test.ts
@@ -0,0 +1,48 @@
+// @vitest-environment jsdom
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import { plexFiltersToParams, type PlexFilters } from "./plex";
+
+// FastAPI ignores a query parameter it does not know, so a Plex filter key the frontend misspells or
+// the backend renames does not fail anywhere — the filter silently stops narrowing while its chip
+// still shows (E32 S2, the sibling of lib/api/feed.test.ts). Every key the filter set can put on the
+// wire must be a field of the backend's LibraryFilters, which /library and /facets both inject.
+const EVERYTHING: PlexFilters = {
+ genres: ["Drama"],
+ genreMode: "all",
+ contentRatings: ["PG"],
+ yearMin: 1990,
+ yearMax: 1999,
+ ratingMin: 7,
+ durationMin: 60,
+ durationMax: 7200,
+ addedWithin: "1w",
+ directors: ["A"],
+ actors: ["B"],
+ actorMode: "all",
+ studios: ["C"],
+ playable: ["direct"],
+ keywords: ["heist"],
+ collections: ["123"],
+ collectionMode: "all",
+ collectionTitles: { "123": "Display only" },
+};
+
+describe("Plex filters on the wire", () => {
+ it("every key sent is a LibraryFilters field the backend reads", () => {
+ const src = readFileSync(resolve(__dirname, "../../../../backend/app/plex/query.py"), "utf-8");
+ const block = src.match(/class LibraryFilters:[\s\S]*?"""[\s\S]*?"""([\s\S]*?)def as_dict/);
+ expect(block, "plex/query.py no longer defines LibraryFilters").toBeTruthy();
+ const fields = new Set(
+ [...(block?.[1] ?? "").matchAll(/^\s{4}(\w+):/gm)].flatMap((m) => (m[1] ? [m[1]] : [])),
+ );
+ expect(fields.has("collection_mode")).toBe(true); // the parse found the real list
+
+ const sent = [...plexFiltersToParams(EVERYTHING).keys()];
+ expect(sent.filter((k) => !fields.has(k))).toEqual([]);
+ // Every filter in the set reached the wire (display-only titles excepted), so the check
+ // above saw them all.
+ expect(sent).toHaveLength(Object.keys(EVERYTHING).length - 1);
+ });
+});
diff --git a/frontend/src/lib/queryKeys.test.ts b/frontend/src/lib/queryKeys.test.ts
index 88b035d2..645e7573 100644
--- a/frontend/src/lib/queryKeys.test.ts
+++ b/frontend/src/lib/queryKeys.test.ts
@@ -12,10 +12,6 @@ describe("qk — bare vs parameterized branch", () => {
it("collapses to the bare prefix key only when no arg is given", () => {
expect(qk.feed()).toEqual(["feed"]);
expect(qk.feed(FILTERS)).toEqual(["feed", FILTERS]);
- expect(qk.feedCount()).toEqual(["feed-count"]);
- expect(qk.feedCount(FILTERS)).toEqual(["feed-count", FILTERS]);
- expect(qk.facets()).toEqual(["facets"]);
- expect(qk.facets(FILTERS)).toEqual(["facets", FILTERS]);
expect(qk.feedSummary()).toEqual(["feed-summary"]);
expect(qk.feedSummary(FILTERS)).toEqual(["feed-summary", FILTERS]);
expect(qk.playlist()).toEqual(["playlist"]);
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index 7c07ca10..13d608ca 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -6,7 +6,7 @@ import type { FeedFilters } from "./api";
// SAME array shape the call sites used before, so TanStack's partial (prefix) matching is
// unchanged — `qk.feed()` (`["feed"]`) still invalidates `qk.feed(filters)` (`["feed", filters]`).
// Deliberately NOT bundling co-invalidations into helpers here: the clusters differ per call site
-// (some invalidate `feed` alone, others `feed` + `feed-count`), so collapsing them would change
+// (some invalidate `feed` alone, others `feed` + `feed-summary`), so collapsing them would change
// which queries refetch. Grouped invalidation stays opt-in per domain (see `invalidateDownloads`).
// Extended per domain as R7 S2 migrates each area (S2a: feed/channels/playlists + shared roots).
export const qk = {
@@ -26,10 +26,6 @@ export const qk = {
// silently collapsed to the prefix.
feed: (filters?: FeedFilters) =>
filters !== undefined ? (["feed", filters] as const) : (["feed"] as const),
- feedCount: (filters?: FeedFilters) =>
- filters !== undefined ? (["feed-count", filters] as const) : (["feed-count"] as const),
- facets: (filters?: FeedFilters) =>
- filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
feedSummary: (filters?: FeedFilters) =>
filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const),
feedYears: (filters?: FeedFilters) =>
From dc00b55feb70dc146f64843628d0e2da6c3a37ee Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 21:53:06 +0200
Subject: [PATCH 26/54] test(auth): warm the Welcome import once so the first
case cannot time out under load
---
frontend/src/components/authCardStates.dom.test.tsx | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/authCardStates.dom.test.tsx b/frontend/src/components/authCardStates.dom.test.tsx
index 59c37ade..c0e09e56 100644
--- a/frontend/src/components/authCardStates.dom.test.tsx
+++ b/frontend/src/components/authCardStates.dom.test.tsx
@@ -7,7 +7,7 @@
//
// entryUrl snapshots window.location at IMPORT time, so the access-notice cases reset the module
// registry and set the URL before importing — that is the behaviour under test, not a workaround.
-import { describe, it, expect, afterEach, vi } from "vitest";
+import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import type { VerifyEntry } from "../lib/useVerifyEntry";
@@ -18,6 +18,13 @@ const noVerify: VerifyEntry = {
dismiss: () => {},
};
+// Every case re-imports the whole Welcome tree after a module reset. The FIRST import also transforms
+// it, which under the full suite's parallel load ran past the 5 s test timeout (E32 S2, gate flake).
+// Transforms survive resetModules, so warming them once here leaves each case only the evaluation.
+beforeAll(async () => {
+ await import("./Welcome");
+}, 60_000);
+
afterEach(() => {
cleanup();
vi.resetModules();
From 76358a0d77a52928278dd94be87db41b9879bc29 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sat, 3 Oct 2026 23:18:26 +0200
Subject: [PATCH 27/54] refactor(ui): the popover engine and every render-phase
ref write compile under the React Compiler
E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.
- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
a ref, so every component holding one was skipped (21 call sites, the engine and its surface
among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
useSyncExternalStore, which React asks on every render instead of a cached memo.
Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
"one writer" rule; the writer comes first now and the effect goes through it.
The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
---
frontend/src/components/AboutCell.tsx | 4 +-
frontend/src/components/ActiveFilterChips.tsx | 3 +-
frontend/src/components/AddToPlaylist.tsx | 9 ++-
frontend/src/components/BootGate.tsx | 9 +--
.../src/components/ChannelStatsPopover.tsx | 3 +-
frontend/src/components/ChannelTags.tsx | 13 +--
frontend/src/components/ChannelsProvider.tsx | 7 +-
frontend/src/components/ChatThread.tsx | 25 +++---
frontend/src/components/ConfigPanel.tsx | 38 +++++----
frontend/src/components/DateFilterSelect.tsx | 4 +-
frontend/src/components/Feed.tsx | 18 ++---
frontend/src/components/IssueComposer.tsx | 37 ++++-----
frontend/src/components/LanguageSwitcher.tsx | 4 +-
frontend/src/components/Messages.tsx | 12 +--
frontend/src/components/NavSidebar.tsx | 7 +-
.../src/components/NavigationProvider.tsx | 19 ++---
frontend/src/components/OnboardingWizard.tsx | 8 +-
.../src/components/PersonalViewsModal.tsx | 60 ++++++++------
frontend/src/components/PlayerModal.tsx | 14 ++--
.../src/components/PlaylistLimitsHint.tsx | 5 +-
frontend/src/components/Playlists.tsx | 52 ++++++------
frontend/src/components/PlexBrowse.tsx | 28 +++----
.../src/components/PlexCollectionEditor.tsx | 12 +--
.../src/components/PlexCollectionView.tsx | 8 +-
frontend/src/components/PlexPlayer.tsx | 6 +-
frontend/src/components/PlexPlaylistAdd.tsx | 6 +-
frontend/src/components/PlexPlaylistView.tsx | 8 +-
frontend/src/components/SavedViewsPopover.tsx | 5 +-
frontend/src/components/SyncStatus.tsx | 3 +-
frontend/src/components/TagFilterChips.tsx | 3 +-
frontend/src/components/TagManager.tsx | 15 ++--
frontend/src/components/UpNextCard.tsx | 9 +--
frontend/src/components/VideoEditor.tsx | 25 +++---
frontend/src/components/ViewSwitcher.tsx | 4 +-
frontend/src/components/Welcome.tsx | 9 +--
frontend/src/components/YearFilter.tsx | 4 +-
.../compilerCoverage.contract.test.ts | 80 ++++---------------
frontend/src/components/ui/ActionMenu.tsx | 8 +-
.../src/components/ui/AnchoredPopover.tsx | 7 +-
.../src/components/ui/Combobox.dom.test.tsx | 24 ++++++
frontend/src/components/ui/Combobox.tsx | 58 +++++++++-----
frontend/src/components/ui/FilterSelect.tsx | 3 +-
.../src/components/ui/FocusSafeButton.tsx | 23 +++++-
frontend/src/components/ui/Modal.tsx | 8 +-
frontend/src/components/ui/NavRail.tsx | 3 +-
frontend/src/components/ui/PageScroller.tsx | 50 +++++++-----
.../src/components/ui/PriorityStepper.tsx | 8 +-
frontend/src/components/ui/SidePanel.tsx | 6 +-
frontend/src/components/ui/SortControl.tsx | 3 +-
.../src/components/ui/Toaster.dom.test.tsx | 12 +++
frontend/src/components/ui/Toaster.tsx | 41 +++++-----
frontend/src/components/ui/Tooltip.tsx | 3 +-
.../src/components/ui/anchoredPopoverHooks.ts | 11 +--
frontend/src/components/ui/sortableA11y.ts | 23 +++---
frontend/src/components/ui/tableHeader.tsx | 9 ++-
frontend/src/lib/history.ts | 17 ++--
frontend/src/lib/keymap.ts | 11 ++-
frontend/src/lib/layerStack.ts | 24 +++---
frontend/src/lib/listNav.ts | 14 ++--
frontend/src/lib/playerModalHooks.ts | 4 -
frontend/src/lib/plexPlayerHooks.ts | 19 ++---
frontend/src/lib/useChannelPriority.ts | 39 ++++-----
frontend/src/lib/useChipOverflowFit.ts | 9 +--
frontend/src/lib/useDismiss.ts | 20 ++---
frontend/src/lib/useLatest.ts | 26 ++++++
frontend/src/lib/usePref.ts | 11 +--
frontend/src/lib/useUndoable.ts | 66 ++++++++-------
frontend/src/lib/useUrlMirror.ts | 15 ++--
68 files changed, 606 insertions(+), 545 deletions(-)
create mode 100644 frontend/src/lib/useLatest.ts
diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx
index 00895088..b4412c94 100644
--- a/frontend/src/components/AboutCell.tsx
+++ b/frontend/src/components/AboutCell.tsx
@@ -36,6 +36,8 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
fitHeight: true,
});
+ const { setReference } = pop.refs;
+
const { data: ch } = useQuery({
queryKey: qk.channel(channelId),
queryFn: () => api.channelDetail(channelId),
@@ -48,7 +50,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
return (
<>
: undefined}
diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx
index d4179213..863443e1 100644
--- a/frontend/src/components/AddToPlaylist.tsx
+++ b/frontend/src/components/AddToPlaylist.tsx
@@ -31,10 +31,11 @@ export default function AddToPlaylist({
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const pop = useMembershipPopover(open, setOpen);
+ const { setReference } = pop.refs;
return (
<>
diff --git a/frontend/src/components/BootGate.tsx b/frontend/src/components/BootGate.tsx
index 5091975e..dd8562cb 100644
--- a/frontend/src/components/BootGate.tsx
+++ b/frontend/src/components/BootGate.tsx
@@ -1,4 +1,4 @@
-import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
+import { useEffectEvent, useLayoutEffect, useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
@@ -89,12 +89,11 @@ export default function BootGate({ children }: { children: ReactNode }) {
// orders its lists and writes its mail in the stored one, else English, and a Hungarian screen
// over English-ordered lists breaks "A→Z in the UI language's alphabet".
const [readyFor, setReadyFor] = useState
(undefined);
- // `me` is read for the identity it produced, so it rides a ref: a background refetch of the same
+ // `me` is read for the identity it produced, so it is an effect event: a background refetch of the same
// account must not re-run the adoption over a language the user has just changed. That rule used
// to live in a dep list that excluded `me`, which made the dep list load-bearing for correctness —
// and the failure mode of getting it wrong was a page-reload loop, not a stale value.
- const meRef = useRef(me);
- meRef.current = me;
+ const adoptedMe = useEffectEvent(() => me);
useLayoutEffect(() => {
if (identity === undefined) return;
if (readyFor !== undefined) {
@@ -107,7 +106,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
if (readyFor !== identity) location.reload();
return;
}
- const account = meRef.current;
+ const account = adoptedMe();
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
const lang = account?.preferences?.language;
if (typeof lang === "string" && isSupported(lang)) {
diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx
index 0a280ab2..f0f26b10 100644
--- a/frontend/src/components/ChannelStatsPopover.tsx
+++ b/frontend/src/components/ChannelStatsPopover.tsx
@@ -32,6 +32,7 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
placement: "bottom-end",
offset: 8,
});
+ const { setReference } = pop.refs;
// Each row keeps the per-stat explanation the old inline stats carried (as a native `title` on the
// row), so hovering a figure still tells the user what it counts — the guidance is not lost.
@@ -84,7 +85,7 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
variant="glass"
size="sm"
icon={ }
- ref={pop.refs.setReference}
+ ref={setReference}
{...pop.getReferenceProps()}
>
{t("channels.stats.label")}
diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx
index 5ef4023d..e8e545a9 100644
--- a/frontend/src/components/ChannelTags.tsx
+++ b/frontend/src/components/ChannelTags.tsx
@@ -128,6 +128,7 @@ function SuggestedChipsLane({
mode: "hover",
placement: "bottom",
});
+ const { setReference: setOverflowAnchor } = overflowPop.refs;
return (
@@ -165,7 +166,7 @@ function SuggestedChipsLane({
@@ -208,7 +209,7 @@ function TagEditorMenu({
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState("");
- const fade = useScrollFade();
+ const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const attached = new Set(attachedIds);
const submit = () => {
const name = draft.trim();
@@ -218,7 +219,7 @@ function TagEditorMenu({
};
return (
-
+
{userTags.map((tg) => {
const on = attached.has(tg.id);
return (
@@ -349,6 +350,7 @@ export function ChannelTagsControl({
// of the roving list so neither steals the field's caret.
hostsInput: true,
});
+ const { setReference: setEditAnchor } = editPop.refs;
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render —
@@ -362,6 +364,7 @@ export function ChannelTagsControl({
mode: "hover",
placement: "bottom",
});
+ const { setReference: setOverflowAnchor } = overflowPop.refs;
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
const pillClass = chipClass("filter", "xs");
@@ -381,7 +384,7 @@ export function ChannelTagsControl({
}
@@ -440,7 +443,7 @@ export function ChannelTagsControl({
{hidden > 0 && (
diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx
index a4d10373..6b6f79c7 100644
--- a/frontend/src/components/ChannelsProvider.tsx
+++ b/frontend/src/components/ChannelsProvider.tsx
@@ -10,6 +10,7 @@ import {
import { getAccountRaw, LS, useAccountPersistedState } from "../lib/storage";
import { parseChannelLayout } from "../lib/channelLayout";
import { hydrateSort } from "../lib/columnSort";
+import { useLatest } from "../lib/useLatest";
import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
import { useUrlMirror } from "../lib/useUrlMirror";
@@ -135,9 +136,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
// it (a dep would recreate the action on every switch and re-render every actions consumer).
- // Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
- const viewRef = useRef(view);
- viewRef.current = view;
+ const viewRef = useLatest(view);
const setSearch = useCallback((q: string) => setSearchState(q), []);
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
@@ -159,7 +158,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
}
},
- [setViewRaw],
+ [setViewRaw, viewRef],
);
const focusChannel = useCallback(
(name: string) => {
diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx
index 6cdd4ee5..80b84671 100644
--- a/frontend/src/components/ChatThread.tsx
+++ b/frontend/src/components/ChatThread.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useEffectEvent, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -33,7 +33,7 @@ export default function ChatThread({
const [draft, setDraft] = useState("");
const [plain, setPlain] = useState>({});
const bottomRef = useRef(null);
- const fade = useScrollFade();
+ const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const ready = useKeyState() === "ready";
const needsKey = !isSystem && !ready;
@@ -43,10 +43,10 @@ export default function ChatThread({
{ intervalMs: POLL_MS, enabled: !needsKey },
);
const items = q.data?.items ?? [];
- // The two effects below take the FETCH as their trigger and read the payload through this ref, so
- // their dep lists name everything they touch without making the identity of `q.data` a trigger.
- const threadRef = useRef(q.data);
- threadRef.current = q.data;
+ // The two effects below take the FETCH as their trigger and read the payload through this effect
+ // event, so their dep lists name everything they touch without making the identity of `q.data` a
+ // trigger.
+ const latestThread = useEffectEvent(() => q.data);
// Decrypt the thread when fresh server data arrives. Keyed on dataUpdatedAt (not `items`): the
// `?? []` fallback is a new array reference every render, which would otherwise re-run this
@@ -54,6 +54,7 @@ export default function ChatThread({
useEffect(() => {
if (isSystem || !ready) return;
let cancelled = false;
+ const thread = latestThread();
// Every await inside is caught per message (a failure reads "can't decrypt"), so this never rejects.
void (async () => {
const out: Record = {};
@@ -62,7 +63,7 @@ export default function ChatThread({
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
let pub: string | null = null;
let refreshed = false;
- for (const m of threadRef.current?.items ?? []) {
+ for (const m of thread?.items ?? []) {
if (!m.ciphertext || !m.iv) continue;
try {
if (!pub) pub = await partnerPub(partnerId);
@@ -90,8 +91,8 @@ export default function ChatThread({
return () => {
cancelled = true;
};
- // `dataUpdatedAt` is the trigger and the payload is read through the ref above — see Messages,
- // where keying this family on `q.data` itself produced a render loop.
+ // `dataUpdatedAt` is the trigger and the payload is read through the effect event above — see
+ // Messages, where keying this family on `q.data` itself produced a render loop.
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
@@ -108,7 +109,7 @@ export default function ChatThread({
lastPartner.current = partnerId;
lastIncoming.current = -1;
}
- const incoming = (threadRef.current?.items ?? []).filter((m) => m.recipient_id === meId).length;
+ const incoming = (latestThread()?.items ?? []).filter((m) => m.recipient_id === meId).length;
if (incoming !== lastIncoming.current) {
lastIncoming.current = incoming;
qc.invalidateQueries({ queryKey: qk.conversations() });
@@ -167,8 +168,8 @@ export default function ChatThread({
return (
<>
{q.isError && !q.data ? (
diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx
index 57c8d81d..cba7376a 100644
--- a/frontend/src/components/ConfigPanel.tsx
+++ b/frontend/src/components/ConfigPanel.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useId, useMemo, useRef, useState } from "react";
+import { useId, useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -28,6 +28,23 @@ import { SURFACE } from "./ui/surfaces";
// registry's own order (`sysconfig.SPECS`), so a new group lands where the backend puts it instead of
// needing a second, hand-kept order list here.
+/** The admin's draft, plus the secrets marked to reset-to-default on the next Save (that can't be
+ * expressed by clearing the field: an empty secret field means "leave unchanged"). Re-seeded from
+ * `baseline` DURING render when `seedKey` changes, never on every new baseline: an unchanged refetch
+ * produces a new object, and re-seeding on that is exactly the clobbering of an in-progress draft
+ * that CB2 was about. (It was an effect: the first frame after a load painted the empty draft.) */
+function useSeededDraft(baseline: Record
, seedKey: string) {
+ const [draft, setDraft] = useState>({});
+ const [secretReset, setSecretReset] = useState>({});
+ const [seededFor, setSeededFor] = useState(null);
+ if (seededFor !== seedKey) {
+ setSeededFor(seedKey);
+ setDraft(baseline);
+ setSecretReset({});
+ }
+ return { draft, setDraft, secretReset, setSecretReset };
+}
+
export default function ConfigPanel() {
const { t } = useTranslation();
const panel = useTabPanel();
@@ -46,13 +63,9 @@ export default function ConfigPanel() {
return m;
}, [items]);
- const [draft, setDraft] = useState>({});
- // Secrets the admin marked to reset-to-default on the next Save (can't be expressed by
- // clearing the field, since an empty secret field means "leave unchanged").
- const [secretReset, setSecretReset] = useState>({});
const [saveState, setSaveState] = useState("idle");
// Bumped after our own save so the draft re-seeds from fresh server values; a plain mid-edit
- // refetch does NOT (see the effect below).
+ // refetch does NOT (see the seed below).
const [reseedToken, setReseedToken] = useState(0);
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
@@ -63,15 +76,10 @@ export default function ConfigPanel() {
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
- // The baseline is read through a ref, not named as a dependency: an unchanged refetch produces a
- // new object, and re-seeding on that is exactly the clobbering of an in-progress draft that CB2
- // was about. `dataVersion` (the config SHAPE) and the post-save token are the real triggers.
- const baselineRef = useRef(baseline);
- baselineRef.current = baseline;
- useEffect(() => {
- setDraft(baselineRef.current);
- setSecretReset({});
- }, [dataVersion, reseedToken]);
+ const { draft, setDraft, secretReset, setSecretReset } = useSeededDraft(
+ baseline,
+ `${reseedToken}:${dataVersion}`,
+ );
const isDirty = (it: ConfigItem): boolean => {
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
diff --git a/frontend/src/components/DateFilterSelect.tsx b/frontend/src/components/DateFilterSelect.tsx
index 712411fd..cb93375c 100644
--- a/frontend/src/components/DateFilterSelect.tsx
+++ b/frontend/src/components/DateFilterSelect.tsx
@@ -92,6 +92,8 @@ export default function DateFilterSelect({
hostsInput: true,
});
+ const { setReference } = pop.refs;
+
const pick = (value: string) => {
const change = selectDateChip(value, filters);
setCustomOpen(change.customOpen);
@@ -105,7 +107,7 @@ export default function DateFilterSelect({
context="form"
open={open}
icon={ }
- ref={pop.refs.setReference}
+ ref={setReference}
{...pop.getReferenceProps()}
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
>
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index aee20101..c04ae797 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -1,4 +1,4 @@
-import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
+import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
keepPreviousData,
@@ -280,6 +280,8 @@ export default function Feed({
// onState lets memo(VideoCard) skip re-rendering existing cards on page append.
const loadedRef = useRef([]);
+ // The toast's Undo re-enters onState, a forward reference: through this ref (set below).
+ const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
const onState = useCallback(
(id: string, status: VideoStatus) => {
setOverrides((o) => ({ ...o, [id]: status }));
@@ -349,11 +351,6 @@ export default function Feed({
},
[qc],
);
- // The toast's Undo action re-enters onState, which is a forward reference to the binding still
- // being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
- const onStateRef = useRef(onState);
- // eslint-disable-next-line react-hooks/immutability -- a latest-value ref for a forward reference: the Undo re-enters onState before its binding exists
- onStateRef.current = onState;
const onResetState = useCallback(
(id: string) => {
@@ -404,7 +401,12 @@ export default function Feed({
);
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
- loadedRef.current = loaded;
+ const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
+ // onState's refs follow each commit: itself, and what the cards show (live search results if on).
+ useLayoutEffect(() => {
+ onStateRef.current = onState;
+ loadedRef.current = ytActive ? ytLoaded : loaded;
+ });
const withOverrides = (list: Video[]): Video[] =>
list
.map((v) => {
@@ -435,8 +437,6 @@ export default function Feed({
// A separate data source rendered in the same cards/player. No watch-state view filter (the
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) {
- const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
- loadedRef.current = ytLoaded;
const ytItems: Video[] = sortYtResults(
withOverrides(ytLoaded),
ytSort.key as SortKey,
diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/components/IssueComposer.tsx
index 80d669da..37b46ba7 100644
--- a/frontend/src/components/IssueComposer.tsx
+++ b/frontend/src/components/IssueComposer.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState, type DragEvent, type RefObject } from "react";
+import { useEffect, useEffectEvent, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
@@ -132,8 +132,9 @@ function ComposerBody({
const [reviewing, setReviewing] = useState(null);
const issueLink = useRef(null);
const retryButton = useRef(null);
- // The log whose review just closed: its Review button takes the focus back.
- const reviewed = useRef(null);
+ // The log whose review just closed: its Review button takes the focus back when the attachments
+ // mount again (they unmount while a review is open).
+ const [reviewed, setReviewed] = useState(null);
const sent = session.issue_url !== null;
const shotsQ = useQuery({
@@ -231,19 +232,17 @@ function ComposerBody({
});
const locked = sent || send.isPending || sending;
- const saveRef = useRef(save.mutate);
- saveRef.current = save.mutate;
+ const saveNow = useEffectEvent(() => save.mutate());
useEffect(() => {
if (locked || !unsaved) return;
- const timer = window.setTimeout(() => saveRef.current(), SAVE_DELAY_MS);
+ const timer = window.setTimeout(() => saveNow(), SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [body, form.title, locked, unsaved]);
// Closed before the typing paused: save what is still waiting, now.
- const flushRef = useRef(() => {});
- flushRef.current = () => {
+ const flush = useEffectEvent(() => {
if (unsaved && !locked) save.mutate();
- };
- useEffect(() => () => flushRef.current(), []);
+ });
+ useEffect(() => () => flush(), []);
// Screenshots are added here, not in the attachments column: an image pasted ANYWHERE in the
// composer is taken — while typing the description is exactly when one is pasted.
@@ -252,8 +251,7 @@ function ComposerBody({
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: () => refreshShots(qc, session.id),
});
- const addRef = useRef(addShot.mutate);
- addRef.current = addShot.mutate;
+ const addImage = useEffectEvent((image: Blob) => addShot.mutate(image));
const root = useRef(null);
useEffect(() => {
if (locked) return;
@@ -265,7 +263,7 @@ function ComposerBody({
const images = [...(e.clipboardData?.files ?? [])].filter(isImage);
if (!images.length) return;
e.preventDefault();
- for (const image of images) addRef.current(image);
+ for (const image of images) addImage(image);
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
@@ -393,7 +391,7 @@ function ComposerBody({
}))
}
onDone={() => {
- reviewed.current = reviewing;
+ setReviewed(reviewing);
setReviewing(null);
}}
/>
@@ -606,7 +604,7 @@ function Attachments({
adding: boolean;
onAdd: (files: Iterable) => void;
onReview: (source: Source) => void;
- reviewed: RefObject;
+ reviewed: Source | null;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -615,13 +613,10 @@ function Attachments({
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
const pick = useRef(null);
const reviewButtons = useRef>>({});
- // eslint-disable-next-line react-hooks/immutability -- the effect that consumes the caller's one-shot focus token (see the write below)
+ // On mount after a review closed (this list unmounts while one is open), that log's Review button
+ // takes the focus back. Only a mount or a new review moves it, so no later render steals it.
useEffect(() => {
- const source = reviewed.current;
- if (!source) return;
- // eslint-disable-next-line react-hooks/immutability -- a ONE-SHOT token, consumed: the parent sets it to say "give this source's review button the focus back when the list re-renders", and clearing it here is what stops the focus being stolen on every later render. The ref is the caller's, which is why it is a dependency rather than a local.
- reviewed.current = null;
- reviewButtons.current[source]?.focus();
+ if (reviewed) reviewButtons.current[reviewed]?.focus();
}, [reviewed]);
const remove = useMutation({
mutationFn: (shotId: number) => api.removeDebugScreenshot(session.id, shotId),
diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx
index b5c0f8e8..a7a1bebb 100644
--- a/frontend/src/components/LanguageSwitcher.tsx
+++ b/frontend/src/components/LanguageSwitcher.tsx
@@ -44,10 +44,12 @@ export default function LanguageSwitcher({
selectedIndex: currentIdx,
});
+ const { setReference } = pop.refs;
+
return (
<>
>({});
// Decrypt conversation previews when fresh data arrives. The TRIGGER is `dataUpdatedAt` and the
- // payload is read through a ref, which is what makes the dep list below complete without making
- // the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a
+ // payload is read through an effect event, which is what makes the dep list below complete without
+ // making the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a
// new array every render, and anything handing back a fresh object per render (a stub, a caller
// that rebuilds it) turns "re-run when the data changes" into a render loop through setPreviews.
// Measured the hard way — E27 S2 keyed this on `q.data`, and Messages.keyboard.dom.test hung.
- const conversationsRef = useRef(q.data);
- conversationsRef.current = q.data;
+ const latestConversations = useEffectEvent(() => q.data);
useEffect(() => {
if (!ready) return;
let cancelled = false;
+ const conversations = latestConversations();
// Each decrypt is caught (a failure previews as "⚠"), so this never rejects.
void (async () => {
const out: Record = {};
- for (const c of conversationsRef.current?.items ?? []) {
+ for (const c of conversations?.items ?? []) {
const m = c.last_message;
if (m.kind === "user" && m.ciphertext && m.iv) {
try {
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx
index 64453c31..0ddb0685 100644
--- a/frontend/src/components/NavSidebar.tsx
+++ b/frontend/src/components/NavSidebar.tsx
@@ -74,6 +74,7 @@ export default function NavSidebar({
placement: "right-end",
offset: 8,
});
+ const { setReference: setAcctAnchor } = acctPop.refs;
async function logout() {
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
@@ -145,7 +146,7 @@ export default function NavSidebar({
// Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook,
// also used by the side panels) so a high-zoom overflow scrolls without a scrollbar.
- const listFade = useScrollFade();
+ const { ref: listFadeRef, style: listFadeStyle } = useScrollFade();
type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number };
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each
@@ -198,7 +199,7 @@ export default function NavSidebar({
// against its right edge.
-
+
{userItems.map(renderItem)}
{systemItems.length > 0 && (
<>
@@ -243,7 +244,7 @@ export default function NavSidebar({
void) =>
@@ -101,7 +98,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
notifyNav();
then?.();
}),
- [],
+ [pageRef, channelViewRef],
);
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx
index 121585ae..a4db3b82 100644
--- a/frontend/src/components/OnboardingWizard.tsx
+++ b/frontend/src/components/OnboardingWizard.tsx
@@ -149,13 +149,13 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
}, [layer]);
// Are we currently importing (or about to)? Show a progress screen instead of the
- // "you're connected" step until the first import settles.
+ // "you're connected" step until the first import settles. Once triggered, the mutation itself says
+ // which (pending / success / error) — the trigger ref is the effect's once-only latch, never read
+ // here (a ref read in render made the React Compiler skip the wizard, E32).
const importing =
me.can_read &&
!importSubs.isError &&
- (importSubs.isPending ||
- (importTriggered.current && !importSubs.isSuccess) ||
- (myStatus.data?.channels_total === 0 && !importSubs.isSuccess));
+ (importSubs.isPending || (myStatus.data?.channels_total === 0 && !importSubs.isSuccess));
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx
index e4b415a6..8976ee66 100644
--- a/frontend/src/components/PersonalViewsModal.tsx
+++ b/frontend/src/components/PersonalViewsModal.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BookmarkPlus, Check, Play, Save, Star, Trash2 } from "lucide-react";
import {
@@ -80,6 +80,34 @@ function ViewRow({
);
}
+/** The SELECTED view's two drafts: its name (the rename field) and a local, undoable draft of its
+ * captured filters. Removing a chip prunes the filter draft and — via onApply — writes the pruned set
+ * straight back to the stored view; Undo/Redo re-persist the same way. The draft is the single
+ * source of truth for the chip row, so a chip-prune refetch (which returns the value we just wrote)
+ * never clobbers the undo history: both re-seed only when the SELECTION changes (and on Save-current,
+ * by the caller). Adjusted DURING render against the remembered selection — an effect painted the
+ * previous view's filters and name for a frame first. `selectedId` is the trigger, `selected` only
+ * the data. */
+function useSelectedViewDrafts(
+ selected: SavedView | null,
+ selectedId: number | null,
+ updateFilters: (id: number, filters: Record) => unknown,
+) {
+ const [nameDraft, setNameDraft] = useState("");
+ const filtersDraft = useUndoable>(selected?.filters ?? {}, {
+ onApply: (f) => {
+ if (selected) void updateFilters(selected.id, f);
+ },
+ });
+ const [seededFor, setSeededFor] = useState(undefined);
+ if (seededFor !== selectedId) {
+ setSeededFor(selectedId);
+ filtersDraft.reset(selected?.filters ?? {});
+ setNameDraft(selected?.name ?? "");
+ }
+ return { filtersDraft, nameDraft, setNameDraft };
+}
+
export default function PersonalViewsModal() {
const { t } = useTranslation();
const {
@@ -107,29 +135,11 @@ export default function PersonalViewsModal() {
const selected = views.find((v) => v.id === selectedId) ?? null;
// Two independent name fields: the left one names a NEW view; the right one renames the selected one.
const [createName, setCreateName] = useState("");
- const [nameDraft, setNameDraft] = useState("");
-
- // A local, undoable draft of the SELECTED view's captured filters. Removing a chip prunes the draft
- // and — via onApply — writes the pruned set straight back to the stored view; Undo/Redo re-persist
- // the same way. The draft is the single source of truth for the chip row, so a chip-prune refetch
- // (which returns the value we just wrote) never clobbers the undo history. We only re-seed it on an
- // external change of the visible filters: switching the selection, or Save-current below.
- const filtersDraft = useUndoable>(selected?.filters ?? {}, {
- onApply: (f) => {
- if (selected) void updateFilters(selected.id, f);
- },
- });
- const resetDraft = filtersDraft.reset;
- // Re-seed only when the SELECTION changes, not on every same-view refetch — a refetch returns the
- // value we just wrote, and re-seeding on it would throw away the prune's undo history. The chosen
- // view is therefore read through a ref: `selectedId` is the trigger, `selected` is only the data.
- const selectedRef = useRef(selected);
- selectedRef.current = selected;
- useEffect(() => {
- const view = selectedRef.current;
- resetDraft(view?.filters ?? {});
- setNameDraft(view?.name ?? "");
- }, [selectedId, resetDraft]);
+ const { filtersDraft, nameDraft, setNameDraft } = useSelectedViewDrafts(
+ selected,
+ selectedId,
+ updateFilters,
+ );
// The selected view's captured filters, as the same human labels its module's chip row shows, read
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
@@ -322,7 +332,7 @@ export default function PersonalViewsModal() {
const saved = await saveCurrent(selected.id);
// Re-seed the draft to what we just stored (the live filters) so the chip row
// reflects the save; clears the now-stale undo history.
- if (saved) resetDraft(saved);
+ if (saved) filtersDraft.reset(saved);
}}
disabled={saveCurrentDisabled}
title={
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index 2d242a68..198d94b7 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -148,7 +148,7 @@ export default function PlayerModal({
const lang = i18n.language;
const { openChannel } = useNavigationActions();
const qc = useQueryClient();
- const descFade = useScrollFade();
+ const { ref: descFadeRef, style: descFadeStyle } = useScrollFade();
// Browser/mouse Back closes the player instead of leaving the page.
useBackToClose(onClose);
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
@@ -258,7 +258,6 @@ export default function PlayerModal({
const {
isFullscreen,
maximized,
- maximizedRef,
isFullscreenRef,
toggleFullscreen,
toggleTrueFullscreen,
@@ -468,6 +467,7 @@ export default function PlayerModal({
placement: "top-start",
offset: 8,
});
+ const { setReference: setDescAnchor } = descPop.refs;
const detail = useQuery({
queryKey: qk.videoDetail(currentVideoId),
queryFn: () => api.videoDetail(currentVideoId),
@@ -514,7 +514,7 @@ export default function PlayerModal({
// all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the
// common case and checking only `isFullscreen` made the keys dead exactly where they matter most.
const volume = (e: KeyboardEvent, up: boolean) => {
- const covered = isFullscreenRef.current || maximizedRef.current;
+ const covered = isFullscreenRef.current || maximized;
if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1))
return;
e.preventDefault();
@@ -544,7 +544,7 @@ export default function PlayerModal({
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
- if (maximizedRef.current) {
+ if (maximized) {
ev.preventDefault();
setMaximized(false);
return;
@@ -889,7 +889,7 @@ export default function PlayerModal({
{!wide && (
{description((body) => (
{body}
diff --git a/frontend/src/components/PlaylistLimitsHint.tsx b/frontend/src/components/PlaylistLimitsHint.tsx
index 33449599..839cb2e9 100644
--- a/frontend/src/components/PlaylistLimitsHint.tsx
+++ b/frontend/src/components/PlaylistLimitsHint.tsx
@@ -22,16 +22,17 @@ export default function PlaylistLimitsHint() {
placement: "right-end",
offset: 8,
});
+ const { setPositionReference, setReference } = pop.refs;
return (
// The panel is placed off the whole row, not the "Why?" button: off the button it opened
// over the right third of the rail and covered the lists (measured on localdev).
{t("playlists.limits.hint")}
-
+
{t("playlists.limits.why")}
("");
// The selection as of NOW, for code that runs after an await or on behalf of the open player.
- const selectedRef = useRef(selectedId);
- selectedRef.current = selectedId;
+ const selectedRef = useLatest(selectedId);
// Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed
// reorder or move must not wait for the resync to do it: the server's list is unchanged, so the
// refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below
@@ -330,12 +330,15 @@ export default function Playlists() {
// push/delete-on-YouTube paths (usePlaylistActions). Folding it in here would have taken drag and remove away from
// every account without the scope, which they have always had.
const canEdit = editable;
- const canEditRef = useRef(canEdit);
- canEditRef.current = canEdit;
+ const canEditRef = useLatest(canEdit);
+ // Forward refs to `shown` and `removeItem` (defined further down) for the stable callbacks here,
+ // pointed at the latest values after each commit (below).
+ const shownRef = useRef([]);
+ const removeRef = useRef(async () => false);
// Stable (the memoized row callbacks depend on it), so it reads `shown` through the ref rather
// than closing over it.
- const openPlayer = useCallback((id: string) => {
+ const openPlayer = useStableCallback((id: string) => {
const queue = shownRef.current;
const index = queue.findIndex((v) => v.id === id);
if (index < 0) return;
@@ -346,18 +349,14 @@ export default function Playlists() {
listId: selectedRef.current,
editable: canEditRef.current,
});
- }, []);
- const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]);
+ });
// The player's "remove from this playlist" goes through the SAME removeItem the row menu and the
// Delete key use — one optimistic update, one undo toast, one undo-stack rebase, no second copy to
// drift. It answers whether the removal actually happened, because the player has to decide
// afterwards whether to step to the next item or close. It removes from the list the player was
// launched from (see `playing.listId`), not from whatever is selected by now.
const launchListId = playing?.listId ?? null;
- const removeFromPlayer = useCallback(
- (v: Video) => removeRef.current(v, launchListId),
- [launchListId],
- );
+ const removeFromPlayer = (v: Video) => removeRef.current(v, launchListId);
const storedIndex = useMemo(() => {
const m = new Map(items.map((v, i) => [v.id, i]));
@@ -379,7 +378,7 @@ export default function Playlists() {
playlistColumns({
t,
indexOf: storedIndex,
- onPlay,
+ onPlay: (v) => openPlayer(v.id),
more: (v) => (
),
}),
- [t, storedIndex, onPlay, rowMenuButton, rowMenuOpen, rowMenuTarget],
+ [t, storedIndex, openPlayer, rowMenuButton, rowMenuOpen, rowMenuTarget],
);
const filterActive = Object.values(filters).some(isActive);
@@ -397,11 +396,6 @@ export default function Playlists() {
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy, lang),
[items, columns, filters, viewSort, groupBy, lang],
);
- // Read by the drag announcements, which must report the CURRENT positions without making the
- // announcement closures a render dependency of DndContext.
- const shownRef = useRef(shown);
- // eslint-disable-next-line react-hooks/immutability -- the house latest-value ref: the announcement closures must read the CURRENT list without the list becoming a render dependency of DndContext. A render-phase ref write is never part of the rendered output, which is what makes it safe; the checker is conservative about refs an effect also reads.
- shownRef.current = shown;
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
// Saving a FILTERED view would drop every hidden item from the playlist, so the button only
@@ -535,10 +529,12 @@ export default function Playlists() {
});
return true;
}
- // Kept in a ref so the memoized column callbacks don't rebuild on every state change.
- const removeRef = useRef(removeItem);
- // eslint-disable-next-line react-hooks/immutability -- same latest-value ref as shownRef above — it keeps the memoised column callbacks from rebuilding on every state change.
- removeRef.current = removeItem;
+ // The drag announcements read `shownRef` too: the CURRENT positions, without making their closures
+ // a render dependency of DndContext; `removeRef` keeps the memoized column callbacks stable.
+ useLayoutEffect(() => {
+ shownRef.current = shown;
+ removeRef.current = removeItem;
+ });
async function moveItemTo(video: Video, target: Playlist) {
if (selectedId == null) return;
@@ -610,7 +606,7 @@ export default function Playlists() {
// Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
- const closePlayer = useCallback(() => setPlaying(null), []);
+ const closePlayer = () => setPlaying(null);
// Name the current view in the user's terms: "Title ↑", "grouped by channel", or both.
const sortLabel =
@@ -866,11 +862,11 @@ export default function Playlists() {
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") {
- renameFocus.arm(e);
+ armRenameReturn(e);
void saveRename(); // plActions.rename reports its own failure
}
if (e.key === "Escape") {
- renameFocus.arm(e);
+ armRenameReturn(e);
setRenaming(false);
}
}}
@@ -902,7 +898,7 @@ export default function Playlists() {
{/* Rename, the pin, YouTube (open / push / revert) and Delete: the list's ONE
menu (E28 C1), the same the rail row opens — the header keeps Play all. */}
void (infoScrollRef.current = px);
+
// The header search typeahead picked a title → open it the same way a card click would (a movie's
// info page like the card menu's "Media info", a show's drill-down), then clear the hand-off so it
// doesn't re-open.
useEffect(() => {
if (jumpTo != null) {
- if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top
+ if (jumpTo.type !== "show") rememberInfoScroll(0); // a jumped-to info page starts at the top
sub.open(
jumpTo.type === "show"
? { kind: "show", id: jumpTo.id }
@@ -187,22 +197,6 @@ export default function PlexBrowse() {
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
// instead of the top of the library. The scroller is App's (the page's overflow-y-auto).
const scrollRef = useRef(0);
- // Same idea for the info page: "Browse collection" (onFilter) leaves the info view for a filtered
- // grid, and browser Back returns to this same info page — restore where the user had scrolled it
- // (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
- // different title's info starts at the top.
- const infoScrollRef = useRef(0);
- // ONE writer for that memory, so "stash on the way out of an info page, reset on a fresh one" is a
- // rule the code holds rather than one spread over four call sites — and so the compiler's
- // complaint about it is answered once, here, instead of at each of them.
- //
- // The complaint is a false positive of a conservative analysis, not a hazard: the rule objects to a
- // ref being written outside the effect that reads it, but that IS the arrangement a scroll memory
- // needs — the navigation handlers record where the page was, and the restore layout effect above
- // reads it on the way back. React's own guarantee (a ref write is never part of rendering) is what
- // makes it safe, and nothing here reads the value during render.
- // eslint-disable-next-line react-hooks/immutability -- a scroll memory: handlers record, the restore layout effect reads, nothing reads it in render
- const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
// One menu for the titles grid and one for the search's episodes (see ui/ContextMenu).
const menu = useContextMenu();
const epMenu = useContextMenu();
diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx
index 9eaf4f72..809811ff 100644
--- a/frontend/src/components/PlexCollectionEditor.tsx
+++ b/frontend/src/components/PlexCollectionEditor.tsx
@@ -34,8 +34,8 @@ export default function PlexCollectionEditor({
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
- const editableFade = useScrollFade();
- const othersFade = useScrollFade();
+ const { ref: editableFadeRef, style: editableFadeStyle } = useScrollFade();
+ const { ref: othersFadeRef, style: othersFadeStyle } = useScrollFade();
const [members, setMembers] = useState>(() => new Set(memberOf));
const [q, setQ] = useState("");
const [newName, setNewName] = useState("");
@@ -186,8 +186,8 @@ export default function PlexCollectionEditor({
{/* Editable collections with in/out toggle */}
{listQ.isLoading ? (
@@ -238,8 +238,8 @@ export default function PlexCollectionEditor({
{t("plex.collEditor.others", { count: eligible.length })}
{eligible.map((c) => (
diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx
index af223c1e..59fde3fc 100644
--- a/frontend/src/components/PlexCollectionView.tsx
+++ b/frontend/src/components/PlexCollectionView.tsx
@@ -101,7 +101,7 @@ export default function PlexCollectionView({
const d = q.data;
const [renaming, setRenaming] = useState(false);
- const renameFocus = useReturnFocus(renaming);
+ const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
const [name, setName] = useState("");
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
@@ -305,11 +305,11 @@ export default function PlexCollectionView({
onBlur={doRename}
onKeyDown={(e) => {
if (e.key === "Enter") {
- renameFocus.arm(e);
+ armRenameReturn(e);
void doRename(); // reports its own failure
}
if (e.key === "Escape") {
- renameFocus.arm(e);
+ armRenameReturn(e);
setRenaming(false);
}
}}
@@ -371,7 +371,7 @@ export default function PlexCollectionView({
{canEdit && (
<>
{
setName(d.title);
setRenaming(true);
diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx
index 6d34f24f..a41aa363 100644
--- a/frontend/src/components/PlexPlayer.tsx
+++ b/frontend/src/components/PlexPlayer.tsx
@@ -325,7 +325,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
fitHeight: true,
openOnArrowKey: false,
});
- const tracksFade = useScrollFade();
+ const { ref: tracksFadeRef, style: tracksFadeStyle } = useScrollFade();
// The ⋯ is both the menu's trigger and the tuning panel's dismiss exception and focus return.
const setMoreReference = morePop.refs.setReference;
const moreTriggerRef = useCallback(
@@ -1783,10 +1783,10 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
{/* Portaled, but a React child of the player: a click or a wheel here must not
reach the stage's play-toggle and volume handlers. */}
e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
- style={{ ...tracksFade.style, maxHeight: "min(60dvh, var(--fit-max-h))" }}
+ style={{ ...tracksFadeStyle, maxHeight: "min(60dvh, var(--fit-max-h))" }}
className="overflow-auto no-scrollbar p-2"
>
{detail.audio_streams.length > 1 && (
diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx
index dfedfa46..cd3ddc35 100644
--- a/frontend/src/components/PlexPlaylistAdd.tsx
+++ b/frontend/src/components/PlexPlaylistAdd.tsx
@@ -30,7 +30,7 @@ export default function PlexPlaylistAdd({
onClose: () => void;
}) {
const { t } = useTranslation();
- const fade = useScrollFade();
+ const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const qc = useQueryClient();
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
@@ -141,8 +141,8 @@ export default function PlexPlaylistAdd({
{listQ.isLoading ? (
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx
index e8af6374..5d1bf1a4 100644
--- a/frontend/src/components/PlexPlaylistView.tsx
+++ b/frontend/src/components/PlexPlaylistView.tsx
@@ -416,7 +416,7 @@ export default function PlexPlaylistView({
const qc = useQueryClient();
const confirm = useConfirm();
const [renaming, setRenaming] = useState(false);
- const renameFocus = useReturnFocus(renaming);
+ const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
const [name, setName] = useState("");
const [layout, setLayout] = useState
(() =>
getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion",
@@ -741,12 +741,12 @@ export default function PlexPlaylistView({
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
- renameFocus.arm(e);
+ armRenameReturn(e);
void rename(); // reports its own failure
}
// Unmounting the field fires no blur, so Escape really does cancel.
if (e.key === "Escape") {
- renameFocus.arm(e);
+ armRenameReturn(e);
setRenaming(false);
}
}}
@@ -783,7 +783,7 @@ export default function PlexPlaylistView({
{/* Rename and delete — the playlist's own, rarer actions — sit in its "…" (E28 C1); a
rename opened from there hands the focus back to it. */}
@@ -42,7 +43,7 @@ export default function SavedViewsPopover({
open={open}
icon={icon}
trailing={trailing}
- ref={pop.refs.setReference}
+ ref={setReference}
{...pop.getReferenceProps()}
aria-label={label}
>
@@ -51,7 +52,7 @@ export default function SavedViewsPopover({
) : (
diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx
index 19c323e9..d3efc4a7 100644
--- a/frontend/src/components/SyncStatus.tsx
+++ b/frontend/src/components/SyncStatus.tsx
@@ -38,6 +38,7 @@ export default function SyncStatus({
placement: "bottom-end",
offset: 8,
});
+ const { setReference } = pop.refs;
const { data } = useQuery({
queryKey: qk.myStatus(),
queryFn: api.myStatus,
@@ -122,7 +123,7 @@ export default function SyncStatus({
return (
))}
{hidden > 0 && (
-
+
+{hidden}
)}
diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx
index 37e136ec..80ad4f74 100644
--- a/frontend/src/components/TagManager.tsx
+++ b/frontend/src/components/TagManager.tsx
@@ -38,7 +38,7 @@ function TagRow({
const { t } = useTranslation();
const [name, setName] = useState(tag.name);
const [open, setOpen] = useState(false);
- const popFade = useScrollFade();
+ const { ref: popFadeRef, style: popFadeStyle } = useScrollFade();
const commit = () => {
const v = name.trim();
if (v && v !== tag.name) onRename(v);
@@ -52,6 +52,7 @@ function TagRow({
offset: 8,
hoverDelay: { open: 0, close: 150 },
});
+ const { setReference } = pop.refs;
return (
e.key === "Enter" && (e.target as HTMLInputElement).blur()}
/>
{/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
{channels.map((ch) => (
@@ -111,7 +112,7 @@ export default function TagManager({
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
- const listFade = useScrollFade();
+ const { ref: listFadeRef, style: listFadeStyle } = useScrollFade();
const [newName, setNewName] = useState("");
const tagsQuery = useTags();
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
@@ -168,8 +169,8 @@ export default function TagManager({
) : (
// Cap at ~8 rows tall, then scroll — the list can grow long.
{userTags.map((tag) => (
diff --git a/frontend/src/components/UpNextCard.tsx b/frontend/src/components/UpNextCard.tsx
index b169125a..d43d07c0 100644
--- a/frontend/src/components/UpNextCard.tsx
+++ b/frontend/src/components/UpNextCard.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useEffectEvent, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Play, X } from "lucide-react";
@@ -32,10 +32,9 @@ export function UpNextCard({
});
const next = nextQ.data;
- // Countdown. `onAdvance` is read through a ref so the 100ms interval never restarts on re-render.
+ // Countdown. `onAdvance` is an effect event so the 100ms interval never restarts on re-render.
const [remaining, setRemaining] = useState(COUNTDOWN_S);
- const advanceRef = useRef(onAdvance);
- advanceRef.current = onAdvance;
+ const advance = useEffectEvent(onAdvance);
useEffect(() => {
const started = performance.now();
const iv = window.setInterval(() => {
@@ -43,7 +42,7 @@ export function UpNextCard({
if (left <= 0) {
window.clearInterval(iv);
setRemaining(0);
- advanceRef.current();
+ advance();
} else {
setRemaining(left);
}
diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx
index a906cfb9..a619772f 100644
--- a/frontend/src/components/VideoEditor.tsx
+++ b/frontend/src/components/VideoEditor.tsx
@@ -189,7 +189,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// --- timeline drag (seek / boundary) + hover preview ---
const [drag, setDrag] = useState
(null);
- const [hoverX, setHoverX] = useState(null);
+ // The hover point, measured in the pointer handler (track-relative x and its time), so render never
+ // reads the track's geometry (a DOM read in render made the React Compiler skip the editor, E32).
+ const [hover, setHover] = useState<{ x: number; time: number } | null>(null);
const timeAt = useCallback(
(clientX: number) => {
const el = trackRef.current;
@@ -273,19 +275,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
};
};
const stripCells = strip && trackW ? Math.max(1, Math.floor(trackW / (44 * aspect))) : 0;
- const hoverTime = hoverX != null ? timeAt(hoverX) : null;
+ const hoverTime = hover?.time ?? null;
// Clamp the hover-scrub popover inside the track so it never overflows the modal (which would
// add a horizontal scrollbar): center it on the cursor, but keep both edges within [0, trackW].
const HOVER_W = 148;
const hoverLeft =
- hoverX != null
- ? Math.max(
- 0,
- Math.min(
- Math.max(0, (trackW || HOVER_W) - HOVER_W),
- hoverX - (trackRef.current?.getBoundingClientRect().left ?? 0) - HOVER_W / 2,
- ),
- )
+ hover != null
+ ? Math.max(0, Math.min(Math.max(0, (trackW || HOVER_W) - HOVER_W), hover.x - HOVER_W / 2))
: 0;
// --- create ---
@@ -465,8 +461,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
setDrag({ type: "seek" });
seek(timeAt(e.clientX));
}}
- onPointerMove={(e) => setHoverX(e.clientX)}
- onPointerLeave={() => setHoverX(null)}
+ onPointerMove={(e) =>
+ setHover({
+ x: e.clientX - e.currentTarget.getBoundingClientRect().left,
+ time: timeAt(e.clientX),
+ })
+ }
+ onPointerLeave={() => setHover(null)}
>
{/* aspect-correct filmstrip */}
{stripCells > 0 && (
diff --git a/frontend/src/components/ViewSwitcher.tsx b/frontend/src/components/ViewSwitcher.tsx
index 1973248c..8148edd1 100644
--- a/frontend/src/components/ViewSwitcher.tsx
+++ b/frontend/src/components/ViewSwitcher.tsx
@@ -56,6 +56,8 @@ export default function ViewSwitcher({
selectedIndex: currentIdx,
});
+ const { setReference } = pop.refs;
+
const current = options[currentIdx];
if (!current) return null;
const CurrentIcon = current.icon;
@@ -66,7 +68,7 @@ export default function ViewSwitcher({
context="toolbar"
open={open}
icon={ }
- ref={pop.refs.setReference}
+ ref={setReference}
{...pop.getReferenceProps()}
// The aria-label carries the mode's name at every size — the visible text below is a
// nicety for wide windows, not the accessible name.
diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx
index a195c662..ed49ecd0 100644
--- a/frontend/src/components/Welcome.tsx
+++ b/frontend/src/components/Welcome.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useHistorySubview } from "../lib/history";
import { useTranslation } from "react-i18next";
import {
@@ -270,9 +270,8 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
// `useDialogChrome` and its one counted scroll lock.
const panelRef = useRef(null);
useDialogChrome(panelRef);
- // Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal).
- const onCloseRef = useRef(onClose);
- onCloseRef.current = onClose;
+ // An effect event, so the stack id is assigned once per mount (as in ui/Modal).
+ const close = useEffectEvent(onClose);
useEffect(() => {
// Round 6 gave this shell the trap and the restore but not the LAYER MEMBERSHIP the other three
// have, so its Escape and Tab fired from a raw window listener whatever was on top of it. That
@@ -285,7 +284,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
const onKey = (e: KeyboardEvent) => {
if (takeEscape(e, id)) {
e.stopPropagation();
- onCloseRef.current();
+ close();
return;
}
// The trap defers only to a layer that owns the keyboard, not to a hover card under the pointer.
diff --git a/frontend/src/components/YearFilter.tsx b/frontend/src/components/YearFilter.tsx
index 18538a82..97da87f8 100644
--- a/frontend/src/components/YearFilter.tsx
+++ b/frontend/src/components/YearFilter.tsx
@@ -90,6 +90,8 @@ export default function YearFilter({
placement: "bottom-start",
});
+ const { setReference } = pop.refs;
+
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
if (years.length === 0) {
if (yearsQuery.isLoading) {
@@ -124,7 +126,7 @@ export default function YearFilter({
{years.length > quick.length && (
<>
diff --git a/frontend/src/components/compilerCoverage.contract.test.ts b/frontend/src/components/compilerCoverage.contract.test.ts
index 3950bdbe..fdc0a0d5 100644
--- a/frontend/src/components/compilerCoverage.contract.test.ts
+++ b/frontend/src/components/compilerCoverage.contract.test.ts
@@ -13,94 +13,38 @@ import { compileReport, shippedSources, type CompileReport } from "./compilerSki
// • a NEW skip (or a known one changing category) — fix the function; if it truly cannot be
// compiled, adding it here is a decision for the owner, with the reason in docs/gates.md;
// • a skip that is GONE — delete its entry, so the list only ever shrinks.
-// E32 S3+ burns it down: the popover engine (Refs: `getReferenceProps()` / `refs.setReference`
-// passed during render), render-phase ref writes, try/finally (Todo), the two exhaustive-deps
-// suppressions. The end state is IncompatibleLibrary alone (VirtualGrid on TanStack Virtual).
+// E32 burns it down: S3 took it from 93 to 29 (the popover engine's `ref={pop.refs.setReference}`
+// — read through destructuring now — and every render-phase ref write, moved to `useEffectEvent` /
+// `useLatest`). Left: try/finally and other Todo shapes, and the two exhaustive-deps suppressions.
+// The end state is IncompatibleLibrary alone (VirtualGrid on TanStack Virtual).
const KNOWN_SKIPS: CompileReport["skipped"] = {
- "components/AboutCell.tsx": { AboutCell: "Refs" },
- "components/ActiveFilterChips.tsx": { OverflowPopover: "Refs" },
- "components/AddToPlaylist.tsx": { AddToPlaylist: "Refs", MembershipPanel: "Todo" },
- "components/BootGate.tsx": { BootGate: "Refs" },
- "components/ChannelStatsPopover.tsx": { ChannelStatsPopover: "Refs" },
- "components/ChannelTags.tsx": {
- ChannelTagsControl: "Refs",
- SuggestedChipsLane: "Refs",
- TagEditorMenu: "Refs",
- },
- "components/ChannelsProvider.tsx": { ChannelsProvider: "Refs" },
- "components/ChatThread.tsx": { ChatThread: "Refs" },
- "components/ConfigPanel.tsx": { ConfigPanel: "Refs" },
- "components/DateFilterSelect.tsx": { DateFilterSelect: "Refs" },
+ "components/AddToPlaylist.tsx": { MembershipPanel: "Todo" },
+ "components/ConfigPanel.tsx": { ConfigPanel: "Todo" },
"components/DownloadCenter.tsx": { DownloadCenter: "Todo" },
"components/DownloadDialog.tsx": { DownloadDialog: "Todo" },
- "components/Feed.tsx": { Feed: "Immutability" },
- "components/IssueComposer.tsx": { Attachments: "Immutability", ComposerBody: "Refs" },
"components/KeyGate.tsx": { KeyGate: "Todo" },
- "components/LanguageSwitcher.tsx": { LanguageSwitcher: "Refs" },
- "components/Messages.tsx": { ConversationList: "Refs" },
- "components/NavSidebar.tsx": { NavSidebar: "Refs" },
- "components/NavigationProvider.tsx": { NavigationProvider: "Refs" },
- "components/OnboardingWizard.tsx": { OnboardingWizard: "Refs" },
- "components/PersonalViewsModal.tsx": { PersonalViewsModal: "Refs" },
"components/PlayerModal.tsx": { PlayerModal: "Todo" },
- "components/PlaylistLimitsHint.tsx": { PlaylistLimitsHint: "Refs" },
- "components/Playlists.tsx": { Playlists: "Immutability" },
"components/PlaylistsRail.tsx": { PlaylistsRail: "Todo" },
- "components/PlexBrowse.tsx": { PlexBrowse: "Immutability" },
"components/PlexCollectionEditor.tsx": { PlexCollectionEditor: "Todo" },
"components/PlexCollectionView.tsx": { PlexCollectionView: "Todo" },
"components/PlexPlayer.tsx": { PlexPlayer: "Suppression" },
"components/PlexPlaylistAdd.tsx": { PlexPlaylistAdd: "Todo" },
- "components/PlexPlaylistView.tsx": { PlexPlaylistView: "Refs" },
"components/PlexStatsOverlay.tsx": { PlexStatsOverlay: "Todo" },
- "components/SavedViewsPopover.tsx": { SavedViewsPopover: "Refs" },
"components/SavedViewsProvider.tsx": { SavedViewsProvider: "Todo" },
"components/SettingsPanel.tsx": { SignInMethods: "Todo" },
"components/SetupWizard.tsx": { SetupWizard: "Todo" },
- "components/SyncStatus.tsx": { SyncStatus: "Refs" },
- "components/TagFilterChips.tsx": { TagFilterChips: "Refs" },
- "components/TagManager.tsx": { TagManager: "Refs", TagRow: "Refs" },
- "components/UpNextCard.tsx": { UpNextCard: "Refs" },
"components/UploadDialog.tsx": { UploadDialog: "Todo" },
- "components/VideoEditor.tsx": { VideoEditor: "Refs" },
- "components/ViewSwitcher.tsx": { ViewSwitcher: "Refs" },
+ "components/VideoEditor.tsx": { VideoEditor: "Todo" },
"components/WatchPage.tsx": { WatchPage: "Todo", useMediaSession: "Todo" },
- "components/Welcome.tsx": { AuthCard: "Todo", Lightbox: "Refs" },
- "components/YearFilter.tsx": { YearFilter: "Refs" },
+ "components/Welcome.tsx": { AuthCard: "Todo" },
"components/plexViews.tsx": { PlexSeasonView: "Todo", PlexShowView: "Todo" },
- "components/ui/ActionMenu.tsx": { ActionMenu: "Refs" },
- "components/ui/AnchoredPopover.tsx": { PopoverSurfaceInner: "Refs" },
- "components/ui/Combobox.tsx": { Combobox: "Refs" },
- "components/ui/FilterSelect.tsx": { FilterSelect: "Refs" },
- "components/ui/FocusSafeButton.tsx": { FocusSafeButton: "Refs" },
- "components/ui/Modal.tsx": { Modal: "Refs" },
- "components/ui/NavRail.tsx": { NavRailGroup: "Refs" },
- "components/ui/PageScroller.tsx": { PageScroller: "Refs" },
- "components/ui/PriorityStepper.tsx": { PriorityStepper: "Refs" },
- "components/ui/SidePanel.tsx": { SidePanel: "Refs" },
- "components/ui/SortControl.tsx": { SortControl: "Refs" },
- "components/ui/Toaster.tsx": { Toaster: "Globals" },
- "components/ui/Tooltip.tsx": { Tooltip: "Refs" },
"components/ui/VirtualGrid.tsx": { VirtualGrid: "IncompatibleLibrary" },
- "components/ui/anchoredPopoverHooks.ts": { useAnchoredPopover: "Refs" },
- "components/ui/sortableA11y.ts": { useSortableA11y: "Immutability" },
- "components/ui/tableHeader.tsx": { FilterFunnel: "Refs", FilterOptionList: "Refs" },
"components/useDebugRecording.ts": { useDebugRecording: "Todo" },
"components/useDownloadActions.tsx": { useDownloadActions: "Todo" },
"components/usePlaylistActions.tsx": { usePlaylistActions: "Todo" },
"components/usePlexPlaylistEdits.ts": { usePlexPlaylistEdits: "Todo" },
- "lib/history.ts": { useBackToClose: "Refs", useBackToExit: "Refs", useHistorySubview: "Refs" },
- "lib/keymap.ts": { useGlobalKeymap: "Refs" },
- "lib/layerStack.ts": { useLayer: "Refs" },
- "lib/listNav.ts": { useListNav: "Refs" },
- "lib/playerModalHooks.ts": { useYtBigView: "Refs", useYtProgress: "Suppression" },
- "lib/plexPlayerHooks.ts": { useAutoSkip: "Refs", usePlexTracks: "Todo" },
- "lib/useChannelPriority.ts": { useChannelPriority: "Refs" },
- "lib/useChipOverflowFit.ts": { useChipOverflowFit: "Refs" },
- "lib/useDismiss.ts": { useDismiss: "Refs" },
- "lib/usePref.ts": { usePref: "Refs" },
- "lib/useUndoable.ts": { useUndoable: "Refs" },
- "lib/useUrlMirror.ts": { useUrlMirror: "Refs" },
+ "lib/playerModalHooks.ts": { useYtProgress: "Suppression" },
+ "lib/plexPlayerHooks.ts": { usePlexTracks: "Todo" },
};
// The files a regression hurt most: compiled, and they must stay so (a positive check too, so a
@@ -108,6 +52,10 @@ const KNOWN_SKIPS: CompileReport["skipped"] = {
const MUST_COMPILE: Record = {
"components/Channels.tsx": ["Channels"],
"components/ui/DataTable.tsx": ["DataTable"],
+ // Under every button and every popover in the app (E32 S3).
+ "components/ui/FocusSafeButton.tsx": ["FocusSafeButton"],
+ "components/ui/anchoredPopoverHooks.ts": ["useAnchoredPopover"],
+ "components/ui/AnchoredPopover.tsx": ["PopoverSurfaceInner"],
};
type Row = [file: string, fn: string, category: string];
diff --git a/frontend/src/components/ui/ActionMenu.tsx b/frontend/src/components/ui/ActionMenu.tsx
index 72dda344..12b4d3e5 100644
--- a/frontend/src/components/ui/ActionMenu.tsx
+++ b/frontend/src/components/ui/ActionMenu.tsx
@@ -164,10 +164,11 @@ function SubMenu({ item, onClose }: { item: SubmenuItem; onClose: () => void })
// running off the bottom at 768px.
fitHeight: true,
});
+ const { setReference } = pop.refs;
return (
<>
{/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */}
{className ? (
diff --git a/frontend/src/components/ui/AnchoredPopover.tsx b/frontend/src/components/ui/AnchoredPopover.tsx
index 3de99c6c..dc64d06e 100644
--- a/frontend/src/components/ui/AnchoredPopover.tsx
+++ b/frontend/src/components/ui/AnchoredPopover.tsx
@@ -43,6 +43,10 @@ import type { useAnchoredPopover } from "./anchoredPopoverHooks";
// free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and
// typeahead act on the text, not the list — see ChannelTags.
//
+// The trigger takes its ref DESTRUCTURED: `const { setReference } = pop.refs;` then
+// `ref={setReference} {...pop.getReferenceProps()}`. Written `ref={pop.refs.setReference}`, the React
+// Compiler treats the whole `pop` as a ref and skips the component (E32 S3 — 21 call sites did).
+//
// S? adds a "combobox" mode — the ONE right way to build a typeable combobox (a search/filter input
// with a listbox of options below), so a hand-rolled floating-ui engine (the old MultiSelectChips /
// PlexSearchBox pattern) is no longer needed. It differs from menu/listbox in exactly the focus model:
@@ -294,6 +298,7 @@ function PopoverSurfaceInner({
elementsRef,
labelsRef,
} = popover;
+ const { setFloating } = refs;
// A node portaled to is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
// opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
@@ -369,7 +374,7 @@ function PopoverSurfaceInner({
const surface = (
{
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
});
+ it("names a chip by a label that arrives with later options, in that same render", () => {
+ // E32 S3: the label cache was a ref an effect filled AFTER the render that needed it, and nothing
+ // re-rendered afterwards — the chip kept showing the raw id until something unrelated did.
+ const view = (options: { value: string; label?: string }[]) => (
+
+
+
+ );
+ const { rerender } = render(view([]));
+ expect(screen.getByText("id1")).toBeTruthy();
+ rerender(view([{ value: "id1", label: "Harry Potter" }]));
+ expect(screen.queryByText("id1")).toBeNull();
+ expect(screen.getByText("Harry Potter")).toBeTruthy();
+ // …and it outlives the option: a narrower list later still names the chip.
+ rerender(view([]));
+ expect(screen.getByText("Harry Potter")).toBeTruthy();
+ });
+
it("single-select: picking replaces the value and closes the list", () => {
const onChange = vi.fn();
wrap(
diff --git a/frontend/src/components/ui/Combobox.tsx b/frontend/src/components/ui/Combobox.tsx
index 8019acbf..a6ce688e 100644
--- a/frontend/src/components/ui/Combobox.tsx
+++ b/frontend/src/components/ui/Combobox.tsx
@@ -1,5 +1,5 @@
import clsx from "clsx";
-import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { Button } from "./Button";
@@ -78,6 +78,23 @@ export interface ComboboxProps {
labelsPending?: boolean;
}
+/** Remember each value's display label as options flow through, so a selected chip can name itself
+ * even after the option leaves the query-narrowed list (async single-select). State, merged DURING
+ * render: a ref written in an effect was read by the render before it, so a chip could name itself
+ * by its raw value until something else re-rendered (E32). */
+function useLabelCache(options: readonly ComboboxOption[]): Record
{
+ const [labelCache, setLabelCache] = useState>({});
+ const newLabels = options.filter(
+ (o): o is typeof o & { label: string } => o.label != null && labelCache[o.value] !== o.label,
+ );
+ if (newLabels.length)
+ setLabelCache((c) => ({
+ ...c,
+ ...Object.fromEntries(newLabels.map((o) => [o.value, o.label])),
+ }));
+ return labelCache;
+}
+
export default function Combobox({
label,
options,
@@ -108,17 +125,15 @@ export default function Combobox({
if (queryProp === undefined) setQueryState(q);
onQueryChange?.(q);
};
- const inputRef = useRef(null);
- const fade = useScrollFade();
+ // The input node as STATE, not a ref: the handlers below are handed to getReferenceProps during
+ // render, and a ref inside them made the React Compiler skip the whole component (E32).
+ const [inputEl, setInputEl] = useState(null);
+ const { ref: fadeRef, style: fadeStyle } = useScrollFade();
- // Remember each value's display label as options flow through, so a selected chip can name itself
- // even after the option leaves the query-narrowed list (async single-select). Caller-supplied
- // `selectedLabels` win (they survive a page reload where nothing has been fetched yet).
- const labelCache = useRef>({});
- useEffect(() => {
- for (const o of options) if (o.label != null) labelCache.current[o.value] = o.label;
- }, [options]);
- const knownLabel = (v: string) => selectedLabels?.[v] ?? labelCache.current[v];
+ // Caller-supplied `selectedLabels` win over the cache (they survive a page reload where nothing
+ // has been fetched yet).
+ const labelCache = useLabelCache(options);
+ const knownLabel = (v: string) => selectedLabels?.[v] ?? labelCache[v];
const labelFor = (v: string) => knownLabel(v) ?? v;
const setOpenAnd = (next: boolean) => {
@@ -134,18 +149,19 @@ export default function Combobox({
if (!next) setQuery("");
};
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
+ const { setReference, setPositionReference } = pop.refs;
// The input is the combobox reference for floating-ui AND our own focus target (the field's
// click-to-focus handler, and commit() re-focusing after a pick). A merged callback ref feeds the
- // node to BOTH — without it `inputRef` stays null, so the field's onMouseDown guard (`e.target !==
- // inputRef.current`) is always true and preventDefault() cancels the input's own click-focus, making
+ // node to BOTH — without it the node stays null, so the field's onMouseDown guard (`e.target !==
+ // inputEl`) is always true and preventDefault() cancels the input's own click-focus, making
// the field impossible to type into with the mouse. Memoised so floating-ui's ref isn't re-attached
// every render.
const setInputRef = useCallback(
(node: HTMLInputElement | null) => {
- pop.refs.setReference(node);
- inputRef.current = node;
+ setReference(node);
+ setInputEl(node);
},
- [pop.refs],
+ [setReference],
);
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
@@ -173,7 +189,7 @@ export default function Combobox({
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
);
// Keep the field focused so the user can keep picking / typing; the list stays open.
- inputRef.current?.focus();
+ inputEl?.focus();
} else {
// Single-select is final: close and DON'T re-focus — a refocus fires onFocus, which would
// immediately re-open the list (now that the merged ref makes focus() actually work).
@@ -187,7 +203,7 @@ export default function Combobox({
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. The whole
box is the POSITION anchor (the panel matches its width); the input is the combobox reference. */}
{
// A press anywhere but the input (padding / a chip gap) focuses the field and opens the list —
// focus drives "open", so a caret click never toggles it shut. A press on a chip's × is its
@@ -197,9 +213,9 @@ export default function Combobox({
e.preventDefault();
return;
}
- if (e.target !== inputRef.current) {
+ if (e.target !== inputEl) {
e.preventDefault();
- inputRef.current?.focus();
+ inputEl?.focus();
setOpenAnd(true);
}
}}
@@ -277,7 +293,7 @@ export default function Combobox({
className={`${SURFACE.menu} w-full animate-[popIn_0.16s_ease]`}
>
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
-
+
{/* The listbox holds the options alone; the lines about the list sit beside it (F4). */}
{!showError &&
diff --git a/frontend/src/components/ui/FilterSelect.tsx b/frontend/src/components/ui/FilterSelect.tsx
index f3ad40a9..1765812c 100644
--- a/frontend/src/components/ui/FilterSelect.tsx
+++ b/frontend/src/components/ui/FilterSelect.tsx
@@ -64,6 +64,7 @@ export default function FilterSelect({
options.findIndex((o) => o.value === selected[0]),
),
});
+ const { setReference } = pop.refs;
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
// Summarise the trigger value. Multi lists the first chosen (in options order, so it's stable) + "+N".
@@ -96,7 +97,7 @@ export default function FilterSelect({
context="toolbar"
open={open}
label={hideLabel ? undefined : label}
- ref={pop.refs.setReference}
+ ref={setReference}
{...pop.getReferenceProps()}
data-testid={testId}
// Carry the current value in the accessible name (not just the group label), matching
diff --git a/frontend/src/components/ui/FocusSafeButton.tsx b/frontend/src/components/ui/FocusSafeButton.tsx
index b40099e8..6e22edcc 100644
--- a/frontend/src/components/ui/FocusSafeButton.tsx
+++ b/frontend/src/components/ui/FocusSafeButton.tsx
@@ -1,4 +1,11 @@
-import { forwardRef, useCallback, useRef, useState, type ButtonHTMLAttributes } from "react";
+import {
+ forwardRef,
+ useCallback,
+ useRef,
+ useState,
+ useSyncExternalStore,
+ type ButtonHTMLAttributes,
+} from "react";
import clsx from "clsx";
import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
@@ -23,6 +30,8 @@ import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
// unless it is `user-select: text`, and then the mouseup that ends the drag still clicks the
// button (both measured, 2026-09-26), so the flag sets both halves: the text selects, and the click
// that ends a selection inside the button, or a press dragged past the slop, does nothing.
+const noSubscription = () => () => {};
+
export const FocusSafeButton = forwardRef<
HTMLButtonElement,
ButtonHTMLAttributes & {
@@ -58,9 +67,15 @@ export const FocusSafeButton = forwardRef<
);
// Focused by the event OR by the document's own answer at render time: when the window itself is
// not focused (another app in front) `.focus()` moves `activeElement` but fires no focus event
- // (measured in Chrome), and the button disabled in that state lost the focus all the same.
- const focused =
- focusEvent || (!!node.current && node.current.ownerDocument.activeElement === node.current);
+ // (measured in Chrome), and the button disabled in that state lost the focus all the same. Read
+ // through useSyncExternalStore, which React asks on EVERY render, never from a memo: a bare
+ // render-time DOM read made the React Compiler skip the component (E32), and compiled it would be
+ // cached. Nothing to subscribe to — the focus events above already re-render it.
+ const activeNow = useSyncExternalStore(
+ noSubscription,
+ () => node.current !== null && node.current.ownerDocument.activeElement === node.current,
+ );
+ const focused = focusEvent || activeNow;
const soft = busy || (!!disabled && focused);
// A caller's own aria-disabled is the "unavailable, and here is why" state (DESIGN.md: the tooltip or
// label carries the reason, so the button stays focusable): it is kept, never overwritten by ours, and
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx
index 31882805..c0fa8009 100644
--- a/frontend/src/components/ui/Modal.tsx
+++ b/frontend/src/components/ui/Modal.tsx
@@ -2,6 +2,7 @@ import {
createContext,
useContext,
useEffect,
+ useEffectEvent,
useId,
useRef,
useState,
@@ -124,15 +125,14 @@ export default function Modal({
// the backdrop wrongly dismisses — the click event bubbles to the common ancestor (the backdrop).
const pressedOnBackdrop = useRef(false);
- // Keep a stable handler across renders so the stack id is assigned once per mount.
- const onCloseRef = useRef(onClose);
- onCloseRef.current = onClose;
+ // An effect event, so a new handler each render never re-registers: the stack id is assigned once per mount.
+ const close = useEffectEvent(onClose);
useEffect(() => {
const id = pushLayer();
const onKey = (e: KeyboardEvent) => {
if (takeEscape(e, id)) {
e.stopPropagation();
- onCloseRef.current();
+ close();
return;
}
// Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert
diff --git a/frontend/src/components/ui/NavRail.tsx b/frontend/src/components/ui/NavRail.tsx
index 17446264..d9c1dd2e 100644
--- a/frontend/src/components/ui/NavRail.tsx
+++ b/frontend/src/components/ui/NavRail.tsx
@@ -216,12 +216,13 @@ export function NavRailGroup({
placement: "right-start",
offset: 8,
});
+ const { setReference } = pop.refs;
const active = pages.some((p) => p.active);
return (
<>
n + 1);
return () => setStickyTops((n) => n - 1);
}, []);
- const fade = useScrollFade(20, stickyTops === 0);
+ const { ref: fadeRef, style: fadeStyle } = useScrollFade(20, stickyTops === 0);
const fabUp = useBackToTopShown();
- const fadeRef = fade.ref;
const elRef = useRef(null);
// Stable identity: an inline ref callback changes every render, which makes React detach and
- // re-attach the ref each commit — and since fade.ref is a state setter, that re-attach loops it
+ // re-attach the ref each commit — and since fadeRef is a state setter, that re-attach loops it
// into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding
// once here is safe.
const setRef = useCallback(
@@ -150,29 +156,31 @@ export default function PageScroller({
// all) leaves the focus in its slot rather than on — see useFocusKeeper.
useFocusKeeper(elRef);
- // Live scrollKey for the event/nav-bus callbacks (which are bound once, not per scrollKey).
- const scrollKeyRef = useRef(scrollKey);
- scrollKeyRef.current = scrollKey;
const lastComposite = useRef(null);
const cancelRef = useRef<() => void>(() => {});
// Restore (or reset to top) whenever the (history entry + content) identity changes — a page/sub-view
// navigation, a Back/Forward, or a feed filter change. Idempotent: a no-op when the composite is
- // unchanged, so it's safe to call from several triggers.
- const resolve = useCallback(() => {
+ // unchanged, so it's safe to call from several triggers. An effect event: the listeners below are
+ // bound once, not per scrollKey, and still read the live one.
+ const resolve = useEffectEvent(() => {
const el = elRef.current;
- if (!el || scrollKeyRef.current == null) return;
- const key = `${ensureNavId()}:${scrollKeyRef.current}`;
+ if (!el || scrollKey == null) return;
+ const key = `${ensureNavId()}:${scrollKey}`;
if (key === lastComposite.current) return;
lastComposite.current = key;
cancelRef.current();
cancelRef.current = restoreScroll(el, savedScroll.get(key) ?? 0);
- }, []);
+ });
+ const remember = useEffectEvent((el: HTMLElement) => {
+ if (scrollKey == null) return;
+ rememberScroll(`${peekNavId()}:${scrollKey}`, el.scrollTop);
+ });
// Trigger 1: mount + a content-key change (page switch, feed filter) — both re-render this component.
useLayoutEffect(() => {
resolve();
- }, [scrollKey, resolve]);
+ }, [scrollKey]);
// Trigger 2: Back/Forward (popstate) and a Plex sub-view open (nav bus) — neither necessarily
// re-renders this component. Also records the position continuously (not at unmount): the scroller's
@@ -180,20 +188,18 @@ export default function PageScroller({
useLayoutEffect(() => {
const el = elRef.current;
if (!el) return;
- const onScroll = () => {
- if (scrollKeyRef.current == null) return;
- rememberScroll(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
- };
+ const onScroll = () => remember(el);
+ const onNav = () => resolve();
el.addEventListener("scroll", onScroll, { passive: true });
- window.addEventListener("popstate", resolve);
- const unsubNav = subscribeNav(resolve);
+ window.addEventListener("popstate", onNav);
+ const unsubNav = subscribeNav(onNav);
return () => {
cancelRef.current();
el.removeEventListener("scroll", onScroll);
- window.removeEventListener("popstate", resolve);
+ window.removeEventListener("popstate", onNav);
unsubNav();
};
- }, [resolve]);
+ }, []);
return (
// `id` + `tabIndex={-1}`: the skip link's target (ui/SkipLink) takes the focus, so the next Tab
@@ -204,7 +210,7 @@ export default function PageScroller({
id={CONTENT_ID}
tabIndex={-1}
{...FOCUS_REST_ATTRS}
- style={fade.style}
+ style={fadeStyle}
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable focus:outline-none"
>
{children}
diff --git a/frontend/src/components/ui/PriorityStepper.tsx b/frontend/src/components/ui/PriorityStepper.tsx
index 49de5502..c55cea09 100644
--- a/frontend/src/components/ui/PriorityStepper.tsx
+++ b/frontend/src/components/ui/PriorityStepper.tsx
@@ -121,7 +121,7 @@ export function PriorityStepper({
onPriority(delta);
};
- const editFocus = useReturnFocus(editing);
+ const { target: editTarget, arm: armEditReturn } = useReturnFocus(editing);
const commitEdit = () => {
const parsed = parseInt(draft, 10);
if (!Number.isNaN(parsed) && parsed !== priority) onSet(parsed);
@@ -162,7 +162,7 @@ export function PriorityStepper({
} ${cls.value}`}
>
{
if (e.key === "Enter") {
- editFocus.arm(e);
+ armEditReturn(e);
commitEdit();
} else if (e.key === "Escape") {
- editFocus.arm(e);
+ armEditReturn(e);
setEditing(false);
}
}}
diff --git a/frontend/src/components/ui/SidePanel.tsx b/frontend/src/components/ui/SidePanel.tsx
index 726e77b1..6f30301a 100644
--- a/frontend/src/components/ui/SidePanel.tsx
+++ b/frontend/src/components/ui/SidePanel.tsx
@@ -66,7 +66,7 @@ export default function SidePanel({
const { t } = useTranslation();
const cardRef = useRef(null);
const tabRef = useRef(null);
- const fade = useScrollFade();
+ const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const peek = useHoverFocusWithin();
const drawerMode = useMediaQuery(DRAWER_QUERY, false);
const pinRoom = useMediaQuery(PIN_ROOM_QUERY, true);
@@ -234,8 +234,8 @@ export default function SidePanel({
)}
{body}
diff --git a/frontend/src/components/ui/SortControl.tsx b/frontend/src/components/ui/SortControl.tsx
index 92836d41..7a842533 100644
--- a/frontend/src/components/ui/SortControl.tsx
+++ b/frontend/src/components/ui/SortControl.tsx
@@ -48,6 +48,7 @@ export default function SortControl({
mode: "menu",
placement: "bottom-end",
});
+ const { setReference } = pop.refs;
// A stored sort can name an option this surface no longer offers; show it as unsorted rather than
// letting an arrow claim a sort that `sortRows`/the server won't actually apply.
@@ -71,7 +72,7 @@ export default function SortControl({
context="toolbar"
open={open}
label={label}
- ref={pop.refs.setReference}
+ ref={setReference}
{...pop.getReferenceProps()}
// Carry the active sort key AND direction in the accessible name (the visible arrow is
// aria-hidden), matching ViewSwitcher — a screen reader announces "Sort: Date, Descending",
diff --git a/frontend/src/components/ui/Toaster.dom.test.tsx b/frontend/src/components/ui/Toaster.dom.test.tsx
index e5fab134..b42508ea 100644
--- a/frontend/src/components/ui/Toaster.dom.test.tsx
+++ b/frontend/src/components/ui/Toaster.dom.test.tsx
@@ -94,6 +94,18 @@ describe("Toaster — handing the toasts to another host", () => {
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
});
+ it("takes the inherited set ONCE, at the takeover: a toast of its own stays an alert", () => {
+ // E32: the handover is a snapshot made when the host becomes top, not a read of what the top
+ // host last showed — re-read on every render, this host's own "first" turned silent the moment
+ // "second" arrived (its own screen had become the handover by then).
+ render(
);
+ act(() => void notify({ level: "error", message: "first" }));
+ act(() => void notify({ level: "error", message: "second" }));
+ const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
+ expect(card("first").getAttribute("role")).toBe("alert");
+ expect(card("second").getAttribute("role")).toBe("alert");
+ });
+
it("keeps its live region off while the inherited toasts go in, and arms it a task later", async () => {
// The other half of "silently": a region that is already polite when the inherited nodes are
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx
index c03bacd6..bcd8a8e4 100644
--- a/frontend/src/components/ui/Toaster.tsx
+++ b/frontend/src/components/ui/Toaster.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
+import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
@@ -70,30 +70,29 @@ export default function Toaster({
// the takeover render. Reading the store live also silenced a toast RAISED in the same commit as
// the takeover: a dialog that notifies and closes in one handler (UploadDialog) put its success
// toast up in the very batch that handed the app host the top spot, so it was classified as
- // inherited and announced by neither path (deep-review round 5). Written during render on purpose
- // — the handover must be readable by the taking-over host in the SAME commit, and an effect
- // cleanup runs one phase too late; only the host that IS top writes, so the value is the losing
- // host's last screen either way.
+ // inherited and announced by neither path (deep-review round 5). The top host records its screen
+ // after each commit (the layout effect below); a host taking over reads it in a LATER render, after
+ // that commit, so it always sees the outgoing host's last committed screen. (It was written during
+ // render, which made the React Compiler skip the Toaster — E32 — and let a discarded render
+ // overwrite it.)
//
- // Snapshotted through a ref keyed on `isTop`, not a `useMemo`: React re-invokes a component body
- // — and every `useMemo` factory in it — on a StrictMode double render and on a restarted one, and
- // the LAST invocation is what commits. With a memo the second pass re-read `lastTopIds` after the
- // first pass had already overwritten it on the line below, so the takeover host classified its
- // OWN new toast as inherited and the dev build behaved exactly like the code this replaced
- // (measured in jsdom: role "status" without StrictMode, null with it — round 6). The ref only
- // recomputes when `isTop` actually flips, so a repeated invocation of one render is a no-op.
- const handover = useRef<{ top: boolean; ids: ReadonlySet
}>({
+ // Snapshotted into state when `isTop` flips, adjusted DURING render — not a `useMemo`, which React
+ // may recompute (and the compiler is free to): a recompute while top would re-read this host's OWN
+ // screen and silence its next toast. Under StrictMode the double invocation reads the same,
+ // unchanged global, so both passes agree (the round-6 failure was a render-phase WRITE racing it).
+ const [handover, setHandover] = useState<{ top: boolean; ids: ReadonlySet }>({
top: false,
ids: EMPTY_IDS,
});
- if (handover.current.top !== isTop)
- handover.current = { top: isTop, ids: new Set(isTop ? lastTopIds : []) };
- const inherited = handover.current.ids;
- // AFTER the snapshot above has read the outgoing host's screen — this host's own first render as
- // top would otherwise overwrite the handover with the toasts it is about to classify. Writing the
- // same value twice is harmless; that is the point of the ref.
- // eslint-disable-next-line react-hooks/globals -- the handover between toast hosts is module-level BY DESIGN and must be written during render — see the 12 lines above, which record why a useMemo produced the wrong answer under StrictMode. The write is idempotent, which is what makes a repeated render invocation safe.
- if (isTop) lastTopIds = toasts.map((n) => n.id);
+ let inherited = handover.ids;
+ if (handover.top !== isTop) {
+ const next = { top: isTop, ids: new Set(isTop ? lastTopIds : []) };
+ setHandover(next);
+ inherited = next.ids;
+ }
+ useLayoutEffect(() => {
+ if (isTop) lastTopIds = toasts.map((n) => n.id);
+ });
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!isTop) {
diff --git a/frontend/src/components/ui/Tooltip.tsx b/frontend/src/components/ui/Tooltip.tsx
index 4e3789f0..68408328 100644
--- a/frontend/src/components/ui/Tooltip.tsx
+++ b/frontend/src/components/ui/Tooltip.tsx
@@ -38,12 +38,13 @@ export default function Tooltip({
// rest-before-show for labels that shouldn't flash on a fast sweep across an icon row.
hoverDelay: { open: openDelay, close: 0 },
});
+ const { setReference } = pop.refs;
if (!enabled || !hint) return <>{children}>;
return (
<>
-
+
{children}
{/* Passive caption: it must not swallow a click aimed at a neighbouring control that it
diff --git a/frontend/src/components/ui/anchoredPopoverHooks.ts b/frontend/src/components/ui/anchoredPopoverHooks.ts
index 909bcae8..eb5e0eef 100644
--- a/frontend/src/components/ui/anchoredPopoverHooks.ts
+++ b/frontend/src/components/ui/anchoredPopoverHooks.ts
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import {
useFloating,
autoUpdate,
@@ -256,12 +256,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
setActiveIndex(to);
setHoverIndex(null);
};
- const pageKeyRef = useRef(onPageKey);
- pageKeyRef.current = onPageKey;
- const paging = useMemo(() => {
- const onKeyDown = (e: React.KeyboardEvent) => pageKeyRef.current(e);
- return { reference: { onKeyDown }, floating: { onKeyDown } };
- }, []);
+ // The compiler memoizes this on what onPageKey reads (the active row), so the prop getters below
+ // change only when that does.
+ const paging = { reference: { onKeyDown: onPageKey }, floating: { onKeyDown: onPageKey } };
const interactions = useInteractions([
click,
diff --git a/frontend/src/components/ui/sortableA11y.ts b/frontend/src/components/ui/sortableA11y.ts
index 15f721b1..33077ae2 100644
--- a/frontend/src/components/ui/sortableA11y.ts
+++ b/frontend/src/components/ui/sortableA11y.ts
@@ -1,4 +1,5 @@
-import { useMemo, useRef } from "react";
+import { useMemo } from "react";
+import { useLatest } from "../../lib/useLatest";
import { useTranslation } from "react-i18next";
import { closestCenter } from "@dnd-kit/core";
import type {
@@ -38,8 +39,7 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
announcements: Announcements;
} {
const { t } = useTranslation();
- const latest = useRef(describe);
- latest.current = describe;
+ const latest = useLatest(describe);
return useMemo(() => {
// Spread into a fresh object: i18next's options type is an index signature, which a named
// interface does not satisfy.
@@ -51,22 +51,21 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
// dnd-kit reports the item over ITSELF right after the pick-up, in the same tick — announced,
// "moved to position 1" replaced "picked up … use the arrow keys" in the live region before a
// screen reader read it (measured in Chrome). Only a real move is announced as one.
- let moved = false;
+ // Per-DRAG state the announcement closures share (a component-lifetime ref would be the wrong
+ // lifetime): an object owned by this memo, so the closures mutate a property, never a binding.
+ const drag = { moved: false };
return {
screenReaderInstructions: { draggable: t("common.dnd.instructions") },
announcements: {
onDragStart: ({ active }) => {
- moved = false;
+ drag.moved = false;
return t("common.dnd.pickedUp", at(active.id));
},
onDragOver: ({ active, over }) => {
- if (!over || (!moved && over.id === active.id)) return undefined;
- // `moved` is per-DRAG state, and the announcement closures capturing it is the point:
+ if (!over || (!drag.moved && over.id === active.id)) return undefined;
// dnd-kit reports the item over ITSELF in the pick-up tick (see above), and this flag is
- // the only thing separating that from a real move. A ref would be the wrong lifetime —
- // one drag, not the component.
- // eslint-disable-next-line react-hooks/immutability -- per-DRAG state the announcement closures share; a ref would live as long as the component
- moved = true;
+ // the only thing separating that from a real move.
+ drag.moved = true;
return t("common.dnd.movedOver", landing(active.id, over.id));
},
onDragEnd: ({ active, over }) =>
@@ -76,7 +75,7 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
onDragCancel: ({ active }) => t("common.dnd.cancelled", at(active.id)),
},
};
- }, [t]);
+ }, [t, latest]);
}
// A context that nests sortable lists (a Plex playlist: its blocks, and each show's episodes) must
diff --git a/frontend/src/components/ui/tableHeader.tsx b/frontend/src/components/ui/tableHeader.tsx
index 2fe6b7a5..379579b4 100644
--- a/frontend/src/components/ui/tableHeader.tsx
+++ b/frontend/src/components/ui/tableHeader.tsx
@@ -29,11 +29,11 @@ import { SURFACE } from "./surfaces";
// The multi-select filter's option list, capped and scrollable. Its own component because it owns a
// hook — see the FilterPopover note below for why nesting either of these would remount them.
function FilterOptionList({ children }: { children: ReactNode }) {
- const fade = useScrollFade();
+ const { ref: fadeRef, style: fadeStyle } = useScrollFade();
return (
{children}
@@ -77,13 +77,14 @@ function FilterFunnel({
mode: isText ? "dialog" : "menu",
role: isText ? "dialog" : "menu",
});
+ const { setReference } = pop.refs;
const filterOn = isActive(value);
return (
<>
(
}
return root;
};
- const readViewRef = useRef(readView);
- readViewRef.current = readView;
const [view, setView] = useState(readView);
+ const readLandedView = useEffectEvent(readView);
useEffect(() => {
// Back/Forward restores the sub-view from the landed entry (state OR its restored ? URL).
- const onPop = () => setView(readViewRef.current());
+ const onPop = () => setView(readLandedView());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
@@ -370,11 +369,10 @@ export function closeTopOverlayThenRun(close: () => void, run: () => void): void
/** While the calling component is mounted, Back (or the browser/mouse back button) closes it via
* `onClose` instead of navigating away. Mount the component only while the overlay is open. */
export function useBackToClose(onClose: () => void): void {
- const cb = useRef(onClose);
- cb.current = onClose;
+ const cb = useEffectEvent(onClose);
useEffect(() => {
ensureOverlayListener();
- const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
+ const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
overlayStack.push(entry);
scheduleReconcile();
return () => {
@@ -394,12 +392,11 @@ export function useBackToClose(onClose: () => void): void {
* `active` false, whose cleanup drops the entry and its history state on the next tick — exactly the
* useBackToClose lifecycle, just gated on a flag. `onExit` runs on a genuine Back while active. */
export function useBackToExit(active: boolean, onExit: () => void): void {
- const cb = useRef(onExit);
- cb.current = onExit;
+ const cb = useEffectEvent(onExit);
useEffect(() => {
if (!active) return;
ensureOverlayListener();
- const entry: Overlay = { token: nextOverlayToken(), cb: () => cb.current() };
+ const entry: Overlay = { token: nextOverlayToken(), cb: () => cb() };
overlayStack.push(entry);
scheduleReconcile();
return () => {
diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts
index 1284b35b..50c1d332 100644
--- a/frontend/src/lib/keymap.ts
+++ b/frontend/src/lib/keymap.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef, type RefObject } from "react";
+import { useEffect, useEffectEvent, type RefObject } from "react";
import { anyBlockingLayer, escapeHandled } from "./layerStack";
import { createStore } from "./store";
import type { Page } from "./urlState";
@@ -483,10 +483,9 @@ export function useGlobalKeymap(
pages: readonly Page[],
): void {
// The page jumps are the global keys whose action lives OUTSIDE this file — navigation is a
- // provider and `lib/` may not import a component — so App hands it in. Kept in a ref so a fresh
+ // provider and `lib/` may not import a component — so App hands it in. An effect event, so a fresh
// closure each render doesn't rebind the window listener (and can't leave a stale `setPage`).
- const navRef = useRef(onNavigate);
- navRef.current = onNavigate;
+ const navigate = useEffectEvent(onNavigate);
useKeyScope("page", enabled);
const pagesKey = pages.join(",");
useEffect(() => {
@@ -506,7 +505,7 @@ export function useGlobalKeymap(
(ev: KeyboardEvent) => {
if (!s.page || !navigablePages.get().includes(s.page)) return;
ev.preventDefault();
- navRef.current(s.page);
+ navigate(s.page);
},
]),
) as { readonly [K in ShortcutId<"page">]: (e: KeyboardEvent) => void };
@@ -529,7 +528,7 @@ export function useGlobalKeymap(
},
home: (ev) => {
ev.preventDefault();
- navRef.current("home");
+ navigate("home");
},
});
if (!handled) dispatch(e, "page", pageHandlers);
diff --git a/frontend/src/lib/layerStack.ts b/frontend/src/lib/layerStack.ts
index a653c91d..0a1ed310 100644
--- a/frontend/src/lib/layerStack.ts
+++ b/frontend/src/lib/layerStack.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useEffectEvent, useRef } from "react";
// R8 S3 — one shared registry of open "dismissable layers" (modals, popovers, players), ordered by
// open time. It replaces the three mutually-unaware Escape systems that each guessed at ordering with
@@ -107,24 +107,21 @@ export function useLayer(
opts?: { passive?: boolean; trapsTab?: boolean },
): { isTopmost: () => boolean; anyAbove: () => boolean; blockingAbove: () => boolean } {
const idRef = useRef(null);
- const escRef = useRef(onEscape);
- escRef.current = onEscape;
- const optsRef = useRef(opts);
- optsRef.current = opts;
+ const escape = useEffectEvent(() => onEscape?.());
+ const hasEscape = onEscape !== undefined;
+ const passive = !!opts?.passive;
+ const trapsTab = opts?.trapsTab;
useEffect(() => {
if (!active) return;
- const id = pushLayer({
- passive: !!optsRef.current?.passive,
- trapsTab: optsRef.current?.trapsTab,
- });
+ const id = pushLayer({ passive, trapsTab });
idRef.current = id;
let onKey: ((e: KeyboardEvent) => void) | undefined;
- if (escRef.current) {
+ if (hasEscape) {
onKey = (e: KeyboardEvent) => {
// takeEscape stamps BEFORE closing, so a `window` listener below (fires after this `document`
// one) sees the Escape as consumed even though closing us pops this layer off the stack.
- if (takeEscape(e, id)) escRef.current?.();
+ if (takeEscape(e, id)) escape();
};
document.addEventListener("keydown", onKey);
}
@@ -133,8 +130,9 @@ export function useLayer(
removeLayer(id);
idRef.current = null;
};
- // Re-register only when open/closed toggles; onEscape is read fresh via escRef.
- }, [active]);
+ // Re-register when open/closed toggles (the shape flags are constant per caller); the Escape
+ // handler is an effect event, so a new `onEscape` each render never re-registers the layer.
+ }, [active, hasEscape, passive, trapsTab]);
return {
isTopmost: () => idRef.current != null && isTopmost(idRef.current),
diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts
index b0a94845..39c6d89c 100644
--- a/frontend/src/lib/listNav.ts
+++ b/frontend/src/lib/listNav.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, type RefObject } from "react";
+import { useEffect, useEffectEvent, useState, type RefObject } from "react";
import { dispatch, useKeyScope } from "./keymap";
import { scrollParent } from "./listPaging";
import { focusIsUnclaimed } from "./useFocusKeeper";
@@ -490,8 +490,8 @@ export function useListNav(
onDelete?: (index: number) => void;
},
): void {
- const optsRef = useRef(opts);
- optsRef.current = opts;
+ // Read when a key is pressed, so the registration below never re-runs for a new `opts` object.
+ const latestOpts = useEffectEvent(() => opts);
// The `list` keys do something only while there is an item to land on. An empty queue, a list still
// loading or one that failed mounts its container all the same, and the cheat sheet listed j/k, the
// arrows and Enter over nothing (keymap: a scope is pushed only while its keys really do something).
@@ -503,10 +503,10 @@ export function useListNav(
if (!opts.enabled || !el) return;
lists.set(el, {
el,
- count: () => optsRef.current.count ?? ownItems(el).length,
- cols: () => optsRef.current.cols ?? measuredCols(el),
- reveal: (i) => optsRef.current.reveal?.(i),
- remove: () => optsRef.current.onDelete ?? null,
+ count: () => latestOpts().count ?? ownItems(el).length,
+ cols: () => latestOpts().cols ?? measuredCols(el),
+ reveal: (i) => latestOpts().reveal?.(i),
+ remove: () => latestOpts().onDelete ?? null,
});
el.setAttribute(LIST, "");
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);
diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts
index 3b6881a1..40baad1a 100644
--- a/frontend/src/lib/playerModalHooks.ts
+++ b/frontend/src/lib/playerModalHooks.ts
@@ -256,7 +256,6 @@ export function useYtBigView({
}): {
isFullscreen: boolean;
maximized: boolean;
- maximizedRef: RefObject;
isFullscreenRef: RefObject;
toggleFullscreen: () => void;
toggleTrueFullscreen: () => void;
@@ -267,8 +266,6 @@ export function useYtBigView({
const [isFullscreen, setIsFullscreen] = useState(false);
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
const [maximized, setMaximized] = useState(false);
- const maximizedRef = useRef(false);
- maximizedRef.current = maximized;
// Mirrored into a ref because the keydown handler is bound once and would otherwise read false forever.
const isFullscreenRef = useRef(false);
// Nested Back: peel fullscreen → maximise → close, one Back at a time (each a history layer only while
@@ -310,7 +307,6 @@ export function useYtBigView({
return {
isFullscreen,
maximized,
- maximizedRef,
isFullscreenRef,
toggleFullscreen,
toggleTrueFullscreen,
diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts
index 1ac466be..e347319e 100644
--- a/frontend/src/lib/plexPlayerHooks.ts
+++ b/frontend/src/lib/plexPlayerHooks.ts
@@ -16,6 +16,7 @@ import {
} from "./fullscreen";
import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys";
+import { useLatest } from "./useLatest";
import i18n, { plexLang } from "../i18n";
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
@@ -458,17 +459,13 @@ export function useAutoSkip(opts: {
seekTo,
videoRef,
} = opts;
- const skipProgressRef = useRef(null);
- const cancelAutoSkipRef = useRef<() => void>(() => {});
-
const activeMarker: PlexMarker | undefined = markers?.find(
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
- const activeMarkerRef = useRef(activeMarker);
- activeMarkerRef.current = activeMarker;
+ const activeMarkerRef = useLatest(activeMarker);
const [skipProgress, setSkipProgress] = useState