diff --git a/CLAUDE.md b/CLAUDE.md index b256535c..1db1015c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,8 @@ worse than no rule — if you change how the code works, change this file in the Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a **modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process -(yt-dlp/ffmpeg), one PO-token sidecar. React 18 + react-query frontend with a router-free navigation -provider. +(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a +router-free navigation provider. ## The gate — never merge or ship red @@ -75,30 +75,34 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory ## Canonical patterns — when you have a choice, pick THIS -| Area | Use | Not | -| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ | -| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` | -| Request body | a Pydantic model | `payload: dict` | -| Request SIZE | nothing — `bodylimit` caps everything (SEC-20) | `capped_body`, except for a LARGER per-route cap | -| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` | -| Admin gate import | `app.auth` | the `app.routes.admin` re-export | -| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) | -| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI | -| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` | -| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) | -| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter | -| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals | -| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers | -| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks | -| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled ` + ) : ( + + + {score.toFixed(1)} + + ); +} + // Multi-source ratings cluster (E5 S5): IMDb (links out) · TMDb (with vote count) · Plex audience — // whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column // (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational. @@ -65,29 +97,17 @@ function RatingsRow({ const primary = imdbRating ?? audience ?? null; // the filterable score const chipCls = "inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500"; - const Score = ({ score, filterable }: { score: number; filterable: boolean }) => - filterable && onFilterRating ? ( - - ) : ( - - - {score.toFixed(1)} - - ); const has = (x?: number | null): x is number => x != null; if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null; return (
{has(imdbRating) && ( - + IMDb {imdbUrl && ( - + {t("plex.info.audience")} )} diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index bc198228..2d513091 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -511,6 +511,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // The self-restart path above reaches the CURRENT loadSession through this, so the recursion is // not a forward reference to a binding that is still being defined. const loadSessionRef = useRef(loadSession); + // eslint-disable-next-line react-hooks/immutability -- the latest-value ref the self-restart path above reads; see the comment there for why the recursion cannot name loadSession directly. loadSessionRef.current = loadSession; // Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state; the shared diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx index 3c8834fc..1195527c 100644 --- a/frontend/src/components/PlexSearchBox.tsx +++ b/frontend/src/components/PlexSearchBox.tsx @@ -48,8 +48,14 @@ export default function PlexSearchBox() { const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : []; const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0); - // A fresh query resets the highlight. - useEffect(() => setHi(-1), [dq, activeField, scope]); + // A fresh query resets the highlight — during THAT render, so no frame ever draws the new + // suggestion list with the old row highlighted. + const suggestKey = `${scope}|${activeField}|${dq}`; + const [hiFor, setHiFor] = useState(suggestKey); + if (hiFor !== suggestKey) { + setHiFor(suggestKey); + setHi(-1); + } // Keep the highlighted option scrolled into view during arrow-key navigation. useEffect(() => { if (hi < 0) return; diff --git a/frontend/src/components/Scheduler.dom.test.tsx b/frontend/src/components/Scheduler.dom.test.tsx new file mode 100644 index 00000000..89d34d87 --- /dev/null +++ b/frontend/src/components/Scheduler.dom.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +// +// The scheduler's two number editors — a job's interval and the maintenance batch size. Both used to +// hold a MIRROR of the server value that an effect re-synced; both now show the server value and keep +// a draft only while the field is being edited (E27 S2). That matters here more than elsewhere +// because this page polls: every few seconds a fresh status arrives, and a mirror is exactly the +// thing a poll can overwrite at the wrong moment. +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); + +import Scheduler from "./Scheduler"; +import { api, type SchedulerStatus } from "../lib/api"; +import { qk } from "../lib/queryKeys"; + +const status = (over: Partial = {}): SchedulerStatus => ({ + running_here: true, + enabled: true, + paused: false, + jobs: [ + { + id: "sync", + interval_minutes: 30, + next_run: null, + running: false, + status: "ok", + last_started: null, + last_finished: null, + last_result: null, + last_error: null, + progress: null, + }, + ], + queue: { + channels_recent_pending: 0, + channels_deep_pending: 0, + deep_eta_seconds: 0, + videos_pending_enrich: 0, + videos_pending_shorts: 0, + videos_live_refresh: 0, + }, + quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 }, + maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 }, + ...over, +}); + +let qc: QueryClient; +let current: SchedulerStatus; + +async function mount(initial = status()) { + current = initial; + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await screen.findByText("scheduler.maintenance.batchLabel"); +} + +/** A poll landing, driven through the query the page actually makes. */ +async function poll(next: SchedulerStatus) { + current = next; + await act(() => qc.refetchQueries({ queryKey: qk.scheduler() })); +} + +beforeEach(() => { + vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current)); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const intervalBox = () => screen.queryByRole("spinbutton", { name: "" }); +const batchBox = () => screen.getByLabelText("scheduler.maintenance.batchLabel"); + +describe("Scheduler · the job interval editor", () => { + it("opens on the current interval and commits a new one", async () => { + const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never); + await mount(); + + fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); + const box = intervalBox()!; + expect(box).toHaveProperty("value", "30"); + + fireEvent.change(box, { target: { value: "45" } }); + fireEvent.keyDown(box, { key: "Enter" }); + expect(intervalBox()).toBeNull(); // committing closes the editor at once + await waitFor(() => expect(update).toHaveBeenCalledWith("sync", 45)); + }); + + it("drops what you typed on Escape and shows the server's value again", async () => { + const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never); + await mount(); + + fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); + fireEvent.change(intervalBox()!, { target: { value: "999" } }); + fireEvent.keyDown(intervalBox()!, { key: "Escape" }); + + expect(update).not.toHaveBeenCalled(); + expect(intervalBox()).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); + expect(intervalBox()).toHaveProperty("value", "30"); + }); + + it("keeps what you are typing when a poll lands underneath", async () => { + await mount(); + fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); + fireEvent.change(intervalBox()!, { target: { value: "45" } }); + + await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 30, running: true }] })); + + expect(intervalBox()).toHaveProperty("value", "45"); + }); + + it("follows the server while the editor is closed", async () => { + await mount(); + await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }] })); + await waitFor(() => + expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(), + ); + + fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ })); + expect(intervalBox()).toHaveProperty("value", "90"); + }); +}); + +describe("Scheduler · the maintenance batch editor", () => { + it("is not dirty until edited, and stops being dirty once saved", async () => { + const save = vi + .spyOn(api, "updateMaintenanceBatch") + .mockResolvedValue({ revalidate_batch: 9000 }); + await mount(); + const saveBtn = () => screen.getByRole("button", { name: "common.save" }); + + expect(saveBtn()).toHaveProperty("disabled", true); + fireEvent.change(batchBox(), { target: { value: "9000" } }); + expect(saveBtn()).toHaveProperty("disabled", false); + + fireEvent.keyDown(batchBox(), { key: "Enter" }); + expect(saveBtn()).toHaveProperty("disabled", true); // the draft is dropped at once + await waitFor(() => expect(save).toHaveBeenCalledWith(9000)); + }); + + it("follows a poll that changes the batch while nothing is being typed", async () => { + await mount(); + expect(batchBox()).toHaveProperty("value", "5000"); + await poll(status({ maintenance: { ...status().maintenance, revalidate_batch: 12000 } })); + await waitFor(() => expect(batchBox()).toHaveProperty("value", "12000")); + }); +}); diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 270d7cb4..5d02ee29 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -139,17 +139,19 @@ function JobRow({ runDisabled: boolean; }) { const { t } = useTranslation(); - const [editing, setEditing] = useState(false); - const [val, setVal] = useState(String(job.interval_minutes)); + // The box shows the server's value and holds a draft only while it is being edited, so there is + // nothing for an effect to re-sync: `null` means "follow the job". The poll behind this page + // refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the + // previous value for a frame. + const [draft, setDraft] = useState(null); + const editing = draft !== null; + const val = draft ?? String(job.interval_minutes); const next = secsUntil(job.next_run); - useEffect(() => { - if (!editing) setVal(String(job.interval_minutes)); - }, [job.interval_minutes, editing]); function save() { const m = parseInt(val, 10); if (Number.isFinite(m) && m >= 1 && m <= 1440 && m !== job.interval_minutes) onSave(m); - setEditing(false); + setDraft(null); } return ( @@ -170,10 +172,10 @@ function JobRow({ max={1440} value={val} autoFocus - onChange={(e) => setVal(e.target.value)} + onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") save(); - if (e.key === "Escape") setEditing(false); + if (e.key === "Escape") setDraft(null); }} className="w-16 bg-surface border border-border rounded-sm px-1.5 py-0.5 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> @@ -187,7 +189,7 @@ function JobRow({