commit d023047768b8bb15c303ab963292ee33c60199ee
parent 372a9b12c7da281090901063c14e7035a4723e50
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 30 Jun 2026 10:59:20 -0400
e2e + changelog: download-time guard + selectable format
- editor/e2e/download-format-guard.spec.ts: guard trips on a source-truncated
download (failed-short-audio, stub kept, not transcribed) + full-length
passes; surfacing (short_audio filter, banner, actionable); Odysee auto ->
original, YouTube -> bestaudio, per-channel override -> original.
- New fake-ffprobe.mjs reports a duration from a `__DUR=<n>__` marker the fake
yt-dlp embeds for `truncaud` ids (long metadata via `longvideo`, lbry
extractor via `odyseevid`); fake yt-dlp logs the resolved -f selector. Wire
FFPROBE_BIN into dev:test/start:test. Broaden the fake's audio-check -f match
to accept the Odysee `original/bestaudio/worst` selector.
- editor/CHANGELOG.md: Unreleased entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
5 files changed, 252 insertions(+), 9 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Source-truncated downloads are now caught at download time, and the yt-dlp download format is selectable (Odysee defaults to `original`).** A download that completed (yt-dlp exit 0) but whose audio is far shorter than the video — e.g. an Odysee/LBRY video whose every HLS rung is CDN-truncated to a few minutes while the full audio lives only in the multi-GB `original` format — used to pass the audio-check (the short file is structurally *clean*) and get transcribed, so only the **post-transcription** coverage detector caught it. Two changes fix this. **(1) A download-time duration guard:** after each managed download the produced `audio.<fmt>` is probed with **ffprobe** (new `common/ytdlp/ffprobeDuration.ts`, `paths.ffprobeBin` / `FFPROBE_BIN`) and compared to the metadata duration using the same thresholds as the transcript-coverage detector (`isShortAudio` in `common/lib/transcriptCoverage.ts`: non-livestream, ≥10min, <50% covered). A large shortfall is recorded as a new terminal **`failed-short-audio`** status with the measured `{audioDurationSec, expectedDurationSec, coverage}` — caught **before** transcription (the inline no-subs-fallback whisper is pre-checked too). The short stub is **kept on disk** (so it isn't silently re-downloaded into a loop) and is **not** classified for platform backoff (a truncated stream isn't a transient error). Surfaced as a new **`shortAudio`** snapshot bucket (mirroring `corrupt-full-source`: excluded from `downloadedNoTranscript` so it's never auto-transcribed, and from the wrong-format cleanup so the kept file isn't deleted) → a **`short_audio`** channel-list filter + **Select short-audio** quick-select + bulk **Re-download truncated** action, a **"Download was truncated at the source"** banner on the video page with a one-click **Re-download as Original**, a **"Channels with truncated downloads (short audio)"** `/actionable` section, and a **"Truncated download — audio far shorter than the video (file kept)"** download badge. The auto-download runner lands a `failed-short-audio` unit as a failed job. **(2) Selectable, platform-aware download format:** the previously-hardcoded `-f bestaudio/worst` is now a preset (`auto` | `original` | `bestaudio` | `bestvideo_audio`; `common/ytdlp/downloadFormat.ts`) resolved with the same override chain as `audioFormat` — per-download (the redownload **Format** picker) > per-channel (`ChannelConfig.downloadFormat`, a select in the channel form) > global (`SiteSettings.downloadFormat`, a select in **Settings**). **`auto` is platform-aware:** Odysee/LBRY resolves to `original/bestaudio/worst` (so the truncation is avoided at the source); everything else stays `bestaudio/worst`. The source platform is taken from the prefetched metadata extractor. The short-audio bucket's re-download reuses the per-video fixer (delete the stub → re-fetch with the per-source default → re-transcribe) via the existing `redownload-incomplete-bucket` job (now also driving `ReplayBucket: "shortAudio"`). See `common/ytdlp/{downloadFormat.ts,ffprobeDuration.ts,downloadOneManaged.ts,runYtdlp.ts}`, `common/lib/{transcriptCoverage.ts,downloadOutcome.ts,settings.ts,channelConfig.ts,paths.ts,transcripts-server.ts}`, `common/controller/{channelSnapshot.ts,autoRunner.ts}`, `common/jobs/jobSpec.ts`, `editor/app/channels/[slug]/{incompleteTranscriptActions.ts,bulkVideoActions.ts,lib/{fixIncompleteTranscript.ts,videoRows.ts,videoRowsServer.ts,stageStatus.ts},components/VideoListPane.tsx,videos/[id]/{videoActions.ts,components/VideoPanel.tsx}}`, `editor/app/{settings/{actions.ts,components/SettingsForm.tsx},channels/components/{ChannelForm.tsx,parseChannelForm.ts},actionable/{lib/loadActionable.ts,page.tsx,components/InlineActionButton.tsx},jobs/jobReplayRegistry.ts}`, and `editor/e2e/download-format-guard.spec.ts`.
- **A complete-but-corrupt audio download is no longer re-downloaded forever — it's kept and flagged instead.** When an audio-checked download finished (yt-dlp exit 0, all bytes) but its final integrity probe came back `malformed`, the orchestrator rolled the whole file back and re-downloaded it — repeatedly. For a fast download that completes inside one checkpoint interval no `.good` baseline ever exists, so each "rollback" discarded the entire file and re-fetched it from scratch (a 2.46 GB Odysee video looped until the rollback cap, leaving no audio behind), and a cancel landing mid-loop was deferred behind the next full re-download. Now the final-probe path is bounded: a malformed final probe triggers **exactly one** re-download; if it's still malformed, the orchestrator **keeps the downloaded container on disk** (for inspection) and records a new terminal **`corrupt-full-source`** status — re-downloading a complete file can't change a deterministic verdict. This is distinct from `failed-corrupt-source` (a download that never finished, driven by the checkpoint rollback cap). Surfaced as a new **`corruptFullSource`** channel-snapshot bucket → a Download-stage summary line ("corrupt full source (file kept)"), an **amber download-dot** + `corrupt_full_source` row status in the per-channel video list (folded into the *No audio* filter), and a **"Corrupt full source — download completed but audio is malformed (file kept)"** badge on the video page. It's terminal and **not** retried (auto-runner treats it as skipped; the kept file is an artifact, so it isn't re-queued) and **not** counted as a failed transcription or a transcode-pending video. Cancellation is also fixed: a pending abort now wins over an in-flight rollback decision, and the loop re-checks the abort signal after the final probe so Cancel stops it promptly. See `common/ytdlp/audioCheckedDownload.ts`, `common/ytdlp/downloadOneManaged.ts`, `common/lib/downloadOutcome.ts`, `common/controller/{autoRunner.ts,channelSnapshot.ts}`, `common/ytdlp/runYtdlp.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/components/VideoPanel.tsx}`, and `editor/e2e/audio-check-scenarios.spec.ts`.
- **The Deploy page is reworked around a clearer build/deploy lifecycle, with one-click build-then-deploy and batch multi-site builds.** The page now reads top-to-bottom as you'd actually ship: **Release notes** (the `## [Unreleased]` changelog preview + Cut release) → **Build & deploy** → optional **Individual steps** → **Build multiple sites**. A new **Build & deploy** button runs the build and, only if it succeeds (and wasn't cancelled), deploys it — as a single managed job with one combined streamed log and one Cancel (`buildAndDeployAction`, a composite `runManagedFunction`; cancelling mid-build skips the deploy). The new **Build multiple sites** panel kicks off a build (optionally build+deploy) for several sites at once, each rendered as its own live status-chipped log lane (`BuildSitesPanel` + `JobLane`); in Basic mode the jobs serialize on the shared build/deploy queue (the `export/` output tree is shared), with a note that true parallelism arrives with Docker mode. A **Build mode** toggle (Basic | Docker) on the page persists the choice as the default (`settings.buildPipeline`, also editable on Settings); Docker mode is a follow-up and currently falls back to a basic build with an inline notice. The build/deploy commands now share a child-streaming helper (`common/jobs/runChild.ts`) and mode-routing core (`editor/app/deploy/buildDeployCore.ts`). See `editor/app/deploy/{page.tsx,buildAction usage,components/*}`, `editor/app/build/buildAction.ts`, and `common/lib/settings.ts`.
- **Truncated transcripts are now detected and flagged for re-download.** When an audio download silently stops early (yt-dlp exits `ok`, `download-outcome.json` records success), whisper transcribes only the few minutes that landed — so a 2h22m video ends up with a ~7-minute transcript and nothing warns you. A new coverage check (last cue end ÷ video duration) flags any non-livestream video ≥10min whose transcript covers <50% of its runtime. The single source of truth is `common/lib/transcriptCoverage.ts` (`transcriptCoverage` + `isIncompleteTranscript`, with named thresholds), read from each video's `transcript.cues.json` so the existing corpus is flagged with no migration. Surfaced everywhere: a new **`incompleteTranscript`** channel-snapshot bucket → an **"Incomplete transcript"** filter chip and an **amber transcribed-dot** in the per-channel video list; a warning banner on the video page ("Transcript covers 6:52 of 2:22:21 (4.8%)…") with a one-click **Re-download & re-transcribe** button; and an **"Channels with incomplete (truncated) transcripts"** section on `/actionable`. The fix action (`redownloadIncompleteTranscriptAction`) deletes the truncated audio first, then re-downloads and re-transcribes — re-running whisper alone would just reproduce the short transcript. See `common/controller/channelSnapshot.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/{components/VideoPanel.tsx,videoActions.ts,page.tsx},page.tsx}`, and `editor/app/actionable/{lib/loadActionable.ts,page.tsx}`.
diff --git a/editor/e2e/download-format-guard.spec.ts b/editor/e2e/download-format-guard.spec.ts
@@ -0,0 +1,191 @@
+// Download-time duration guard + selectable / per-source download format.
+//
+// The guard compares a freshly-downloaded audio file's actual duration (via
+// ffprobe) to the video's metadata duration; a large shortfall is recorded as
+// "failed-short-audio" BEFORE transcription, the stub is kept on disk, and the
+// video is surfaced (channel filter, per-video banner, actionable section).
+// The format selector is platform-aware: Odysee "auto" downloads `original`.
+//
+// The fake yt-dlp embeds a `__DUR=120__` marker in audio for ids containing
+// `truncaud` (fake-ffprobe reports 120s); ids with `longvideo` get a 6000s
+// metadata duration; ids with `odyseevid` advertise the lbry extractor. The
+// fake logs the resolved `-f` selector to fake-ytdlp.invocations.
+
+import { mkdir, writeFile, readFile } from "node:fs/promises";
+import { test, expect } from "@playwright/test";
+import { baseUrl } from "./baseUrl";
+import {
+ pathExists,
+ readJson,
+ resetData,
+ resolvePath,
+} from "./helpers";
+
+type DownloadOutcome = {
+ status: string;
+ shortAudio?: {
+ audioDurationSec: number;
+ expectedDurationSec: number;
+ coverage: number;
+ };
+};
+
+async function invalidate() {
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
+}
+
+// Create a transcribe-handling channel (no audio-check → the -x extraction
+// path) with a playlist of the given video ids.
+async function makeChannel(
+ slug: string,
+ ids: string[],
+ config: Record<string, unknown> = {},
+): Promise<void> {
+ const root = resolvePath(`test-transcripts/channels/${slug}`);
+ await mkdir(`${root}/data`, { recursive: true });
+ await writeFile(
+ `${root}/config.json`,
+ JSON.stringify({
+ handling: "transcribe",
+ name: slug,
+ platform: "youtube",
+ url: `https://www.youtube.com/@${slug}`,
+ audioFormat: "mp3",
+ ...config,
+ }),
+ );
+ await writeFile(
+ `${root}/playlist`,
+ ids.map((id) => `https://www.youtube.com/watch?v=${id}\n`).join(""),
+ );
+ await invalidate();
+}
+
+async function downloadAll(page: import("@playwright/test").Page, slug: string) {
+ await page.goto(`/channels/${slug}`);
+ await page.getByRole("button", { name: "Download videos" }).click();
+ const log = page.getByLabel("Download videos output");
+ await expect(log).toContainText("download complete", { timeout: 30_000 });
+}
+
+async function outcome(slug: string, id: string): Promise<DownloadOutcome> {
+ return readJson<DownloadOutcome>(
+ `test-transcripts/channels/${slug}/data/${id}/download-outcome.json`,
+ );
+}
+
+async function waitForOutcome(
+ slug: string,
+ id: string,
+): Promise<DownloadOutcome> {
+ let last: DownloadOutcome | null = null;
+ await expect
+ .poll(
+ async () => {
+ try {
+ last = await outcome(slug, id);
+ return last.status;
+ } catch {
+ return null;
+ }
+ },
+ { timeout: 30_000 },
+ )
+ .not.toBe(null);
+ return last!;
+}
+
+test("duration guard flags a source-truncated download and keeps the stub", async ({
+ page,
+}) => {
+ const SLUG = "guard-trips";
+ // truncaud + longvideo: 6000s metadata, 120s audio → 2% → short.
+ const TRUNC = "truncaudlongvideo1";
+ // longvideo only: 6000s metadata, full audio → passes the guard.
+ const FULL = "fulllongvideo1";
+ await resetData();
+ await makeChannel(SLUG, [TRUNC, FULL]);
+ await downloadAll(page, SLUG);
+
+ // Truncated download: recorded failed-short-audio, audio KEPT, NOT transcribed.
+ const truncOutcome = await waitForOutcome(SLUG, TRUNC);
+ expect(truncOutcome.status).toBe("failed-short-audio");
+ expect(truncOutcome.shortAudio?.expectedDurationSec).toBe(6000);
+ expect(truncOutcome.shortAudio?.audioDurationSec).toBe(120);
+ expect(
+ await pathExists(`test-transcripts/channels/${SLUG}/data/${TRUNC}/audio.mp3`),
+ ).toBe(true);
+ expect(
+ await pathExists(
+ `test-transcripts/channels/${SLUG}/data/${TRUNC}/transcript.json`,
+ ),
+ ).toBe(false);
+
+ // The full-length download passed the guard (not flagged).
+ const fullOutcome = await waitForOutcome(SLUG, FULL);
+ expect(fullOutcome.status).not.toBe("failed-short-audio");
+
+ // Channel list: the short_audio chip filters to exactly the truncated video.
+ const list = page.getByLabel("videos", { exact: true });
+ await expect
+ .poll(
+ async () => {
+ await page.goto(`/channels/${SLUG}?filter=short_audio`);
+ return list.getByLabel(`open ${TRUNC}`).count();
+ },
+ { timeout: 15_000 },
+ )
+ .toBe(1);
+ await expect(list.getByLabel(`open ${FULL}`)).toBeHidden();
+ // The kept stub is excluded from auto-transcribe (downloadedNoTranscript).
+ await page.goto(`/channels/${SLUG}?filter=downloaded_no_transcript`);
+ await expect(list.getByLabel(`open ${TRUNC}`)).toBeHidden();
+
+ // Video page: the short-audio banner offers a re-download as Original.
+ await page.goto(`/channels/${SLUG}/videos/${TRUNC}`);
+ const banner = page.getByLabel("short audio");
+ await expect(banner).toBeVisible();
+ await expect(
+ banner.getByRole("button", { name: /Re-download as Original/ }),
+ ).toBeVisible();
+
+ // Actionable: the channel is listed under truncated downloads.
+ await page.goto(`/actionable`);
+ const section = page.getByRole("region", { name: "short-audio", exact: true });
+ await expect(section).toBeVisible();
+ await expect(section.getByLabel(`short-audio row ${SLUG}`)).toBeVisible();
+});
+
+test("download format: Odysee auto picks original; YouTube uses bestaudio; channel override forces original", async ({
+ page,
+}) => {
+ const SLUG = "fmt-auto";
+ // odyseevid → lbry extractor → auto resolves "odysee" → original.
+ const ODY = "odyseevidlongvideo2";
+ // plain youtube → bestaudio/worst.
+ const YT = "ytlongvideo2";
+ await resetData();
+ await makeChannel(SLUG, [ODY, YT]);
+ await downloadAll(page, SLUG);
+ await waitForOutcome(SLUG, ODY);
+ await waitForOutcome(SLUG, YT);
+
+ const invocations = await readFile(
+ resolvePath(`test-transcripts/channels/${SLUG}/fake-ytdlp.invocations`),
+ "utf8",
+ );
+ expect(invocations).toContain("format:original/bestaudio/worst");
+ expect(invocations).toContain("format:bestaudio/worst");
+
+ // A per-channel downloadFormat override forces original even for YouTube.
+ const SLUG2 = "fmt-override";
+ const YT2 = "ytlongvideo3";
+ await makeChannel(SLUG2, [YT2], { downloadFormat: "original" });
+ await downloadAll(page, SLUG2);
+ await waitForOutcome(SLUG2, YT2);
+ const inv2 = await readFile(
+ resolvePath(`test-transcripts/channels/${SLUG2}/fake-ytdlp.invocations`),
+ "utf8",
+ );
+ expect(inv2).toContain("format:original/bestaudio/worst");
+});
diff --git a/editor/e2e/fixtures/bin/fake-ffprobe.mjs b/editor/e2e/fixtures/bin/fake-ffprobe.mjs
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+// E2E fake ffprobe. The duration guard invokes:
+// ffprobe -v error -show_entries format=duration -of default=...:nokey=1 <file>
+// We report a duration read from a marker the fake-ytdlp embeds in the audio
+// file (`__DUR=<seconds>__`). A file with no marker reports a large duration so
+// the guard never falsely trips on it (matches a full-length download).
+import { readFile } from "node:fs/promises";
+
+const argv = process.argv.slice(2);
+// The input file is the only non-flag argument (and not a value of -show_entries
+// / -of). It's the last positional in the guard's invocation.
+const file = argv[argv.length - 1];
+
+async function main() {
+ if (!file || file.startsWith("-")) {
+ process.stderr.write("[fake-ffprobe] missing input file\n");
+ process.exit(1);
+ }
+ let buf;
+ try {
+ buf = await readFile(file, "utf8");
+ } catch {
+ process.stderr.write(`[fake-ffprobe] cannot read ${file}\n`);
+ process.exit(1);
+ }
+ const m = buf.match(/__DUR=([0-9]+(?:\.[0-9]+)?)__/);
+ // No marker -> report a long duration so isShortAudio() stays false.
+ const seconds = m ? m[1] : "100000";
+ process.stdout.write(`${seconds}\n`);
+}
+
+main();
diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs
@@ -70,14 +70,21 @@ async function writeMetadata(videoDir, id, opts = {}) {
channel_url: "https://www.youtube.com/channel/UCfake",
uploader: "Fake Channel",
upload_date: "20240101",
- duration: 60,
+ // Default a short clip; the `longvideo` sentinel makes it long enough
+ // (>=600s) to exercise the download-time duration guard.
+ duration: opts.longDuration ? 6000 : 60,
description: `Synthetic video ${id}`,
is_live: false,
was_live: false,
live_status: "not_live",
age_limit: 0,
- extractor_key: "Youtube",
- webpage_url: `https://www.youtube.com/watch?v=${id}`,
+ // The `odyseevid` sentinel makes the metadata advertise the lbry extractor
+ // so platformFromMetadata() resolves "odysee" (and the "auto" download
+ // format picks `original`). Otherwise it's a normal YouTube video.
+ extractor_key: opts.odysee ? "lbry" : "Youtube",
+ webpage_url: opts.odysee
+ ? `https://odysee.com/${id}`
+ : `https://www.youtube.com/watch?v=${id}`,
};
// For the no-subs-fallback tests we need yt-dlp's metadata to reflect
// whether the video advertises caption tracks. The default (no opts)
@@ -116,6 +123,8 @@ function urlSentinels(url) {
isLive: lower.includes("islive"),
isUpcoming: lower.includes("isupcoming"),
wasLive: lower.includes("waslive"),
+ longDuration: lower.includes("longvideo"),
+ odysee: lower.includes("odyseevid"),
};
}
@@ -153,11 +162,16 @@ async function downloadOne(url, opts = {}) {
await sleep(500);
}
}
- if (!alreadyHasMetadata) await writeMetadata(videoDir, id);
+ if (!alreadyHasMetadata) await writeMetadata(videoDir, id, urlSentinels(url));
if (audioFmt) {
+ // The `truncaud` sentinel simulates a source-truncated download: embed a
+ // short-duration marker the fake-ffprobe reports, so the duration guard
+ // trips even though the "download" completed. Unmarked audio reports a long
+ // duration (no marker), so it passes the guard.
+ const durMarker = id.toLowerCase().includes("truncaud") ? " __DUR=120__" : "";
await writeFile(
path.join(videoDir, `audio.${audioFmt}`),
- `fake-ytdlp synthesised audio for ${id}\n`,
+ `fake-ytdlp synthesised audio for ${id}${durMarker}\n`,
);
} else {
await writeTranscript(videoDir);
@@ -201,6 +215,9 @@ async function modeDownloadFromFile(file, opts = {}) {
async function modeDownloadOneUrl(url, opts) {
process.stdout.write(`[fake-ytdlp] single-url download ${url}\n`);
await appendFile("fake-ytdlp.invocations", `download-one:${url}\n`);
+ // Record the resolved -f selector so format-selection tests can assert it
+ // (e.g. Odysee "auto" -> original/bestaudio/worst).
+ await appendFile("fake-ytdlp.invocations", `format:${arg("-f") ?? ""}\n`);
await downloadOne(url, opts);
process.stdout.write(`[fake-ytdlp] download complete\n`);
}
@@ -528,8 +545,10 @@ async function main() {
return;
}
- // Audio-check mode: no -x but has -f bestaudio/worst and -c.
- if (has("-c") && arg("-f") === "bestaudio/worst") {
+ // Audio-check mode: no -x but has -c and a bestaudio-style -f selector. The
+ // selector may be "bestaudio/worst" or, for Odysee, "original/bestaudio/worst"
+ // (the platform-aware download-format default), so match on the substring.
+ if (has("-c") && (arg("-f") ?? "").includes("bestaudio")) {
const url = lastNonFlag();
if (!url) {
process.stderr.write(`[fake-ytdlp] audio-check mode missing URL\n`);
diff --git a/editor/package.json b/editor/package.json
@@ -5,8 +5,8 @@
"type": "module",
"scripts": {
"dev": "next dev --port ${EDITOR_PORT:-3001}",
- "dev:test": "WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 next dev --port ${PORT:-3011}",
- "start:test": "WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 next start --port ${PORT:-3011}",
+ "dev:test": "WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 next dev --port ${PORT:-3011}",
+ "start:test": "WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 next start --port ${PORT:-3011}",
"build": "next build",
"start": "next start --port ${EDITOR_PORT:-3001}",
"lint": "eslint",