Archilyzer · Source

archilyzer

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

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:
Meditor/CHANGELOG.md | 1+
Aeditor/e2e/download-format-guard.spec.ts | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/fixtures/bin/fake-ffprobe.mjs | 32++++++++++++++++++++++++++++++++
Meditor/e2e/fixtures/bin/fake-ytdlp.mjs | 33++++++++++++++++++++++++++-------
Meditor/package.json | 4++--
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",