Archilyzer · Source

archilyzer

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

commit 0f6cafceac44e64be36430cb1856677b1c13df29
parent 1c11b0db9e6a7662b03aa7597a605ed83c428408
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 23:18:23 -0400

editor: e2e for the subtitle deferral, the hold and the clean probe; the fake's dl429 obeys --ignore-errors

New rate-limit.spec.ts drives the real download lane against the fake
yt-dlp: a subtitle 429 (dl429) downloads the media, defers only the
subtitles and backs nothing off, and the lane page and the video page say
so; a watch-page 429 (new wp429 sentinel, at the prefetch) on a backoff at
the cap holds youtube with the probe cadence from pacing.holdProbeMinutes,
doubles the pace, shows both, and a manual Sync is refused with the hold
sentence; a clean probe clears the hold and the backoff. pacing.spec's live
429 moves to wp429 (a subtitle 429 no longer backs the platform off);
rumble-sweep.spec's paged walk after the enumeration's 429 now paces at
2 s. The video page's deferral line lives beside VideoPanel, not among
the chore cards.

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

Diffstat:
Aeditor/app/channels/[slug]/videos/[id]/components/SubtitleDeferralLine.tsx | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 2+-
Deditor/app/channels/[slug]/videos/[id]/components/cards/SubtitleDeferralLine.tsx | 51---------------------------------------------------
Meditor/e2e/fixtures/bin/fake-ytdlp.mjs | 37+++++++++++++++++++++++++++++--------
Meditor/e2e/pacing.spec.ts | 16+++++++++-------
Aeditor/e2e/rate-limit.spec.ts | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/rumble-sweep.spec.ts | 8+++++++-
7 files changed, 394 insertions(+), 68 deletions(-)

diff --git a/editor/app/channels/[slug]/videos/[id]/components/SubtitleDeferralLine.tsx b/editor/app/channels/[slug]/videos/[id]/components/SubtitleDeferralLine.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { subtitleDeferralAction } from "../videoActions"; + +// THE SUBTITLE DEFERRAL, IN ONE LINE (release 17, slice RL). A video whose +// YouTube subtitles answered HTTP 429 while its media came down: how many +// times, when last, and until when the batch subtitle fetch (Download missing +// subs) leaves it alone — 6 h after a strike, 7 days from the third. A +// download from this page still fetches them. Draws nothing when there is no +// deferral, which is every video but a handful. +export function SubtitleDeferralLine({ + slug, + videoId, +}: { + slug: string; + videoId: string; +}) { + const [d, setD] = useState<{ count: number; lastAt: number; until: number } | null>(null); + useEffect(() => { + let live = true; + subtitleDeferralAction(slug, videoId) + .then((r) => { + if (live) setD(r); + }) + .catch(() => {}); + return () => { + live = false; + }; + }, [slug, videoId]); + if (!d) return null; + const day = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " ") + " UTC"; + const held = d.count >= 3; + const active = d.until > Date.now(); + return ( + <p + role="note" + 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){" "} + {d.count}× — last {day(d.lastAt)}.{" "} + {active + ? held + ? `Left alone by Download missing subs until ${day(d.until)}.` + : `Download missing subs retries them after ${day(d.until)}.` + : "Download missing subs will retry them."}{" "} + A download from this page still fetches them. + </p> + ); +} diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx @@ -35,7 +35,7 @@ import { RedownloadSection } from "./cards/RedownloadSection"; import { ShortAudioBanner } from "./cards/ShortAudioBanner"; import { SourceVideoSection } from "./cards/SourceVideoSection"; import { TranscriptSourceSection } from "./cards/TranscriptSourceSection"; -import { SubtitleDeferralLine } from "./cards/SubtitleDeferralLine"; +import { SubtitleDeferralLine } from "./SubtitleDeferralLine"; import { CANONICAL_VTT, WHISPER_FILENAME, diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/SubtitleDeferralLine.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/SubtitleDeferralLine.tsx @@ -1,51 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { subtitleDeferralAction } from "../../videoActions"; - -// THE SUBTITLE DEFERRAL, IN ONE LINE (release 17, slice RL). A video whose -// YouTube subtitles answered HTTP 429 while its media came down: how many -// times, when last, and until when the batch subtitle fetch (Download missing -// subs) leaves it alone — 6 h after a strike, 7 days from the third. A -// download from this page still fetches them. Draws nothing when there is no -// deferral, which is every video but a handful. -export function SubtitleDeferralLine({ - slug, - videoId, -}: { - slug: string; - videoId: string; -}) { - const [d, setD] = useState<{ count: number; lastAt: number; until: number } | null>(null); - useEffect(() => { - let live = true; - subtitleDeferralAction(slug, videoId) - .then((r) => { - if (live) setD(r); - }) - .catch(() => {}); - return () => { - live = false; - }; - }, [slug, videoId]); - if (!d) return null; - const day = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " ") + " UTC"; - const held = d.count >= 3; - const active = d.until > Date.now(); - return ( - <p - role="note" - 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){" "} - {d.count}× — last {day(d.lastAt)}.{" "} - {active - ? held - ? `Left alone by Download missing subs until ${day(d.until)}.` - : `Download missing subs retries them after ${day(d.until)}.` - : "Download missing subs will retry them."}{" "} - A download from this page still fetches them. - </p> - ); -} diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs @@ -462,15 +462,26 @@ async function modeYoutubeSingleUrlManaged(url) { `youtube-single:${url} cookies=${cookieArg()}\n`, ); if (cookieGateBlocked(url)) failCookieGate(url); - // `dl429`: the 2026-09-24 YouTube shape — the metadata prefetch succeeded - // (the prefetch branch has no such sentinel), then the real download's - // SUBTITLE fetch answers 429. Per video, not IP-wide: availability.ts - // classifies it rate_limit, so the runner backs youtube off AND defers this - // one video (pacing.spec.ts). + // `dl429`: the 2026-09-24 / 2026-10-01 YouTube shape — the metadata + // prefetch succeeded (the prefetch branch has no such sentinel), then the + // real download's SUBTITLE fetch answers 429. Per video, not IP-wide. Like + // the real yt-dlp: under --ignore-errors (which the managed primary passes + // since release 17, slice RL) the failure is a WARNING, nothing is written + // and the exit is clean; without it, an ERROR and exit 1. availability.ts + // classifies either as `subs_rate_limit`: the downloader fetches the media + // and the runner defers only the subtitles (rate-limit.spec.ts). if (url.toLowerCase().includes("dl429")) { - process.stderr.write( - `ERROR: [youtube] ${url}: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\n`, - ); + const line = `[youtube] ${url}: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests`; + if (has("--ignore-errors")) { + process.stderr.write(`WARNING: ${line}\n`); + const id429 = urlIdYouTube(url); + if (!existsSync(path.join(videoDir, "metadata.info.json"))) { + await writeMetadata(videoDir, id429, urlSentinels(url)); + } + process.stdout.write(`DLOM_ARCHIVE youtube ${id429}\n`); + return; + } + process.stderr.write(`ERROR: ${line}\n`); process.exit(1); } process.stdout.write(`[fake-ytdlp] managed single-URL ${id}\n`); @@ -821,6 +832,16 @@ async function main() { `prefetch:${url} cookies=${cookieArg()}\n`, ); if (cookieGateBlocked(url)) failCookieGate(url); + // `wp429`: a PLATFORM-level 429 — the watch page itself is refused. The + // prefetch is the first request a managed download makes, so the video + // stops there classified `rate_limit`: the platform backs off, its pace + // doubles, and the video is deferred (pacing.spec.ts, rate-limit.spec.ts). + if (url.toLowerCase().includes("wp429")) { + process.stderr.write( + `ERROR: [youtube] ${id}: Unable to download webpage: HTTP Error 429: Too Many Requests\n`, + ); + process.exit(1); + } await writeMetadata(videoDir, id, urlSentinels(url)); process.stdout.write(`[fake-ytdlp] metadata prefetch ${id}\n`); return; diff --git a/editor/e2e/pacing.spec.ts b/editor/e2e/pacing.spec.ts @@ -8,7 +8,9 @@ import { baseUrl } from "./baseUrl"; // runner moves on to the NEXT video instead of re-picking the same one. The // pure decision is unit-tested (common/jobs/unitOutcome.test.ts); this proves // the runner's pick honours a persisted deferral, the lane says why it idles, -// the page shows it, and a live 429 (the fake's `dl429` sentinel) defers. +// the page shows it, and a live 429 (the fake's `wp429` sentinel — a refused +// watch page; a SUBTITLE 429, `dl429`, no longer backs the platform off since +// release 17 slice RL, see rate-limit.spec.ts) defers. type Leaf = { id: string; match: { type: string; value?: string } }; type Group = { id: string; mode: string; children: (Group | Leaf)[] }; @@ -192,9 +194,9 @@ test("a live 429 backs youtube off, defers the video, and the next pick is the n request, }) => { // A REAL cooldown: base 60 s ±10 %. The whole point is what happens when it - // lapses — the runner must move on to a2, not re-pick dl429vid1. + // lapses — the runner must move on to a2, not re-pick wp429vid1. test.setTimeout(150_000); - await setup(["dl429vid1", "a2"]); + await setup(["wp429vid1", "a2"]); await control(request, "start"); @@ -203,7 +205,7 @@ test("a live 429 backs youtube off, defers the video, and the next pick is the n async () => { const d = await persistedDownload(); return { - deferred: Boolean(d?.videoDeferrals?.dl429vid1), + deferred: Boolean(d?.videoDeferrals?.wp429vid1), fails: d?.platformBackoff.youtube?.fails ?? 0, }; }, @@ -211,9 +213,9 @@ test("a live 429 backs youtube off, defers the video, and the next pick is the n ) .toEqual({ deferred: true, fails: 1 }); const d = await persistedDownload(); - expect(d?.videoDeferrals?.dl429vid1?.channelSlug).toBe("alpha"); + expect(d?.videoDeferrals?.wp429vid1?.channelSlug).toBe("alpha"); // Deferred for ~6 h, not for the cooldown's minute. - expect(d!.videoDeferrals!.dl429vid1.until - Date.now()).toBeGreaterThan( + expect(d!.videoDeferrals!.wp429vid1.until - Date.now()).toBeGreaterThan( 5 * 60 * 60_000, ); @@ -223,5 +225,5 @@ test("a live 429 backs youtube off, defers the video, and the next pick is the n intervals: [2_000], }) .toContain("a2"); - expect(pickOrder(await getStatus(request))).toEqual(["dl429vid1", "a2"]); + expect(pickOrder(await getStatus(request))).toEqual(["wp429vid1", "a2"]); }); diff --git a/editor/e2e/rate-limit.spec.ts b/editor/e2e/rate-limit.spec.ts @@ -0,0 +1,297 @@ +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<string, number> = {}, + seed?: Record<string, unknown>, +) { + 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: true, 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<string, { until: number; fails: number }>; + videoDeferrals?: Record<string, { until: number; channelSlug: string }>; + platformPace?: Record<string, { sleepRequestsSeconds: number; baseSeconds: number }>; + platformHolds?: Record<string, { since: number; probeAt: number }>; + subtitleDeferrals?: Record<string, { count: number; until: number; channelSlug: string }>; +}; + +async function persisted(): Promise<Persisted | null> { + 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<Status> { + 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`, { + 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, + ); +}); diff --git a/editor/e2e/rumble-sweep.spec.ts b/editor/e2e/rumble-sweep.spec.ts @@ -46,6 +46,7 @@ type ChannelConfigFile = { type AutoQueueStateFile = { download: { platformBackoff: Record<string, { until: number; fails: number }>; + platformPace?: Record<string, { sleepRequestsSeconds: number }>; }; }; @@ -133,9 +134,13 @@ test("a Rumble sweep that 429s mid-listing is incomplete: paged walk, cooldown, expect(paged.length).toBeGreaterThan(0); for (const line of [...full, ...paged]) { expect(line).toContain("--impersonate chrome"); - expect(line).toContain("--sleep-requests 1"); expect(line).toContain(RUMBLE_URL); } + // The pace adapts (release 17, slice RL): the enumeration ran at rumble's + // fixed 1 s, its 429 doubled the pace, and the paged walk that followed it + // paced at 2 s. + for (const line of full) expect(line).toContain("--sleep-requests 1"); + for (const line of paged) expect(line).toContain("--sleep-requests 2"); // The rumble cooldown is recorded where the Sync gate and the runner read it. const state = await readJson<AutoQueueStateFile>( @@ -144,6 +149,7 @@ test("a Rumble sweep that 429s mid-listing is incomplete: paged walk, cooldown, expect(state.download.platformBackoff.rumble?.until).toBeGreaterThan( Date.now(), ); + expect(state.download.platformPace?.rumble?.sleepRequestsSeconds).toBe(2); // Not a listing: no sweep stamp, stored playlist untouched. const after = await readConfig();