Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 65a79b6e7fbc25851d2e3774e4458ce124a3caaf
parent fd4803007104d6c37c9f13cf9c1d118507e96615
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 14 Jun 2026 00:19:44 -0400

global passive auto-refresh on a configurable interval

Mount one AutoRefresh client component in the root layout that calls
router.refresh() on an interval, so every server-rendered page (and the
shared sidebar badges) reconciles fresh data without a manual reload.

Idle guards: pause while the tab is hidden, refresh once immediately on
return, and skip a tick while a previous refresh is still settling.

Configurable via a new autoRefreshIntervalSeconds setting (default 5s,
clamped 1-600, 0 disables). Replaces the redundant JobsAutoRefresh
(same router.refresh() mechanism); ActiveJobsLive's 1s progress polling
is kept as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/lib/settings.ts | 35+++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Aeditor/app/components/AutoRefresh.tsx | 47+++++++++++++++++++++++++++++++++++++++++++++++
Deditor/app/jobs/components/JobsAutoRefresh.tsx | 18------------------
Meditor/app/jobs/page.tsx | 5-----
Meditor/app/layout.tsx | 4++++
Meditor/app/settings/actions.ts | 22++++++++++++++++++++++
Meditor/app/settings/components/SettingsForm.tsx | 7+++++++
Aeditor/e2e/auto-refresh.spec.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 226 insertions(+), 23 deletions(-)

diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -66,6 +66,11 @@ export type SiteSettings = { // the last report-changing action before regenerating affected channel // reports. See REPORT_DEBOUNCE_PRESETS. Default "fast" (~1s, no cap). reportDebouncePreset: ReportDebouncePreset; + // How often (seconds) the editor UI passively re-fetches the current page's + // server-rendered data via router.refresh(), so sidebar badges and reports + // stay live without a manual reload. Mounted globally; pauses while the tab is + // hidden. 0 disables passive refresh entirely. See AUTO_REFRESH_INTERVAL_*. + autoRefreshIntervalSeconds: number; // Default social links applied to every site that doesn't define its own. // A site inherits these unless its site.json carries an explicit // `socialLinks` array — see Site.socialLinks / resolveSocialLinks in @@ -86,6 +91,12 @@ export const SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS = 10; export const PARALLEL_TRANSCRIPTIONS_MAX = 16; export const PARALLEL_TRANSCRIPTIONS_DEFAULT = 2; +// Passive UI auto-refresh cadence. 0 is a sentinel meaning "disabled"; any other +// value is clamped into [MIN, MAX] seconds. +export const AUTO_REFRESH_INTERVAL_DEFAULT_SECONDS = 5; +export const AUTO_REFRESH_INTERVAL_MIN_SECONDS = 1; +export const AUTO_REFRESH_INTERVAL_MAX_SECONDS = 600; + // Global snapshot-scheduler debounce presets. `debounceMs` is the quiet-period // window after the last report-changing action; `maxWaitMs` caps the total // delay under continuous activity (null = no cap, fire purely on the quiet @@ -126,6 +137,7 @@ function defaults(): SiteSettings { inlineTranscribeOnFallback: false, skipLiveDownloads: true, reportDebouncePreset: DEFAULT_REPORT_DEBOUNCE_PRESET, + autoRefreshIntervalSeconds: AUTO_REFRESH_INTERVAL_DEFAULT_SECONDS, socialLinks: [], }; } @@ -204,6 +216,23 @@ export function clampParallelTranscriptions(value: unknown): number { return n; } +// 0 means "disabled" and is preserved as-is. Anything else is clamped into the +// [MIN, MAX] window; a non-finite value falls back to the default cadence. +export function clampAutoRefreshIntervalSeconds(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return AUTO_REFRESH_INTERVAL_DEFAULT_SECONDS; + } + const n = Math.floor(value); + if (n <= 0) return 0; + if (n < AUTO_REFRESH_INTERVAL_MIN_SECONDS) { + return AUTO_REFRESH_INTERVAL_MIN_SECONDS; + } + if (n > AUTO_REFRESH_INTERVAL_MAX_SECONDS) { + return AUTO_REFRESH_INTERVAL_MAX_SECONDS; + } + return n; +} + export function getSettings(): SiteSettings { const file = getPaths().settingsFile; let parsed: Partial<SiteSettings> = {}; @@ -255,6 +284,9 @@ export function getSettings(): SiteSettings { if (!isReportDebouncePreset(merged.reportDebouncePreset)) { merged.reportDebouncePreset = DEFAULT_REPORT_DEBOUNCE_PRESET; } + merged.autoRefreshIntervalSeconds = clampAutoRefreshIntervalSeconds( + merged.autoRefreshIntervalSeconds, + ); merged.socialLinks = parseSocialLinks(merged.socialLinks); return merged; } @@ -398,6 +430,9 @@ export async function writeSettings(next: SiteSettings): Promise<void> { reportDebouncePreset: isReportDebouncePreset(next.reportDebouncePreset) ? next.reportDebouncePreset : DEFAULT_REPORT_DEBOUNCE_PRESET, + autoRefreshIntervalSeconds: clampAutoRefreshIntervalSeconds( + next.autoRefreshIntervalSeconds, + ), socialLinks, }; const tmp = `${file}.tmp-${process.pid}`; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **The editor refreshes itself on a timer so its data stays live without a manual reload.** Every page now passively re-fetches its own server-rendered data on a configurable interval — so the sidebar badges (active/running job counts, changelog dot), channel reports, and any other on-screen figures keep up to date on their own. It uses Next's `router.refresh()` (the same mechanism the jobs list already used) mounted once globally in the root layout, so it covers every page and the shared sidebar with no per-page wiring. To avoid wasting work when you're not looking, it **pauses entirely while the browser tab is hidden** and does **one immediate refresh the moment you return** to the tab (rather than waiting out the interval); it also skips a tick while a previous refresh is still settling, so refreshes can't pile up. The cadence is set in **Settings → Auto-refresh interval (seconds)**: default **5s** (clamped 1–600), or **0 to disable** passive refresh completely. This replaces the jobs page's old bespoke 2.5s auto-refresh (the `/jobs/active` page keeps its faster 1s progress-bar polling, which animates per-task bars without a full re-render). - **Git worktrees can run in parallel on non-colliding ports (dev tooling).** Two checkouts of the repo (via `git worktree`) can now run their dev servers and e2e suites at the same time without port clashes. A new helper, `scripts/worktree.mjs` (exposed as `pnpm wt`), assigns each worktree a port block offset by `index * 100` based on its position in `git worktree list` — the main worktree keeps the original defaults (editor 3001, test 3011, export 3010/3000/3020), worktree #1 gets 31xx, and so on. `pnpm dev:editor`, `pnpm dev:export`, `pnpm start:export`, and `pnpm e2e` route through `wt run`, which injects the assigned ports, so they "just work" per worktree; the editor/export `package.json` port flags and the Playwright configs now honor these env vars (previously `pnpm dev:test` hardcoded 3011, so a custom `PORT` only moved the URL Playwright waited on, not the server). E2E specs that hit the editor's test API now derive the base URL from `PLAYWRIGHT_BASE_URL` (centralized in `editor/e2e/baseUrl.ts`) instead of hardcoding `localhost:3011`. `pnpm wt add <branch>` creates a sibling worktree pre-seeded with `settings.json` and prints its ports; `--share-data` links it to the main worktree's downloaded `transcripts/` for read-mostly reuse (with an LMDB concurrent-write caveat). See `WORKTREES.md`. - **Channel reports refresh themselves automatically, on a global debounce.** Any action that changes what a channel report (the per-channel snapshot powering the Channels list, `/actionable`, and the channel page) would say now regenerates that report on its own when it finishes — no more manually clicking **Refresh report** after transcribing, transcoding, cleaning audio, checking availability, editing channel config, or the per-video file operations (delete file, set primary transcript, delete dir, mark untranscribable, archive/do-not-clean). Every report-changing action marks its channel "dirty" and re-arms one shared debounce timer; when activity settles, the scheduler regenerates each dirty channel's snapshot in parallel (reusing the existing `refresh-report` job, deduped against any refresh already running) and revalidates the affected pages. This happens **after each completed sub-operation within a batch**, not only when the whole batch finishes — so a long download or transcription run updates its report incrementally as each video lands, rather than staying stale until the end. The debounce coalesces bursts — videos that finish within the same window collapse into a single regen pass instead of one rewrite per operation. The window is configurable in **Settings → Report refresh debounce**: **Fast** (~1s after activity settles, no cap — the default), **Balanced** (~3s, 30s max), or **Lazy** (~10s, 60s max). The download/sync pipeline's previous behavior of regenerating its report inline is removed in favor of this one uniform mechanism (so its report now lags by the debounce window — ~1s by default — rather than being written synchronously). The manual **Refresh report** / **Update all reports** buttons are unchanged. - **Parakeet transcriptions show a per-video ETA.** While a video is being transcribed with the parakeet app, its per-task progress bar on `/jobs/active` now shows an estimate of how long that single video has left (e.g. `segment 3/12 · ETA 6:10`), alongside the existing segment count and percent. The wrapper (`scripts/parakeet-stitch.mjs`) measures each segment's real transcription wall-time and projects the remaining time as **average time per completed segment × remaining segments**, emitting it on its progress lines; the parser surfaces that ETA in the task detail. It appears from the second segment onward (the first segment has no average to project from yet). This is distinct from the batch-level "~4:30 left" estimate across all videos. diff --git a/editor/app/components/AutoRefresh.tsx b/editor/app/components/AutoRefresh.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useRef, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +type Props = { + // Refresh cadence in milliseconds. 0 (or negative) disables passive refresh. + intervalMs: number; +}; + +// Mounted once globally in the root layout. Periodically calls router.refresh() +// so every server-rendered page (and the shared sidebar badges) reconciles fresh +// data without a manual reload. Idle guards: it never refreshes while the tab is +// hidden, does one immediate refresh when the tab becomes visible again, and +// skips a tick while a previous refresh is still settling. +export function AutoRefresh({ intervalMs }: Props) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + // Read pending state from a ref inside the interval so the effect doesn't have + // to re-subscribe (and reset its timer) on every transition state change. + const isPendingRef = useRef(isPending); + isPendingRef.current = isPending; + + useEffect(() => { + if (!Number.isFinite(intervalMs) || intervalMs <= 0) return; + + const tick = () => { + if (typeof document !== "undefined" && document.hidden) return; + if (isPendingRef.current) return; // a refresh is already in flight + startTransition(() => router.refresh()); + }; + + const onVisibility = () => { + // Catch up immediately when the user returns to the tab. + if (!document.hidden) tick(); + }; + + const id = setInterval(tick, intervalMs); + document.addEventListener("visibilitychange", onVisibility); + return () => { + clearInterval(id); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [intervalMs, router]); + + return null; +} diff --git a/editor/app/jobs/components/JobsAutoRefresh.tsx b/editor/app/jobs/components/JobsAutoRefresh.tsx @@ -1,18 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; - -type Props = { - hasNonTerminal: boolean; -}; - -export function JobsAutoRefresh({ hasNonTerminal }: Props) { - const router = useRouter(); - useEffect(() => { - if (!hasNonTerminal) return; - const id = setInterval(() => router.refresh(), 2_500); - return () => clearInterval(id); - }, [hasNonTerminal, router]); - return null; -} diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx @@ -4,7 +4,6 @@ import { listAllJobs } from "yt-dlp-transcript-common/jobs/listJobs"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { ClearArchivedButton } from "./components/ClearArchivedButton"; import { CancelJobButton } from "./components/CancelJobButton"; -import { JobsAutoRefresh } from "./components/JobsAutoRefresh"; export const dynamic = "force-dynamic"; @@ -37,12 +36,8 @@ function statusColor(status: string): string { export default async function JobsPage() { const jobs = await listAllJobs(getPaths()); - const hasNonTerminal = jobs.some( - (j) => j.status === "running" || j.status === "queued", - ); return ( <div className="flex flex-col gap-4"> - <JobsAutoRefresh hasNonTerminal={hasNonTerminal} /> <div className="flex items-center justify-between"> <h1 className="text-2xl font-semibold">Jobs</h1> <ClearArchivedButton /> diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -7,6 +7,7 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { getLatestChangelogDate } from "yt-dlp-transcript-common/lib/changelog"; import { listSites } from "yt-dlp-transcript-common/lib/site"; +import { AutoRefresh } from "./components/AutoRefresh"; import { CommandPalette } from "./components/CommandPalette"; import { ChangelogNavLink } from "./components/ChangelogNavLink"; import { SiteScopeSelect } from "./components/SiteScopeSelect"; @@ -169,6 +170,9 @@ export default async function RootLayout({ <Suspense fallback={null}> <CommandPalette /> </Suspense> + <AutoRefresh + intervalMs={settings.autoRefreshIntervalSeconds * 1000} + /> </body> </html> ); diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -2,6 +2,8 @@ import { revalidatePath } from "next/cache"; import { + AUTO_REFRESH_INTERVAL_MAX_SECONDS, + AUTO_REFRESH_INTERVAL_MIN_SECONDS, DEFAULT_REPORT_DEBOUNCE_PRESET, isReportDebouncePreset, normalizeSocialSvg, @@ -37,6 +39,9 @@ export async function saveSettingsAction( const parallelRaw = String( formData.get("parallelTranscriptions") ?? "", ).trim(); + const autoRefreshRaw = String( + formData.get("autoRefreshIntervalSeconds") ?? "", + ).trim(); const inlineTranscribeOnFallback = formData.get("inlineTranscribeOnFallback") === "on"; const skipLiveDownloads = formData.get("skipLiveDownloads") === "on"; @@ -150,6 +155,22 @@ export async function saveSettingsAction( }; } + // 0 disables passive refresh; any other value must land in the allowed window. + const autoRefreshParsed = Number.parseInt(autoRefreshRaw, 10); + if (!Number.isFinite(autoRefreshParsed)) { + return { ok: false, error: "autoRefreshIntervalSeconds must be a number" }; + } + if ( + autoRefreshParsed !== 0 && + (autoRefreshParsed < AUTO_REFRESH_INTERVAL_MIN_SECONDS || + autoRefreshParsed > AUTO_REFRESH_INTERVAL_MAX_SECONDS) + ) { + return { + ok: false, + error: `autoRefreshIntervalSeconds must be 0 (off) or between ${AUTO_REFRESH_INTERVAL_MIN_SECONDS} and ${AUTO_REFRESH_INTERVAL_MAX_SECONDS}`, + }; + } + let socialInput: unknown; try { socialInput = JSON.parse(String(formData.get("socialLinksJson") ?? "[]")); @@ -187,6 +208,7 @@ export async function saveSettingsAction( inlineTranscribeOnFallback, skipLiveDownloads, reportDebouncePreset, + autoRefreshIntervalSeconds: autoRefreshParsed, socialLinks, }; await writeSettings(next); diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx @@ -157,6 +157,13 @@ export function SettingsForm({ initial, apps }: Props) { type="number" hint="Default number of videos transcribed at once when a run doesn't set its own concurrency (1–16). Default 2. Higher values use more CPU/RAM. The per-run Concurrency input on a channel overrides this for a single run." /> + <Field + label="Auto-refresh interval (seconds)" + name="autoRefreshIntervalSeconds" + defaultValue={String(initial.autoRefreshIntervalSeconds)} + type="number" + hint="How often the editor passively re-fetches the current page so sidebar badges and reports stay live without a manual reload. Pauses automatically while the tab is hidden and refreshes once when you return. Default 5s (range 1–600). Set to 0 to disable." + /> <label className="flex items-start gap-2 text-sm"> <input type="checkbox" diff --git a/editor/e2e/auto-refresh.spec.ts b/editor/e2e/auto-refresh.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from "@playwright/test"; +import { readJson, resetData, writeSettings } from "./helpers"; + +// Covers the global passive auto-refresh: the Settings field (default 5s, +// persists, accepts 0=off, rejects out-of-range) and the runtime behavior — +// with a short interval the page re-fetches its server data on its own (the +// sidebar admin title, rendered by the root layout from settings, updates +// without a reload); with 0 it never does. + +test.describe("auto-refresh setting", () => { + test.beforeEach(async () => { + await resetData("empty"); + }); + + test("defaults to 5s and persists a new value", async ({ page }) => { + await page.goto("/settings"); + await expect(page.getByLabel(/auto-refresh interval/i)).toHaveValue("5"); + + await page.getByLabel(/auto-refresh interval/i).fill("12"); + await page.getByRole("button", { name: /save settings/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + + const saved = await readJson<{ autoRefreshIntervalSeconds?: number }>( + "test-settings.json", + ); + expect(saved.autoRefreshIntervalSeconds).toBe(12); + + await page.reload(); + await expect(page.getByLabel(/auto-refresh interval/i)).toHaveValue("12"); + }); + + test("accepts 0 to disable", async ({ page }) => { + await page.goto("/settings"); + await page.getByLabel(/auto-refresh interval/i).fill("0"); + await page.getByRole("button", { name: /save settings/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + + const saved = await readJson<{ autoRefreshIntervalSeconds?: number }>( + "test-settings.json", + ); + expect(saved.autoRefreshIntervalSeconds).toBe(0); + }); + + test("rejects out-of-range interval", async ({ page }) => { + await page.goto("/settings"); + await page.getByLabel(/auto-refresh interval/i).fill("9999"); + await page.getByRole("button", { name: /save settings/i }).click(); + await expect(page.locator("form").getByRole("alert")).toContainText( + "between", + ); + }); +}); + +test.describe("auto-refresh behavior", () => { + test("re-fetches server data on the interval without a reload", async ({ + page, + }) => { + await resetData("empty"); + // Short cadence so the test isn't slow; the sidebar title comes from the + // root layout's getSettings(), so a router.refresh() repaints it. + await writeSettings({ + autoRefreshIntervalSeconds: 1, + adminTitle: "Before Title", + }); + + await page.goto("/"); + const sidebar = page.locator("aside"); + await expect(sidebar.getByText("Before Title")).toBeVisible(); + + // Change the title out of band. No navigation, no reload — only the passive + // refresher should bring it in. + await writeSettings({ + autoRefreshIntervalSeconds: 1, + adminTitle: "After Title", + }); + + await expect(sidebar.getByText("After Title")).toBeVisible({ + timeout: 8_000, + }); + }); + + test("does not auto-refresh when disabled (0)", async ({ page }) => { + await resetData("empty"); + await writeSettings({ + autoRefreshIntervalSeconds: 0, + adminTitle: "Before Title", + }); + + await page.goto("/"); + const sidebar = page.locator("aside"); + await expect(sidebar.getByText("Before Title")).toBeVisible(); + + await writeSettings({ + autoRefreshIntervalSeconds: 0, + adminTitle: "After Title", + }); + + // Give a disabled refresher ample time to (not) fire, then confirm the page + // still shows the old title — but a manual reload does pick up the change, + // proving it was persisted and simply not auto-applied. + await page.waitForTimeout(3_000); + await expect(sidebar.getByText("Before Title")).toBeVisible(); + await page.reload(); + await expect(sidebar.getByText("After Title")).toBeVisible(); + }); +});