commit 3d273ea14b4d564542222c05c3d327548dfef577
parent 7599fd4db54827e207c0fbeed6e73a9976f76d56
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 27 Apr 2026 23:16:15 -0400
fix(ytdlp): treat exit code 101 as success and update last-sync timestamps
yt-dlp's exit code 101 is its documented "stop iterating" code (emitted by
--break-on-existing / --max-downloads). Previously runChildAndStream and
storePlaylist threw on any non-zero exit, so a Sync that legitimately had
nothing new to fetch was marked failed and touchLastSync/touchLastFullDownload
never ran. Treat 101 the same as 0; lastSyncedAt now updates on no-op syncs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 77 insertions(+), 2 deletions(-)
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -103,9 +103,14 @@ async function storePlaylist(opts: RunYtdlpOpts): Promise<void> {
child.stderr?.on("data", (c: Buffer) => opts.onLog(c.toString("utf8")));
const result = await child;
- if (result.exitCode !== 0) {
+ // yt-dlp exit code convention: 101 = "break-on-existing" / "max-downloads"
+ // (clean stop, not an error). Treat it the same as 0.
+ if (result.exitCode !== 0 && result.exitCode !== 101) {
throw new Error(`yt-dlp exited with code ${result.exitCode}`);
}
+ if (result.exitCode === 101) {
+ opts.onLog(`yt-dlp stopped on existing entry (exit 101).\n`);
+ }
const stdout = String(result.stdout ?? "");
const urls = stdout
@@ -220,9 +225,18 @@ async function runChildAndStream(
});
child.all?.on("data", (c: Buffer) => opts.onLog(c.toString("utf8")));
const result = await child;
- if (result.exitCode !== 0 && !opts.signal.aborted) {
+ // yt-dlp exit code convention: 101 = "break-on-existing" / "max-downloads"
+ // (clean stop, not an error). Treat it the same as 0.
+ if (
+ result.exitCode !== 0 &&
+ result.exitCode !== 101 &&
+ !opts.signal.aborted
+ ) {
throw new Error(`yt-dlp exited with code ${result.exitCode}`);
}
+ if (result.exitCode === 101) {
+ opts.onLog(`yt-dlp stopped on existing entry (exit 101).\n`);
+ }
}
async function touchLastSync(opts: RunYtdlpOpts): Promise<void> {
diff --git a/editor/cypress/e2e/sync-break-on-existing.cy.ts b/editor/cypress/e2e/sync-break-on-existing.cy.ts
@@ -0,0 +1,45 @@
+describe("Sync with --break-on-existing (yt-dlp exit 101)", () => {
+ it("treats yt-dlp exit code 101 as success and updates lastSyncedAt", () => {
+ cy.resetData("sync-already-archived");
+ cy.visit("/channels/archived-channel");
+
+ cy.findByTestId("pipeline-sync").find("button").click();
+ cy.findByTestId("pipeline-sync-log", { timeout: 15_000 }).should(
+ "contain.text",
+ "exit 101",
+ );
+
+ // Run button should re-enable and there's no Cancel button (i.e., not still
+ // running and not in an error state).
+ cy.findByTestId("pipeline-sync")
+ .find("button")
+ .should("not.be.disabled")
+ .and("contain.text", "Sync");
+ cy.findByTestId("pipeline-sync-cancel").should("not.exist");
+ cy.findByTestId("pipeline-sync-error").should("not.exist");
+
+ // Config now has lastSyncedAt set to a parseable ISO string.
+ cy.task(
+ "readJson",
+ "test-transcripts/channels/archived-channel/config.json",
+ ).then((config) => {
+ const c = config as { lastSyncedAt?: string };
+ expect(c.lastSyncedAt, "lastSyncedAt").to.be.a("string");
+ expect(
+ Number.isNaN(Date.parse(c.lastSyncedAt!)),
+ "lastSyncedAt parses",
+ ).to.equal(false);
+ });
+
+ // The job lands as `done`, not `failed`.
+ cy.visit("/jobs");
+ cy.findByTestId("jobs-table").within(() => {
+ cy.contains("sync");
+ });
+ cy.get('[data-testid^="job-row-"]')
+ .first()
+ .within(() => {
+ cy.contains(/^done$/i);
+ });
+ });
+});
diff --git a/editor/cypress/fixtures/bin/fake-ytdlp.mjs b/editor/cypress/fixtures/bin/fake-ytdlp.mjs
@@ -133,6 +133,13 @@ async function main() {
await sleep(30_000);
}
+ // Emit a friendly message and exit 101 so the editor's exit-code handling
+ // can be tested against the "nothing new to download" case.
+ if (has("--cy-break-on-existing")) {
+ process.stderr.write(`[fake-ytdlp] all entries already in archive\n`);
+ process.exit(101);
+ }
+
if (
has("--flat-playlist") &&
has("--skip-download") &&
diff --git a/editor/cypress/fixtures/test-transcripts/sync-already-archived/channels/archived-channel/archive b/editor/cypress/fixtures/test-transcripts/sync-already-archived/channels/archived-channel/archive
@@ -0,0 +1,3 @@
+youtube oldvid000001
+youtube oldvid000002
+youtube oldvid000003
diff --git a/editor/cypress/fixtures/test-transcripts/sync-already-archived/channels/archived-channel/config.json b/editor/cypress/fixtures/test-transcripts/sync-already-archived/channels/archived-channel/config.json
@@ -0,0 +1,6 @@
+{
+ "handling": "youtube",
+ "name": "Archived Channel",
+ "url": "https://www.youtube.com/@archived/videos",
+ "ytdlpExtraArgs": ["--cy-break-on-existing"]
+}