Archilyzer · Source

archilyzer

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

commit 547493411ac3be12af6ff6c0776279ce72141dad
parent e3178ac1a4634aedef09ad9758cc2714be12c26f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  2 Oct 2026 01:09:35 -0400

editor: the held list keeps an overdue hold and says why, a Clear hold button, neutral subtitle wording; e2e for both (review H2, N10)

"Platforms held" now lists a held platform whatever its probe time —
"probe overdue — the lane is off" or "probe due — it waits for a pending
video" — and each row has Clear hold (a clear-platform-hold job whose log
says what was cleared). rate-limit.spec: an overdue hold with the lane off
is shown, a Sync is not refused and its clean run lifts the hold; Clear
hold empties hold, backoff and pace.

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

Diffstat:
Meditor/CHANGELOG.md | 4++--
Meditor/app/channels/[slug]/videos/[id]/components/SubtitleDeferralLine.tsx | 2+-
Meditor/app/operations/components/RunnerOperationView.tsx | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Aeditor/app/operations/pacingActions.ts | 41+++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/rate-limit.spec.ts | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 189 insertions(+), 15 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -33,8 +33,8 @@ - **The operations pages share one count of the lanes' pending work.** Every open operations page asks for the lanes' status every 3 seconds, and each request used to count every lane's pending videos afresh from every channel's report. That count is now made once and handed to every request in the next 3 seconds. Changing a lane's rules, a focus or a channel's priority counts again at once; otherwise a pending count can be up to 3 seconds behind a report that was just rewritten or a video a lane just picked. A lane's hold, its runner and its picks are still read fresh on every request. - **Jobs a stopped editor left "running" are closed when it starts again.** A job that was still running when the editor's process ended (killed, crashed, or shut down before the job had finished unwinding) kept "running" in its record for good, and `/jobs` listed it as archived. On start the editor now marks each one **cancelled**, with "interrupted: the process running it stopped before it finished" as the reason on the job's page, and its end time is the last time its log was written. Nothing is run again; **Retry** works as for any cancelled job. A job that another live process is running, such as `archilyzer run`, is left alone, and the same check now keeps the start-up pass from closing that process's queued jobs. Such leftover jobs never blocked a media move. - **A channel's text stays on the fast disk when its media moves, so a slow or unplugged media drive no longer holds its transcripts.** A channel's big files — the audio and the raw live-chat replay — can now live in the channel's own `media` folder, on this disk or another, while its transcripts, cues, metadata and every other small file stay in `data/` where they always were; each big file that moves leaves a small link behind, so everything that opens it by name still finds it. New downloads, transcodes and live-chat normalizes put their big files there as they finish, and every cleanup that deletes audio removes the file the link points to, not just the link. What that changes when a media drive is stalled, unplugged or mid-move: **the index and stats builds never wait on it or are held by it** (a live chat whose transcript cues are out of date keeps the cues the last build read until the drive answers), the channel's report still refreshes (its media size reads as unknown until the drive answers), **digests keep running — even during a move of that channel's media** — and so do normalize, the availability checks, the metadata scan and clip eviction. Transcription, downloads, the backfill lane and anything else that opens the audio are held as before. **A channel moved the old way — its whole `data/` on the other drive — is now shown as "Media layout retired" and held by everything, the builds and digests included, until `archilyzer storage migrate-tier <channel>` brings its text home;** every refusal says so. Deleting a video from its page is refused while its channel's media drive is not reachable, so its audio is never left behind on the drive. Needs a rebuild and restart of the editor. -- **A video whose YouTube subtitles answer "Too Many Requests" (HTTP 429) is downloaded anyway, and YouTube is not put in a cooldown for it.** YouTube refuses a subtitle file per video while the video itself downloads fine; every one of the day's 429s on 2026-10-01 was a subtitle fetch, and each failed its download, put all of YouTube in a cooldown that reached 30 minutes, and deferred the video for 6 hours to fail the same way again. Now the subtitle failure is noted and the download goes on to the audio, as for a video with no captions, so the transcription lane transcribes it; nothing platform-wide is backed off. The video's subtitles are deferred: **Download missing subs** skips them for 6 hours, and from the third time they are refused, for 7 days; a download from the video's own page still fetches them. The download lane's page lists them under **Deferred subtitles**, and the video page says how many times and when. **Download missing subs** also goes on to the next video when one video's subtitles are refused, instead of stopping. Needs a rebuild and restart of the editor. -- **The download pace adapts to rate limits, a rate limit that outlasts the cooldown holds the platform, and the auto-download lane waits between downloads.** Every yt-dlp run against a platform now waits its platform's current pace between requests: 1 second for YouTube and Rumble, doubled by each real rate limit (up to 16 seconds) and eased back one step after every 5 clean downloads; a subtitle-only refusal never raises it. When a platform has failed three times in a row at the 30-minute cooldown, it is **held**: auto-download tries it once an hour instead of every 30 minutes, a clean try lifts the hold, and a manual **Sync**, download or metadata scan on it is refused with a sentence giving the next try's time. The auto-download lane now waits **Sleep between downloads** between two downloads on one platform, as a channel's batch downloads always did, plus whatever the pace was raised by; batch downloads add that too. The lane page's **Rate-limit cooldown** box shows held platforms, the raised paces and the deferred subtitles, the lane says when it is idle because a platform is held or it is pausing between downloads, and `archilyzer doctor` warns about a platform in a cooldown or held, and about a raised pace. The four numbers are the new `pacing` block in `settings.json` (SETTINGS.md). Needs a rebuild and restart of the editor. +- **A video whose YouTube subtitles answer "Too Many Requests" (HTTP 429) is downloaded anyway, and YouTube is not put in a cooldown for it.** YouTube refuses a subtitle file per video while the video itself downloads fine; every one of the day's 429s on 2026-10-01 was a subtitle fetch, and each failed its download, put all of YouTube in a cooldown that reached 30 minutes, and deferred the video for 6 hours to fail the same way again. Now that refusal is noted and the download goes on to the audio, as for a video with no captions, so the transcription lane transcribes it; nothing platform-wide is backed off. The video's subtitles are deferred: **Download missing subs** skips them for 6 hours, and from the third time they are refused, for 7 days; a download from the video's own page still fetches them. The download lane's page lists them under **Deferred subtitles**, and the video page says how many times and when. **Download missing subs** also goes on to the next video when one video's subtitles are refused, instead of stopping. Any other subtitle failure (a 403, a missing file, a chat replay that fails) still fails the download, as before. Needs a rebuild and restart of the editor. +- **The download pace adapts to rate limits, a rate limit that outlasts the cooldown holds the platform, and the auto-download lane waits between downloads.** Every yt-dlp run against a platform now waits its platform's current pace between requests: 1 second for YouTube and Rumble, doubled by each real rate limit (up to 16 seconds) and eased back one step after every 5 clean downloads, and one step for every hour with no rate limit; a subtitle-only refusal never raises it. When a platform has failed three times in a row at the 30-minute cooldown, it is **held**: auto-download tries it once an hour instead of every 30 minutes, and until that try is due a manual **Sync**, download or metadata scan on it is refused with a sentence giving its time. A clean try lifts the hold — the lane's, or a manual Sync, download or scan once the try is due, which is how a hold ends while auto-download is off or has nothing to fetch on that platform — and so does a try whose video came down although its subtitles were refused. The lane page keeps a held platform listed until then (saying when the lane is off), with a **Clear hold** button that drops the hold, the cooldown and the raised pace at once and says so in a job log. The auto-download lane now waits **Sleep between downloads** between two downloads on one platform, as a channel's batch downloads always did, plus whatever the pace was raised by; batch downloads add that too. The lane page's **Rate-limit cooldown** box shows held platforms, the raised paces and the deferred subtitles, the lane says when it is idle because a platform is held or it is pausing between downloads, and `archilyzer doctor` warns about a platform in a cooldown or held, and about a raised pace. **Download missing subs** also waits that gap between videos. The four numbers are the new `pacing` block in `settings.json` (SETTINGS.md). **After the restart, YouTube may be held at its first real failure:** its cooldown count from before the update (the subtitle refusals) still stands, so one failure puts it straight past the cap — **Clear hold** on the download lane's page resets it, and a clean download does too. Needs a rebuild and restart of the editor. ## [0.11.0] - 2026-09-30 - **Transcripts that arrived after a video was first seen are counted.** The stats behind the homepage, the hub and every site's charts were cached per video and refreshed only when the video's metadata changed, so a transcript that came later — a Whisper run days after the download, or a video downloaded after the last index build — never reached them, and a video with YouTube captions alone had no transcription date. Counts and charts were low; the homepage could show a site with 0 transcripts, 0 channels and 0 hours while it served its videos. A stat is now also redone whenever the index re-reads the video, every transcript has a date, and a captioned video is dated by when its captions arrived rather than by a later Normalize run, so its place on "Transcribed over time" can move. **After updating, rebuild and restart the editor before anything else:** until then, **Build stats dataset** runs the old code and would undo the new stats, while a site, hub or homepage build already runs the new code — and the first stats build of any kind re-reads every video once (about 10–30 minutes on a large archive; it can be stopped and picks up where it stopped). Then build the index, the stats, the homepage, the hub, and the sites. diff --git a/editor/app/channels/[slug]/videos/[id]/components/SubtitleDeferralLine.tsx b/editor/app/channels/[slug]/videos/[id]/components/SubtitleDeferralLine.tsx @@ -43,7 +43,7 @@ export function SubtitleDeferralLine({ aria-label="subtitle deferral" className="rounded-md border border-warning/30 bg-warning-soft px-3 py-2 text-sm text-warning" > - YouTube&apos;s subtitles for this video were rate-limited (HTTP 429){" "} + The subtitles for this video were rate-limited (HTTP 429){" "} {d.count}× — last {day(d.lastAt)}.{" "} {active ? held diff --git a/editor/app/operations/components/RunnerOperationView.tsx b/editor/app/operations/components/RunnerOperationView.tsx @@ -17,6 +17,7 @@ import { NextUp } from "./NextUp"; import { PolicyTreeEditor } from "./PolicyTreeEditor"; import { SnoozeControl } from "./SnoozeControl"; import { type Channel, formatClock, formatCooldown, leafOrder } from "./dispatch"; +import { clearPlatformHoldAction } from "../pacingActions"; // ONE LANE'S RUNNER, IN FULL — any of the four. // @@ -126,6 +127,8 @@ export function RunnerOperationView({ deferred={status.deferred} pace={status.pace ?? []} subtitleDeferred={status.subtitleDeferred ?? []} + laneRunning={status.runner.running} + onRefresh={onRefresh} now={now} /> )} @@ -223,19 +226,37 @@ function CooldownStrip({ deferred, pace, subtitleDeferred, + laneRunning, + onRefresh, now, }: { cooldowns: PlatformCooldownView[]; deferred: VideoDeferralView[]; pace: PlatformPaceView[]; subtitleDeferred: SubtitleDeferralView[]; + laneRunning: boolean; + onRefresh: () => Promise<void>; now: number | null; }) { + const [clearing, setClearing] = useState<string | null>(null); + const [cleared, setCleared] = useState<string | null>(null); + const clearHold = async (platform: string) => { + setClearing(platform); + try { + const res = await clearPlatformHoldAction(platform); + setCleared(res.ok ? res.message : res.error); + await onRefresh(); + } finally { + setClearing(null); + } + }; const left = (untilMs: number) => now === null ? null : Math.max(0, Math.ceil((untilMs - now) / 1000)); const paceOf = (platform: string) => pace.find((p) => p.platform === platform)?.sleepRequestsSeconds; - const cooling = cooldowns.filter((c) => !c.hold); + const cooling = cooldowns.filter( + (c) => !c.hold && (now === null || c.untilMs > now), + ); const held = cooldowns.filter((c) => c.hold); return ( <div @@ -246,28 +267,62 @@ function CooldownStrip({ {held.length > 0 && ( <> <span className="font-medium text-warning"> - Held — the rate limit outlasted the cooldown cap; auto-download - probes once at a time, and manual fetches are refused: + Held — failures outlasted the cooldown cap; auto-download probes + once at a time, and manual fetches wait for the next probe: </span> <ul aria-label="Platforms held" - className="flex flex-wrap gap-x-4 gap-y-1 text-warning" + className="flex flex-col gap-1 text-warning" > {held.map((c) => { const secs = left(c.untilMs); const p = paceOf(c.platform); + const overdue = secs !== null && secs <= 0; return ( - <li key={c.platform} className="tabular-nums"> - <span className="font-mono">{c.platform}</span> — held since{" "} - {formatClock(c.hold!.sinceMs)} ({c.fails} failures in a row) - {secs !== null && <>, next probe in {formatCooldown(secs)}</>} - {p !== undefined && <> · pace {p}s</>} + <li key={c.platform} className="flex flex-wrap items-center gap-x-2 tabular-nums"> + <span> + <span className="font-mono">{c.platform}</span> —{" "} + {c.hold!.rateLimited ? "rate-limited" : "failing (network)"} + , held since {formatClock(c.hold!.sinceMs)} ({c.fails}{" "} + failures in a row) + {secs !== null && + (overdue ? ( + laneRunning ? ( + <> + , probe due — it waits for a pending video on this + platform; a clean manual Sync also lifts it + </> + ) : ( + <> + , probe overdue — the lane is off; a clean manual + Sync also lifts it + </> + ) + ) : ( + <>, next probe in {formatCooldown(secs)}</> + ))} + {p !== undefined && <> · pace {p}s</>} + </span> + <button + type="button" + aria-label={`Clear hold on ${c.platform}`} + disabled={clearing !== null} + onClick={() => void clearHold(c.platform)} + className="rounded border border-warning/40 px-2 py-0.5 text-xs hover:bg-warning/10 disabled:opacity-50" + > + {clearing === c.platform ? "Clearing…" : "Clear hold"} + </button> </li> ); })} </ul> </> )} + {cleared && ( + <span aria-label="clear hold result" className="text-xs text-muted-foreground"> + {cleared} + </span> + )} {cooling.length > 0 && ( <> <span className="font-medium text-warning"> @@ -341,8 +396,8 @@ function CooldownStrip({ {subtitleDeferred.length > 0 && ( <> <span className="font-medium text-warning"> - Deferred subtitles — downloaded, but YouTube&apos;s subtitles - answered HTTP 429; Download missing subs retries them after: + Deferred subtitles — downloaded, but the subtitles answered HTTP + 429; Download missing subs retries them after: </span> <ul aria-label="Deferred subtitles" diff --git a/editor/app/operations/pacingActions.ts b/editor/app/operations/pacingActions.ts @@ -0,0 +1,41 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { runManagedFunction } from "yt-dlp-transcript-common/jobs/streamCommand"; +import { clearPlatformHold } from "yt-dlp-transcript-common/jobs/downloadBackoff"; + +// "CLEAR HOLD" (release 17 slice RL, review H2). The operator's word beats the +// machine, as with an auto-pause: a held platform's hold, its backoff and its +// raised pace all go, and the next failure starts the escalation from the +// bottom. Run as a one-step job so the clearing is in a job log (/jobs, kind +// `clear-platform-hold`), on its own queue — a Sync running on the platform's +// queue must not make the click wait. +export async function clearPlatformHoldAction( + platform: string, +): Promise<{ ok: true; message: string } | { ok: false; error: string }> { + if (!/^[a-z0-9.-]{1,64}$/i.test(platform)) { + return { ok: false, error: `Not a platform: ${platform}` }; + } + const paths = getPaths(); + let message = `${platform} had no hold, backoff or raised pace to clear.`; + const res = await runManagedFunction({ + kind: "clear-platform-hold", + queueKey: `pacing:${platform}`, + paths, + fn: async (onLog) => { + const line = await clearPlatformHold(platform, paths); + if (line) message = line.trim(); + onLog(`${message}\n`); + }, + }); + if (!res.ok) return { ok: false, error: res.error }; + void res.stream.cancel(); + const done = await res.done; + if (done.status !== "done") { + return { ok: false, error: `Clearing the ${platform} hold ended ${done.status}.` }; + } + revalidatePath("/operations"); + revalidatePath("/operations/[id]", "page"); + return { ok: true, message }; +} diff --git a/editor/e2e/rate-limit.spec.ts b/editor/e2e/rate-limit.spec.ts @@ -76,6 +76,7 @@ async function setup( ids: string[], pacing: Record<string, number> = {}, seed?: Record<string, unknown>, + laneEnabled = true, ) { await resetData(null); await makeDownloadChannel("alpha", ids); @@ -94,7 +95,7 @@ async function setup( pacing, autoQueue: { transcription: {}, - download: { enabled: true, maxWorkers: null, root }, + download: { enabled: laneEnabled, maxWorkers: null, root }, }, }); if (seed) { @@ -295,3 +296,80 @@ test("a probe that comes back clean clears the hold and the backoff", async ({ true, ); }); + +// Review H2: a hold must not outlive the lane that would probe it. With the +// lane off and the probe overdue, the page keeps the platform under "Platforms +// held" and says so, a manual Sync is NOT refused, and a clean one lifts the +// hold and the backoff. +test("an overdue hold with the lane off: still shown, Sync not refused, and the clean Sync lifts it", async ({ + page, + request, +}) => { + const now = Date.now(); + await setup( + ["a1"], + {}, + { + platformBackoff: { youtube: { until: now - 1_000, fails: 9 } }, + platformHolds: { youtube: { since: now - 3_600_000, probeAt: now - 1_000, rateLimited: true } }, + }, + false, + ); + + await page.goto("/operations/download"); + const held = page + .getByRole("region", { name: "Rate-limit cooldown" }) + .getByRole("list", { name: "Platforms held" }); + await expect(held).toContainText("youtube", { timeout: 20_000 }); + await expect(held).toContainText("probe overdue — the lane is off"); + + const sync = await request.post(`${baseUrl}/api/ops/sync`, { + headers: AUTH, + data: { slug: "alpha" }, + }); + expect(sync.status(), await sync.text()).toBe(200); + await expect + .poll( + async () => { + const d = await persisted(); + return { + held: Boolean(d?.platformHolds?.youtube), + backoff: Boolean(d?.platformBackoff.youtube), + }; + }, + { timeout: 60_000 }, + ) + .toEqual({ held: false, backoff: false }); +}); + +test("Clear hold drops the hold, the backoff and the pace, and says so", async ({ + page, +}) => { + const now = Date.now(); + await setup( + ["a1"], + {}, + { + platformBackoff: { youtube: { until: now + 3_600_000, fails: 9 } }, + platformHolds: { youtube: { since: now - 60_000, probeAt: now + 3_600_000, rateLimited: true } }, + platformPace: { youtube: { sleepRequestsSeconds: 8, baseSeconds: 1, cleanUnits: 0, steppedAt: now } }, + }, + false, + ); + await page.goto("/operations/download"); + const region = page.getByRole("region", { name: "Rate-limit cooldown" }); + await expect(region.getByRole("list", { name: "Platforms held" })).toContainText( + "next probe in", + { timeout: 20_000 }, + ); + await expect(page.locator("section[data-hydrated=true]").first()).toBeVisible(); + await region.getByRole("button", { name: "Clear hold on youtube" }).click(); + await expect(page.getByLabel("clear hold result")).toContainText( + /^Cleared by hand for youtube: the hold \(since .* UTC\), the backoff \(9 failures\), the pace \(8s → base 1s\)\./, + { timeout: 20_000 }, + ); + const d = await persisted(); + expect(d?.platformHolds ?? {}).toEqual({}); + expect(d?.platformBackoff ?? {}).toEqual({}); + expect(d?.platformPace ?? {}).toEqual({}); +});