Archilyzer · Source

archilyzer

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

commit 09a6a08f9e6115ed45acd845d126896236dd3e94
parent c7811cf6f643901ed5cd9c09cdf0c7bd687d428e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 18 Jun 2026 16:34:41 -0400

Stop and prevent downloads when disk space is low

Add a configurable minimum-free-disk floor (Settings → Minimum free
disk space, default 5 GB; 0 disables) that guards downloads against
filling the disk:

- Prevent: a preflight check returns a clear "Low disk space" error
  before a download/import job is queued (store-playlist is exempt).
- Stop: the serialized per-video download loop checks free space before
  each video (alongside the existing abort/drain checks) and, when low,
  stops launching new videos — the in-flight one finishes and the batch
  ends cleanly with partial progress preserved.
- Visibility: /api/jobs/active gains a disk status and the monitor
  widget shows free space, turning red ("downloads paused") below the
  floor.

Free space is measured with native fs.statfs (no new dependency) and
the check fails open — an unreadable filesystem reads as infinite free
space so a glitch never wrongly blocks downloads; the statfs is skipped
entirely when the gate is disabled.

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

Diffstat:
Acommon/lib/diskSpace.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/format.ts | 1+
Mcommon/lib/settings.ts | 21+++++++++++++++++++++
Mcommon/ytdlp/runYtdlp.ts | 16++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/[slug]/pipelineActions.ts | 29+++++++++++++++++++++++++++++
Meditor/app/jobs/active/buildActiveJobs.ts | 24+++++++++++++++++++++++-
Meditor/app/settings/actions.ts | 14++++++++++++++
Meditor/app/settings/components/SettingsForm.tsx | 7+++++++
Meditor/app/widget/components/MonitorWidget.tsx | 43++++++++++++++++++++++++++++++++++++++++---
Aeditor/e2e/disk-space.spec.ts | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/fixtures/test-settings.default.json | 3++-
12 files changed, 296 insertions(+), 5 deletions(-)

diff --git a/common/lib/diskSpace.ts b/common/lib/diskSpace.ts @@ -0,0 +1,54 @@ +import { statfs } from "node:fs/promises"; +import type { Paths } from "./paths"; +import type { SiteSettings } from "./settings"; + +const BYTES_PER_GB = 1024 ** 3; + +// Free space (bytes) available to an unprivileged process on the filesystem +// holding `dir`. Fail-open: any error (statfs unsupported, path missing, +// permissions) reports Infinity so a measurement glitch never blocks a +// download. statfs reports blocks in `bsize`-sized units; `bavail` excludes +// blocks reserved for root, which is what a normal write can actually use. +export async function getFreeBytes(dir: string): Promise<number> { + try { + const stats = await statfs(dir); + return stats.bsize * stats.bavail; + } catch { + return Number.POSITIVE_INFINITY; + } +} + +export type DiskSpaceStatus = { + // Whether the low-disk gate is configured at all (minFreeDiskGB > 0). + enabled: boolean; + freeBytes: number; + thresholdBytes: number; + // True when there is enough free space (or the gate is disabled). When false, + // downloads should be prevented from starting and a running batch should stop + // starting new videos. + ok: boolean; +}; + +// Measure free space on the transcripts data directory (where all downloads +// land) and compare it against the configured floor. When the gate is disabled +// (minFreeDiskGB === 0) this always reports ok. +export async function checkDiskSpace( + paths: Paths, + settings: SiteSettings, +): Promise<DiskSpaceStatus> { + const enabled = settings.minFreeDiskGB > 0; + const thresholdBytes = settings.minFreeDiskGB * BYTES_PER_GB; + // Skip the statfs syscall entirely when the gate is off — this runs before + // every video in a batch and on every active-jobs poll, and a disabled gate + // hides the indicator anyway. + if (!enabled) { + return { enabled, freeBytes: Number.POSITIVE_INFINITY, thresholdBytes, ok: true }; + } + const freeBytes = await getFreeBytes(paths.transcriptsDir); + return { + enabled, + freeBytes, + thresholdBytes, + ok: freeBytes >= thresholdBytes, + }; +} diff --git a/common/lib/format.ts b/common/lib/format.ts @@ -4,6 +4,7 @@ export function formatDate(yyyymmdd: string): string { } export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes)) return "∞"; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -61,6 +61,11 @@ export type SiteSettings = { // within one invocation, so without this the managed loop hammers the // source IP back-to-back. 0 disables. Per-channel override available. sleepBetweenDownloadsSeconds: number; + // Minimum free disk space (GB) required on the transcripts data directory for + // downloads to run. When free space is below this floor, a download job is + // prevented from starting and a running batch stops launching new videos + // (the in-flight one finishes). 0 disables the gate. See common/lib/diskSpace.ts. + minFreeDiskGB: number; // Default number of videos transcribed in parallel when a "Transcribe // missing" / bucket run doesn't specify its own concurrency. The per-run // Concurrency input in the channel UI overrides this for a single run. @@ -129,6 +134,9 @@ export type SocialLink = { export const SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS = 600; export const SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS = 10; +export const MIN_FREE_DISK_GB_DEFAULT = 5; +export const MIN_FREE_DISK_GB_MAX = 100000; + export const PARALLEL_TRANSCRIPTIONS_MAX = 16; export const PARALLEL_TRANSCRIPTIONS_DEFAULT = 2; @@ -253,6 +261,7 @@ function defaults(): SiteSettings { workers: [], cookiesFromBrowser: "", sleepBetweenDownloadsSeconds: SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS, + minFreeDiskGB: MIN_FREE_DISK_GB_DEFAULT, parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT, inlineTranscribeOnFallback: false, skipLiveDownloads: true, @@ -327,6 +336,16 @@ export function clampSleepBetweenDownloadsSeconds(value: unknown): number { return n; } +export function clampMinFreeDiskGB(value: unknown): number { + const n = + typeof value === "number" && Number.isFinite(value) + ? Math.floor(value) + : MIN_FREE_DISK_GB_DEFAULT; + if (n < 0) return 0; + if (n > MIN_FREE_DISK_GB_MAX) return MIN_FREE_DISK_GB_MAX; + return n; +} + export function clampParallelTranscriptions(value: unknown): number { const n = typeof value === "number" && Number.isFinite(value) @@ -393,6 +412,7 @@ export function getSettings(): SiteSettings { merged.sleepBetweenDownloadsSeconds = clampSleepBetweenDownloadsSeconds( merged.sleepBetweenDownloadsSeconds, ); + merged.minFreeDiskGB = clampMinFreeDiskGB(merged.minFreeDiskGB); merged.parallelTranscriptions = clampParallelTranscriptions( merged.parallelTranscriptions, ); @@ -565,6 +585,7 @@ export async function writeSettings(next: SiteSettings): Promise<void> { sleepBetweenDownloadsSeconds: clampSleepBetweenDownloadsSeconds( next.sleepBetweenDownloadsSeconds, ), + minFreeDiskGB: clampMinFreeDiskGB(next.minFreeDiskGB), parallelTranscriptions: clampParallelTranscriptions( next.parallelTranscriptions, ), diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts @@ -10,6 +10,8 @@ import { type ChannelHandling, } from "../lib/channelConfig"; import { getSettings } from "../lib/settings"; +import { checkDiskSpace } from "../lib/diskSpace"; +import { formatBytes } from "../lib/format"; import { detectPlatform } from "../lib/platform"; import type { Paths } from "../lib/paths"; import { @@ -523,6 +525,10 @@ async function runManagedDownloads( const abortOnError = opts.abortOnError !== false; let firstFailure: Error | null = null; let processedCount = 0; + // Set once free disk falls below the configured floor: like a drain, the + // in-flight download finishes but no new ones start, so the batch ends + // cleanly (status "done") with partial progress preserved on disk. + let lowDiskStopped = false; await Promise.all( urls.map((url) => @@ -531,6 +537,16 @@ async function runManagedDownloads( // Drain (soft-cancel): finish the in-flight download, start no more. if (opts.drainSignal?.aborted) return; if (firstFailure && abortOnError) return; + if (lowDiskStopped) return; + const disk = await checkDiskSpace(opts.paths, settings); + if (!disk.ok) { + lowDiskStopped = true; + opts.onLog( + `Stopping batch: ${formatBytes(disk.freeBytes)} free is below ` + + `the ${settings.minFreeDiskGB} GB disk floor. Remaining videos skipped.\n`, + ); + return; + } const task = opts.tracker?.start({ id: extractVideoId(url) ?? url, label: extractVideoId(url) ?? url, diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Downloads stop and stay blocked when free disk space runs low.** A new **Settings → Minimum free disk space (GB)** floor (default **5 GB**; set **0** to disable) guards every download against filling the disk. When free space on the transcripts data directory is at or below the floor, a download job is **prevented from starting** — the channel/import action returns a clear "Low disk space: X free, Y required" error instead of queuing — and a **running batch stops between videos**: the in-flight download finishes, no new ones start, and the batch ends cleanly (status *done*, partial progress preserved) rather than crashing into an out-of-space error mid-file. Free space is measured natively (`statfs`, no new dependency) and the check **fails open** — if it can't read the filesystem, downloads proceed rather than being wrongly blocked. The monitor widget (and `/api/jobs/active`) gained a compact disk indicator showing free space, which turns red and reads "downloads paused" when below the floor (and stays visible even when idle, so it explains why nothing is downloading). `store-playlist` (which writes no media) is not gated. See `common/lib/diskSpace.ts`. - **New read-only monitor widget (`/widget`) plus a builder to compose and embed it.** A compact, chrome-less page shows worker status (a colored idle/busy/draining/disabled/degraded dot per worker) and active-job progress bars at a glance — no sidebar, no command palette, and no action controls — so it fits in a small pinned window or an `<iframe>` for at-a-glance monitoring. It reuses the existing `/api/jobs/active` and `/api/workers` endpoints (polled live), and its initial paint is server-rendered for no flicker. What it shows is driven entirely by GET params: `jobs`/`workers` (toggle each section), `channel` (filter active jobs to one slug), `poll` (refresh seconds), `compact` (drop per-task detail), `titles` (section headers), and `idle=hide` (collapse to a tiny "Idle" line when nothing is active). A new **Monitor** page under the sidebar's **Pool** group (`/widget/builder`) exposes all of those as form controls, builds the shareable link with a **Copy** button, an **Open popup** button that launches the widget in a chrome-less `window.open` popup (a tab-less window) at the selected preview size, and live-previews the real widget in a sized iframe. To strip the app shell on exactly the widget route, the root layout now renders its sidebar/command-palette/auto-refresh through a small `AppFrame` client wrapper that hides them when the path is `/widget` (the builder keeps the normal shell). The per-worker payload builder shared by the Workers page and `/api/workers` was extracted to `buildWorkersPayload()` so the widget reuses it too. - **The channel video selector can bulk-remove audio files and wrong-format audio, and a new channel-wide sweep clears wrong-format audio in one click.** The selector pane's bulk action picker (channel page → video list) gained two operations, both pure filesystem ops that queue no job (so they never trigger a transcode). **Remove audio files** deletes each checked video's finalized `audio.<ext>` files while keeping transcripts, metadata, and any in-progress `.part` download (which can still resume). **Remove wrong-format audio** deletes only audio files that aren't the channel's target format — e.g. the `audio.m4a` / `audio.mp4` leftovers from downloads that failed yt-dlp's extract-to-`mp3` step *before* audio-integrity checking existed — even when that's a video's only audio, so it re-downloads cleanly. A matching **Select wrong-format** quick-select (shown when any such videos exist) checks exactly those videos, so the cleanup is one flow: **Select wrong-format** → action **Remove wrong-format audio** → **Apply** (each is confirmed first). For whole-channel cleanup, the channel page's **Cleanup** stage gained a **Remove wrong-format audio** section: a `type "remove" to confirm` sweep that walks every video dir and deletes all non-target audio — including the failed-extract orphans the existing **Clean extra audio formats** deliberately skips (it only de-dupes extras when the target file already exists). The sweep respects per-video **do not clean** markers and shows a reclaim estimate; the bulk action, being an explicit selection, removes regardless of the marker. - **The channel video selector can bulk-delete directories and clear failure markers, and its action bar is now an action picker.** The selector pane's bulk bar (channel page → video list) previously stacked separate Transcribe / Retry / Mark-untranscribable buttons; it's now a single **Action** dropdown + **Apply** button that also exposes two new operations. **Delete directories** removes each checked video's directory outright (`fs.rm` recursive) — a pure filesystem op that queues no job, so it never triggers a transcode the way leaving failed downloads in place can; this is the quick way to clear a batch of failed downloads. It's gated by an inline *type `delete` to confirm* box (the Apply button stays disabled until matched), mirroring the Clean-extra-formats pattern. **Clear failed markers** prunes the selected ids from the channel's `failed-transcriptions` and `failed-transcodings` files so they're retried on the next pass. Two quick-select helpers, **Select failed** (every video listed in either failure file) and **Select filtered** (every row matching the current filter + search), make the cleanup one flow: filter **Failed** → **Select failed** → action **Delete directories** → type `delete` → **Apply**. Both new actions report a per-id success/failure summary and refresh the channel report like the existing bulk actions. diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts @@ -18,11 +18,32 @@ import { import { extractVideoId, runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdlp"; import { downloadOneManaged } from "yt-dlp-transcript-common/ytdlp/downloadOneManaged"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { checkDiskSpace } from "yt-dlp-transcript-common/lib/diskSpace"; +import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks"; +import type { Paths } from "yt-dlp-transcript-common/lib/paths"; + +// Preflight disk-space gate: keep a download job from even starting when free +// space is already below the configured floor. Returns an error result to +// surface immediately, or null to proceed. The running batch separately stops +// between videos via the gate in runManagedDownloads (common/ytdlp/runYtdlp.ts). +async function lowDiskError( + paths: Paths, +): Promise<{ ok: false; error: string } | null> { + const disk = await checkDiskSpace(paths, getSettings()); + if (disk.ok) return null; + return { + ok: false, + error: + `Low disk space: ${formatBytes(disk.freeBytes)} free, ` + + `${formatBytes(disk.thresholdBytes)} required. Free up space or ` + + `lower the floor in Settings.`, + }; +} async function runPipelineAction( slug: string, @@ -52,6 +73,12 @@ async function runPipelineAction( if (!channelConfig.url) { return { ok: false, error: "Channel has no `url` configured" }; } + // store-playlist only fetches the video list (no media written), so it is not + // gated; every other mode downloads audio/subtitles into transcriptsDir. + if (mode !== "store-playlist") { + const err = await lowDiskError(paths); + if (err) return err; + } return runManagedFunction({ kind, queueKey: resolveQueueKey(downloadQueueKey(channelConfig), queueKey), @@ -229,6 +256,8 @@ export async function importVideoAction( // downloadOneManaged falls back to %(id)s and the reconcile pass repairs the // dir, so we don't hard-fail here. const videoId = extractVideoId(videoUrl) ?? undefined; + const err = await lowDiskError(paths); + if (err) return err; const settings = getSettings(); return runManagedFunction({ kind: "import-one", diff --git a/editor/app/jobs/active/buildActiveJobs.ts b/editor/app/jobs/active/buildActiveJobs.ts @@ -9,11 +9,25 @@ import { type ChannelStat, } from "yt-dlp-transcript-common/controller/channels"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { checkDiskSpace } from "yt-dlp-transcript-common/lib/diskSpace"; import type { RunningJobsListItem } from "../components/RunningJobsList"; +export type DiskStatusView = { + // Whether the low-disk gate is configured (minFreeDiskGB > 0). When false the + // UI hides the indicator entirely. + enabled: boolean; + freeBytes: number; + thresholdBytes: number; + // True when the gate is enabled and free space is at/below the floor — i.e. + // downloads are currently being blocked. + low: boolean; +}; + export type ActiveJobsPayload = { jobs: RunningJobsListItem[]; channels: { slug: string; displayName: string }[]; + disk: DiskStatusView; }; // Estimate seconds remaining as: remaining tasks × average measured task @@ -129,5 +143,13 @@ export async function buildActiveJobsPayload(): Promise<ActiveJobsPayload> { displayName: channelStats.get(slug)?.config.name ?? slug, })); - return { jobs, channels }; + const diskStatus = await checkDiskSpace(paths, getSettings()); + const disk: DiskStatusView = { + enabled: diskStatus.enabled, + freeBytes: diskStatus.freeBytes, + thresholdBytes: diskStatus.thresholdBytes, + low: diskStatus.enabled && !diskStatus.ok, + }; + + return { jobs, channels, disk }; } diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -6,6 +6,7 @@ import { AUTO_REFRESH_INTERVAL_MIN_SECONDS, DEFAULT_REPORT_DEBOUNCE_PRESET, isReportDebouncePreset, + MIN_FREE_DISK_GB_MAX, normalizeSocialSvg, parseSocialLinks, PARALLEL_TRANSCRIPTIONS_DEFAULT, @@ -40,6 +41,7 @@ export async function saveSettingsAction( const autoRefreshRaw = String( formData.get("autoRefreshIntervalSeconds") ?? "", ).trim(); + const minFreeDiskRaw = String(formData.get("minFreeDiskGB") ?? "").trim(); const inlineTranscribeOnFallback = formData.get("inlineTranscribeOnFallback") === "on"; const skipLiveDownloads = formData.get("skipLiveDownloads") === "on"; @@ -111,6 +113,17 @@ export async function saveSettingsAction( }; } + const minFreeDiskParsed = Number.parseInt(minFreeDiskRaw, 10); + if (!Number.isFinite(minFreeDiskParsed)) { + return { ok: false, error: "minFreeDiskGB must be a number" }; + } + if (minFreeDiskParsed < 0 || minFreeDiskParsed > MIN_FREE_DISK_GB_MAX) { + return { + ok: false, + error: `minFreeDiskGB must be between 0 and ${MIN_FREE_DISK_GB_MAX}`, + }; + } + // Sync-scheduler block. Values are clamped by sanitizeSyncScheduler inside // writeSettings, so we only coerce here (NaN/blank fall back to defaults). const intOrNaN = (key: string): number => @@ -168,6 +181,7 @@ export async function saveSettingsAction( workers, cookiesFromBrowser, sleepBetweenDownloadsSeconds: sleepParsed, + minFreeDiskGB: minFreeDiskParsed, parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT, inlineTranscribeOnFallback, skipLiveDownloads, diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx @@ -76,6 +76,13 @@ export function SettingsForm({ initial, apps }: Props) { hint="Pause inserted between per-video yt-dlp invocations in managed batch downloads (download-from-playlist, download-missing). 0 disables. Default 10s. Each channel can override this in its Advanced settings." /> <Field + label="Minimum free disk space (GB)" + name="minFreeDiskGB" + defaultValue={String(initial.minFreeDiskGB)} + type="number" + hint="Downloads are prevented from starting, and a running batch stops launching new videos, when free space on the transcripts directory falls below this floor. Default 5 GB. Set to 0 to disable the gate." + /> + <Field label="Auto-refresh interval (seconds)" name="autoRefreshIntervalSeconds" defaultValue={String(initial.autoRefreshIntervalSeconds)} diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -1,8 +1,11 @@ "use client"; import { useEffect, useState } from "react"; -import { formatDuration } from "yt-dlp-transcript-common/lib/format"; -import type { ActiveJobsPayload } from "../../jobs/active/buildActiveJobs"; +import { formatDuration, formatBytes } from "yt-dlp-transcript-common/lib/format"; +import type { + ActiveJobsPayload, + DiskStatusView, +} from "../../jobs/active/buildActiveJobs"; import type { RunningJobsListItem } from "../../jobs/components/RunningJobsList"; import type { WorkersPayload, WorkerView } from "../../workers/components/WorkersView"; import type { WidgetConfig } from "../lib/config"; @@ -82,12 +85,15 @@ export function MonitorWidget({ (j) => !config.channel || j.channelSlug === config.channel, ); const workers = workersPayload?.workers ?? []; + const disk = jobsPayload?.disk; const nothingActive = (!config.jobs || jobs.length === 0) && (!config.workers || workers.every((w) => !w.busy)); - if (config.hideIdle && nothingActive) { + // A low-disk warning is worth showing even when nothing is running, since it + // explains why no downloads start — so it overrides hideIdle. + if (config.hideIdle && nothingActive && !disk?.low) { return ( <div className="p-2 text-xs text-zinc-500" aria-label="monitor idle"> Idle @@ -97,6 +103,7 @@ export function MonitorWidget({ return ( <div className="flex flex-col gap-3 p-2 text-zinc-900 dark:text-zinc-100"> + {disk?.enabled && <DiskStrip disk={disk} />} {config.workers && ( <WorkersStrip workers={workers} @@ -184,6 +191,36 @@ function WorkersStrip({ ); } +// Compact free-disk indicator. Green when above the floor, red when at/below it +// (downloads blocked). Only rendered when the gate is enabled. +function DiskStrip({ disk }: { disk: DiskStatusView }) { + return ( + <section + aria-label="Disk space" + className={`flex items-center gap-2 rounded border px-2 py-1 text-xs ${ + disk.low + ? "border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-950 text-red-800 dark:text-red-200" + : "border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-600 dark:text-zinc-300" + }`} + > + <span + className={`inline-block h-2 w-2 shrink-0 rounded-full ${ + disk.low ? "bg-red-500" : "bg-green-500/60" + }`} + /> + <span> + {formatBytes(disk.freeBytes)} free + {disk.low && ( + <> + {" "} + — below {formatBytes(disk.thresholdBytes)} floor; downloads paused + </> + )} + </span> + </section> + ); +} + function ActiveJobsStrip({ jobs, showTitle, diff --git a/editor/e2e/disk-space.spec.ts b/editor/e2e/disk-space.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from "@playwright/test"; +import { pathExists, resetData, writeSettings } from "./helpers"; + +// The low-disk gate (common/lib/diskSpace.ts) is driven by the +// minFreeDiskGB setting. e2e can't force the filesystem to fill up, so these +// tests pin the threshold absurdly high (so the real test disk always reads as +// "below floor") to exercise the blocked path, and 0 to exercise the disabled +// path. The per-video stop gate in runManagedDownloads shares the same +// checkDiskSpace as the preflight — with a static threshold the preflight +// always intercepts first, so a mid-batch stop only happens on a real-time +// drop, which isn't reproducible here; the preflight + disabled paths cover the +// shared logic. + +const HUGE_FLOOR_GB = 100000; // == MIN_FREE_DISK_GB_MAX; far above any real disk + +test("prevents a download from starting when free disk is below the floor", async ({ + page, +}) => { + await resetData("test-pipeline"); + await writeSettings({ minFreeDiskGB: HUGE_FLOOR_GB }); + + await page.goto("/channels/test-pipeline"); + // The preflight runs before the job is queued (and before the playlist is + // even read), so the action returns an error instead of starting yt-dlp. + await page.getByRole("button", { name: "Download from playlist" }).click(); + await expect( + page.getByLabel("Download from playlist error"), + ).toContainText("Low disk space", { timeout: 15_000 }); + + // Nothing was downloaded. + expect( + await pathExists( + "test-transcripts/channels/test-pipeline/data/fake00000001/metadata.info.json", + ), + ).toBe(false); +}); + +test("lets downloads proceed when the gate is disabled (0)", async ({ + page, +}) => { + test.setTimeout(120_000); + await resetData("test-pipeline"); + await writeSettings({ minFreeDiskGB: 0 }); + + await page.goto("/channels/test-pipeline"); + await page.getByRole("button", { name: "Store playlist" }).click(); + await expect(page.getByLabel("Store playlist output")).toContainText( + "Wrote 5 URLs", + { timeout: 20_000 }, + ); + + await page.getByRole("button", { name: "Download from playlist" }).click(); + await expect( + page.getByLabel("Download from playlist output"), + ).toContainText("Managed download complete", { timeout: 60_000 }); + expect( + await pathExists( + "test-transcripts/channels/test-pipeline/data/fake00000001/metadata.info.json", + ), + ).toBe(true); +}); + +test("active-jobs API and monitor widget report low disk", async ({ page }) => { + await resetData("test-pipeline"); + + // Enabled + below floor → low. + await writeSettings({ minFreeDiskGB: HUGE_FLOOR_GB }); + const lowRes = await page.request.get("/api/jobs/active"); + expect(lowRes.ok()).toBe(true); + const lowPayload = (await lowRes.json()) as { + disk: { enabled: boolean; low: boolean }; + }; + expect(lowPayload.disk.enabled).toBe(true); + expect(lowPayload.disk.low).toBe(true); + + // The monitor widget surfaces the warning. + await page.goto("/widget"); + await expect(page.getByLabel("Disk space")).toContainText("downloads paused"); + + // Disabled (0) → indicator hidden, gate off. + await writeSettings({ minFreeDiskGB: 0 }); + const offRes = await page.request.get("/api/jobs/active"); + const offPayload = (await offRes.json()) as { + disk: { enabled: boolean; low: boolean }; + }; + expect(offPayload.disk.enabled).toBe(false); + expect(offPayload.disk.low).toBe(false); +}); diff --git a/editor/e2e/fixtures/test-settings.default.json b/editor/e2e/fixtures/test-settings.default.json @@ -1,5 +1,6 @@ { "adminTitle": "Test Admin", "maxTranscriptPageBytes": 8388608, - "sleepBetweenDownloadsSeconds": 0 + "sleepBetweenDownloadsSeconds": 0, + "minFreeDiskGB": 0 }