import { mkdir, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { pathExists, readJson, resetData, resolvePath, writeSettings, } from "./helpers"; import { baseUrl } from "./baseUrl"; // A SUBTITLE 429 DOES NOT FAIL A DOWNLOAD, AND THE PACE ADAPTS (release 17, // slice RL). The pure decisions are unit-tested (common/jobs/unitOutcome.test.ts, // common/ytdlp/subtitleRateLimit.test.ts); this proves them through the real // download lane and the fake yt-dlp: // - `dl429`: the 2026-10-01 shape — the prefetch succeeds, the subtitle fetch // answers 429. The unit succeeds with the media and no subtitles, the // video's subtitles are deferred, youtube is NOT backed off, and the lane // page and the video page say so. // - `wp429`: a platform-level 429 (the watch page refused) on a backoff // already at the cap: youtube is HELD, its next try is a probe // `pacing.holdProbeMinutes` away, the pace doubled, the page shows the // hold and the pace, and a manual Sync is refused with the hold sentence. // - a probe that comes back clean clears the hold and the backoff. type Leaf = { id: string; match: { type: string; value?: string } }; type Group = { id: string; mode: string; children: (Group | Leaf)[] }; const STATE_FILE = "test-transcripts/.auto-queue/state.json"; const TOKEN = "test-worker-token"; const AUTH = { authorization: `Bearer ${TOKEN}` }; // Copied from pacing.spec.ts: a YouTube channel with undownloaded videos. async function makeDownloadChannel(slug: string, ids: string[]) { const root = resolvePath(`test-transcripts/channels/${slug}`); await mkdir(`${root}/data`, { recursive: true }); await writeFile( `${root}/config.json`, JSON.stringify({ handling: "youtube", name: slug, url: `https://www.youtube.com/@${slug}/videos`, }), ); await writeFile( `${root}/playlist`, ids.map((id) => `https://www.youtube.com/watch?v=${id}`).join("\n") + "\n", ); await writeFile( `${root}/snapshot.json`, JSON.stringify({ generatedAt: "2026-06-01T00:00:00.000Z", totals: { videos: ids.length, transcribed: 0, downloaded: 0 }, buckets: { noTranscript: [], downloadedNoTranscript: [], untranscoded: [], multipleAudioFormats: [], transcribedWithAudio: [], untranscribable: [], noMetadata: [], failedListed: [], missingFromArchive: [], duplicateDirs: [], partialDownloads: [], corruptSource: [], nonStandardVtt: [], skippedByFilter: [], }, undownloadedIds: ids, }), ); } async function setup( ids: string[], pacing: Record = {}, seed?: Record, laneEnabled = true, ) { await resetData(null); await makeDownloadChannel("alpha", ids); const root: Group = { id: "root", mode: "strict", children: [{ id: "leaf-alpha", match: { type: "channel", value: "alpha" } }], }; // Settings first: writing them clears the server's shared auto-queue state, // so the seed below is what the runner reads when it starts. await writeSettings({ adminTitle: "Test Admin", maxTranscriptPageBytes: 8388608, sleepBetweenDownloadsSeconds: 0, minFreeDiskGB: 0, pacing, autoQueue: { transcription: {}, download: { enabled: laneEnabled, maxWorkers: null, root }, }, }); if (seed) { await mkdir(resolvePath("test-transcripts/.auto-queue"), { recursive: true }); await writeFile( resolvePath(STATE_FILE), JSON.stringify({ transcription: { runtime: {}, picks: [], platformBackoff: {} }, download: { runtime: {}, picks: [], platformBackoff: {}, ...seed }, }), ); } } type Persisted = { platformBackoff: Record; videoDeferrals?: Record; platformPace?: Record; platformHolds?: Record; subtitleDeferrals?: Record; }; async function persisted(): Promise { try { return (await readJson<{ download: Persisted }>(STATE_FILE)).download; } catch { return null; } } type Status = { download: { runner: { running: boolean; idleReason: string | null }; picks: { videoId: string }[]; }; }; async function getStatus( request: import("@playwright/test").APIRequestContext, ): Promise { const res = await request.get(`${baseUrl}/api/auto-queue/status`); expect(res.ok()).toBeTruthy(); return res.json(); } async function control( request: import("@playwright/test").APIRequestContext, action: "start" | "stop", ) { const res = await request.post(`${baseUrl}/api/auto-queue/control`, { // Behind the ops token since release 19 (A3): it starts and stops lanes. headers: { authorization: "Bearer test-worker-token" }, data: { kind: "download", action }, }); if (action === "start") expect(res.ok()).toBeTruthy(); } test.afterEach(async ({ request }) => { await control(request, "stop"); }); test("a subtitle 429 downloads the media, defers only the subtitles, and backs nothing off", async ({ page, request, }) => { await setup(["dl429sub1"]); await control(request, "start"); await expect .poll(async () => (await persisted())?.subtitleDeferrals?.dl429sub1?.count ?? 0, { timeout: 60_000, }) .toBe(1); const d = await persisted(); // The platform was not backed off, its pace was not raised, and the video // itself is not deferred — its media is done. expect(d?.platformBackoff.youtube).toBeUndefined(); expect(d?.platformPace ?? {}).toEqual({}); expect(d?.videoDeferrals ?? {}).toEqual({}); expect(d?.subtitleDeferrals?.dl429sub1?.channelSlug).toBe("alpha"); // The media is on disk, no subtitles, and the outcome is a success that says why. const dir = "test-transcripts/channels/alpha/data/dl429sub1"; expect(await pathExists(`${dir}/audio.mp3`)).toBe(true); expect(await pathExists(`${dir}/transcript.en.vtt`)).toBe(false); const outcome = await readJson<{ status: string; failureClass?: string }>( `${dir}/download-outcome.json`, ); expect(outcome.status).toBe("ok"); expect(outcome.failureClass).toBe("subs_rate_limit"); await page.goto("/operations/download"); const region = page.getByRole("region", { name: "Rate-limit cooldown" }); await expect(region).toBeVisible({ timeout: 20_000 }); const subs = region.getByRole("list", { name: "Deferred subtitles" }); await expect(subs).toContainText("alpha/dl429sub1"); await expect(subs).toContainText("1×"); await expect(region.getByRole("list", { name: "Platforms in cooldown" })).toHaveCount(0); await expect(region.getByRole("list", { name: "Platforms held" })).toHaveCount(0); await page.goto("/channels/alpha/videos/dl429sub1"); await expect(page.getByRole("note", { name: "subtitle deferral" })).toContainText( /rate-limited \(HTTP 429\) 1×/, { timeout: 20_000 }, ); }); test("a platform 429 at the cap holds youtube: one probe per holdProbeMinutes, the pace doubled, a manual Sync refused", async ({ page, request, }) => { // fails: 5 lapsed — the next failure is the first AT the 30-minute cap, and // with holdAfterFailsAtCap 1 that one holds the platform. await setup( ["wp429held", "a2"], { holdAfterFailsAtCap: 1, holdProbeMinutes: 7 }, { platformBackoff: { youtube: { until: Date.now() - 1_000, fails: 5 } } }, ); await control(request, "start"); await expect .poll(async () => Boolean((await persisted())?.platformHolds?.youtube), { timeout: 60_000, }) .toBe(true); const d = (await persisted())!; expect(d.platformBackoff.youtube.fails).toBe(6); // The probe cadence is the setting's, not the 30-minute cap's. const left = d.platformBackoff.youtube.until - Date.now(); expect(left).toBeGreaterThan(6 * 60_000); expect(left).toBeLessThanOrEqual(7 * 60_000); expect(d.platformHolds!.youtube.probeAt).toBe(d.platformBackoff.youtube.until); // A rate limit doubled the pace. expect(d.platformPace?.youtube?.sleepRequestsSeconds).toBe(2); // a2 is pending on a held platform: the lane says it is held. await expect .poll(async () => (await getStatus(request)).download.runner.idleReason, { timeout: 30_000, }) .toBe("held"); expect((await getStatus(request)).download.picks.map((p) => p.videoId)).toEqual([ "wp429held", ]); await page.goto("/operations/download"); const region = page.getByRole("region", { name: "Rate-limit cooldown" }); await expect(region).toBeVisible({ timeout: 20_000 }); const held = region.getByRole("list", { name: "Platforms held" }); await expect(held).toContainText("youtube"); await expect(held).toContainText(/next probe in (6m|7m)/); await expect(held).toContainText("6 failures in a row"); await expect(region.getByRole("list", { name: "Platforms in cooldown" })).toHaveCount(0); await expect(region.getByRole("list", { name: "Request pace" })).toContainText( "2s between requests (base 1s)", ); await expect( page .getByText(/every pending platform is held after repeated rate limits/) .first(), ).toBeVisible(); // A manual Sync is refused with the hold, naming the next probe. const sync = await request.post(`${baseUrl}/api/ops/sync`, { headers: AUTH, data: { slug: "alpha" }, }); expect(sync.status()).toBe(400); const body = (await sync.json()) as { error: string; info?: boolean }; expect(body.info).toBe(true); expect(body.error).toMatch( /^youtube is held: its rate limit outlasted the cooldown cap \(6 failures in a row, held since \d\d:\d\d UTC\)\. Auto-download probes it once every 7 min — the next probe is in [67] min\. Sync will run once a probe comes back clean\.$/, ); }); test("a probe that comes back clean clears the hold and the backoff", async ({ request, }) => { const now = Date.now(); await setup(["a1"], { holdProbeMinutes: 60 }, { platformBackoff: { youtube: { until: now - 1_000, fails: 9 } }, platformHolds: { youtube: { since: now - 3_600_000, probeAt: now - 1_000 } }, }); await control(request, "start"); 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 }); expect((await getStatus(request)).download.picks.map((p) => p.videoId)).toEqual(["a1"]); expect(await pathExists("test-transcripts/channels/alpha/data/a1/transcript.en.vtt")).toBe( 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({}); });