commit 67b405fc53ebd0af57a6693486243d57494b2943
parent a941598c8e5cc09ad29068ba12821efd6577cbde
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 18 Jun 2026 11:40:40 -0400
Add bulk delete + clear-failed-markers to channel video selector
Rework the channel video-selector bulk bar into an Action dropdown +
Apply picker and add two synchronous, no-job-queued actions: Delete
directories (validated fs.rm, gated by a type-'delete' confirm) and
Clear failed markers (prunes the failed-transcriptions/-transcodings
files). Add Select-failed / Select-filtered quick-select helpers so a
batch of failed downloads can be removed without triggering a transcode.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 379 insertions(+), 92 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **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.
- **Audio-integrity checks now pause the download while they run, cutting re-downloaded bytes and HTTP 429 risk.** With audio-integrity checking enabled, the downloader periodically snapshots the in-progress `.part` and validates it with ffmpeg. Previously yt-dlp was only paused for the brief *copy* of that snapshot and then resumed immediately, so it kept downloading throughout the (longer) ffmpeg probe — and if the probe came back malformed, every byte pulled during the probe, plus everything back to the last good checkpoint, was discarded and had to be re-fetched. That wasted, repeated fetching is a prime driver of rate-limit (429) responses. Now yt-dlp stays suspended (SIGSTOP) across the whole probe and only resumes on a clean verdict; on a corrupt verdict it's killed while still stopped and rolled back, having downloaded zero throwaway bytes. The trade-off is a briefly idle source connection during each probe (probes are seconds; if a held connection is ever dropped, yt-dlp's own `-c` resume recovers on the next launch). This is the new default; a per-channel **Resume during probe (legacy)** checkbox (channel editor → Audio-integrity checking, `audioCheck.resumeDuringProbe` in `config.json`) restores the old resume-immediately behavior for comparison, and the `AUDIO_CHECK_RESUME_DURING_PROBE` env var overrides it for one-off runs.
- **Channels can sync automatically on a schedule.** Each channel gained an **Auto-sync** setting (channel editor → Source): *Default* (inherit the global cadence), *Off*, or a concrete interval (every 10m / 30m / hourly / 6h / 12h / daily / weekly), stored as `syncIntervalMinutes` in `config.json`. Inspired by the Laravel scheduler, a single lightweight cron heartbeat (`pnpm sync:tick`, an ~30-line client) POSTs to the editor's new `/api/scheduler/tick`, and the **server** decides which channels are due — a channel is due when `now − lastSyncedAt ≥ its interval`, so a missed tick (server down, machine asleep) simply runs at the next one with no catch-up storm. All work runs **inside the editor** through the existing job queue and per-channel lock, so a scheduled sync can't collide with a manual **Sync** click, shows up live on `/jobs`, and feeds the same transcription worker pool — no second process, no new file locks. Global controls live in **Settings → Sync scheduler**: a master **enable** (off by default), a **default interval**, a **max concurrent syncs** cap (a tick queues at most `cap − running` channels, most-overdue first, rolling the rest to the next tick — which both bounds load and staggers a large due-batch so it doesn't hit the source all at once), an optional **quiet-hours** window, and **failure backoff** (after N consecutive failures a channel waits `base·2^(N-1)` minutes, capped, before retrying). Channels already marked **Exclude from sync** never auto-sync. A new **Schedule** page (`/scheduler`) shows each channel's interval, last sync, next-due time, last outcome, and any active backoff, plus a recent-ticks log and a **Run scheduler now** button; the same data is at `GET /api/scheduler/status`. The cron client targets the editor's port (3001) by default and is hardenable with a `SYNC_TICK_TOKEN` bearer token for installs that expose the editor — see `SCHEDULED_SYNC.md`.
- **Sites can link to each other.** A site's form gained a **Public URL** field (the absolute URL it's served at, e.g. `https://jeralyzer.com`) and a **Related sites** section. The export footer automatically links to every *other* site that has a Public URL, so filling these in is all that's needed for cross-site links; a site left without a URL is simply omitted from the lists. The **Related sites** editor lets a site pull closely-related siblings to the front under named groups (e.g. Jeralyzer featuring Rekietalyzer under "MTG drama") — add a group, give it an optional heading, and check which sibling sites belong; everything you don't feature falls into a trailing "Other sites" group on its own. Groups reorder with ↑/↓. The picker only lists sites that actually exist, and featured ids for sites that were since deleted are dropped on save (with a heads-up note). It's a subtle, secondary feature — see the matching note in the export changelog for how it renders.
diff --git a/editor/app/channels/[slug]/bulkVideoActions.ts b/editor/app/channels/[slug]/bulkVideoActions.ts
@@ -2,9 +2,22 @@
import { revalidatePath } from "next/cache";
import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler";
+import {
+ failedTranscriptionsFile,
+ pruneFailedTranscriptions,
+} from "yt-dlp-transcript-common/controller/failedTranscriptions";
+import {
+ failedTranscodingsFile,
+ pruneFailedTranscodings,
+} from "yt-dlp-transcript-common/controller/failedTranscodings";
import { transcribeBucketAction } from "./whisperActions";
import { retryBucketAction } from "./pipelineActions";
-import { markVideoUntranscribableAction } from "./videos/[id]/videoActions";
+import {
+ deleteOneVideoDir,
+ markVideoUntranscribableAction,
+} from "./videos/[id]/videoActions";
export type BulkActionSummary = {
ok: boolean;
@@ -56,3 +69,54 @@ export async function bulkMarkUntranscribableAction(
failures,
};
}
+
+// Deleting a video directory is a pure filesystem op (no queued job), so it
+// reports a per-id summary and never triggers a transcode — the whole point of
+// the bulk delete button.
+export async function bulkDeleteVideoDirsAction(
+ slug: string,
+ videoIds: string[],
+): Promise<BulkActionSummary> {
+ const failures: BulkActionSummary["failures"] = [];
+ let succeeded = 0;
+ for (const id of videoIds) {
+ const r = await deleteOneVideoDir(slug, id);
+ if (r.ok) succeeded++;
+ else failures.push({ videoId: id, error: r.error });
+ }
+ revalidatePath(`/channels/${slug}`);
+ requestChannelSnapshot(getPaths(), slug);
+ return {
+ ok: failures.length === 0,
+ attempted: videoIds.length,
+ succeeded,
+ failures,
+ };
+}
+
+// Clearing failure markers is a set operation over the two failure-list files,
+// so `succeeded` reports how many ids were actually removed from either list
+// (some selected ids may not have been marked failed).
+export async function bulkClearFailedMarkersAction(
+ slug: string,
+ videoIds: string[],
+): Promise<BulkActionSummary> {
+ const paths = getPaths();
+ const idSet = new Set(videoIds);
+ const trxn = await pruneFailedTranscriptions(
+ failedTranscriptionsFile(paths, slug),
+ idSet,
+ );
+ const trcd = await pruneFailedTranscodings(
+ failedTranscodingsFile(paths, slug),
+ idSet,
+ );
+ revalidatePath(`/channels/${slug}`);
+ requestChannelSnapshot(paths, slug);
+ return {
+ ok: true,
+ attempted: videoIds.length,
+ succeeded: trxn.pruned + trcd.pruned,
+ failures: [],
+ };
+}
diff --git a/editor/app/channels/[slug]/components/VideoListPane.tsx b/editor/app/channels/[slug]/components/VideoListPane.tsx
@@ -8,12 +8,29 @@ import type { VideoRow, VideoFilter } from "../lib/videoRows";
import { filterRows } from "../lib/videoRows";
import { QueueControl } from "../../../components/QueueControl";
import {
+ bulkClearFailedMarkersAction,
+ bulkDeleteVideoDirsAction,
bulkMarkUntranscribableAction,
bulkRetryDownloadAction,
bulkTranscribeAction,
type BulkActionSummary,
} from "../bulkVideoActions";
+type BulkAction =
+ | "transcribe"
+ | "retry"
+ | "untranscribable"
+ | "clear_failed"
+ | "delete";
+
+const BULK_ACTION_OPTIONS: { value: BulkAction; label: string }[] = [
+ { value: "transcribe", label: "Transcribe" },
+ { value: "retry", label: "Retry download" },
+ { value: "untranscribable", label: "Mark untranscribable" },
+ { value: "clear_failed", label: "Clear failed markers" },
+ { value: "delete", label: "Delete directories" },
+];
+
type Props = {
slug: string;
rows: VideoRow[];
@@ -71,6 +88,8 @@ export function VideoListPane({
const [transcribeQueue, setTranscribeQueue] = useState(defaultTranscribeQueue);
const [retryQueue, setRetryQueue] = useState(defaultDownloadQueue);
const [retryAbortOnError, setRetryAbortOnError] = useState(false);
+ const [action, setAction] = useState<BulkAction>("transcribe");
+ const [deleteConfirm, setDeleteConfirm] = useState("");
const visibleRows = useMemo(() => {
const filtered = filterRows(rows, filter);
@@ -158,22 +177,81 @@ export function VideoListPane({
});
}
- // Mark-untranscribable is a synchronous metadata write with a per-id summary.
+ // Mark-untranscribable / clear-markers / delete are synchronous metadata or
+ // filesystem ops with a per-id summary — no queued job, so no transcode.
function doSummaryBulk(
- action: (slug: string, ids: string[]) => Promise<BulkActionSummary>,
+ summaryAction: (slug: string, ids: string[]) => Promise<BulkActionSummary>,
+ onOk?: () => void,
) {
const ids = Array.from(selected);
if (ids.length === 0) return;
setLastResult(null);
setError(null);
startTransition(async () => {
- const result = await action(slug, ids);
+ const result = await summaryAction(slug, ids);
setLastResult(result);
- if (result.ok) setSelected(new Set());
+ if (result.ok) {
+ setSelected(new Set());
+ onOk?.();
+ }
router.refresh();
});
}
+ // Quick-select helpers. Both merge into the current selection so they compose
+ // across filter changes (e.g. select failed, then narrow and select more).
+ function selectFailed() {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ for (const r of rows) {
+ if (r.failedTranscription || r.failedTranscoding) next.add(r.id);
+ }
+ return next;
+ });
+ }
+
+ function selectFiltered() {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ for (const r of visibleRows) next.add(r.id);
+ return next;
+ });
+ }
+
+ const deleteArmed = deleteConfirm.trim().toLowerCase() === "delete";
+ const applyDisabled = pending || (action === "delete" && !deleteArmed);
+
+ function handleApply() {
+ switch (action) {
+ case "transcribe":
+ doStreamingBulk((s, ids) =>
+ bulkTranscribeAction(s, ids, transcribeQueue),
+ );
+ break;
+ case "retry":
+ doStreamingBulk((s, ids) =>
+ bulkRetryDownloadAction(s, ids, retryQueue, retryAbortOnError),
+ );
+ break;
+ case "untranscribable":
+ if (
+ !confirm(
+ `Mark ${selected.size} video${selected.size === 1 ? "" : "s"} untranscribable?`,
+ )
+ )
+ return;
+ doSummaryBulk(bulkMarkUntranscribableAction);
+ break;
+ case "clear_failed":
+ doSummaryBulk(bulkClearFailedMarkersAction);
+ break;
+ case "delete":
+ if (!deleteArmed) return;
+ doSummaryBulk(bulkDeleteVideoDirsAction, () => setDeleteConfirm(""));
+ break;
+ }
+ }
+
return (
<div className="flex flex-col gap-2 min-h-0">
<div className="flex flex-col gap-2">
@@ -205,21 +283,39 @@ export function VideoListPane({
);
})}
</div>
- <div className="flex items-center justify-between text-xs text-zinc-500">
- <span>
+ <div className="flex items-center justify-between gap-2 text-xs text-zinc-500">
+ <span className="flex-shrink-0">
{visibleRows.length} of {rows.length} videos
</span>
- {visibleRows.length > 0 && (
- <label className="inline-flex items-center gap-1.5 cursor-pointer">
- <input
- type="checkbox"
- checked={allSelected}
- onChange={toggleAll}
- aria-label="select all visible"
- />
- <span>select all visible</span>
- </label>
- )}
+ <div className="flex items-center gap-2 flex-wrap justify-end">
+ <button
+ type="button"
+ onClick={selectFailed}
+ className="rounded border border-zinc-200 dark:border-zinc-800 px-2 py-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+ Select failed
+ </button>
+ {visibleRows.length > 0 && (
+ <>
+ <button
+ type="button"
+ onClick={selectFiltered}
+ className="rounded border border-zinc-200 dark:border-zinc-800 px-2 py-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+ Select filtered
+ </button>
+ <label className="inline-flex items-center gap-1.5 cursor-pointer">
+ <input
+ type="checkbox"
+ checked={allSelected}
+ onChange={toggleAll}
+ aria-label="select all visible"
+ />
+ <span>select all visible</span>
+ </label>
+ </>
+ )}
+ </div>
</div>
</div>
@@ -292,70 +388,81 @@ export function VideoListPane({
</button>
<div className="basis-full flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5">
- <QueueControl
- value={transcribeQueue}
- onChange={setTranscribeQueue}
- defaultQueueKey={defaultTranscribeQueue}
- existingQueues={existingQueues}
- actionLabel="bulk transcribe"
- />
- <button
- type="button"
- disabled={pending}
- onClick={() =>
- doStreamingBulk((s, ids) =>
- bulkTranscribeAction(s, ids, transcribeQueue),
- )
- }
- className="px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50"
- >
- Transcribe
- </button>
- </div>
- <div className="flex flex-wrap items-center gap-1.5">
- <QueueControl
- value={retryQueue}
- onChange={setRetryQueue}
- defaultQueueKey={defaultDownloadQueue}
- existingQueues={existingQueues}
- actionLabel="bulk retry download"
- />
- <label className="flex items-center gap-1 text-xs text-zinc-500">
- <input
- type="checkbox"
- checked={retryAbortOnError}
- onChange={(e) => setRetryAbortOnError(e.target.checked)}
- aria-label="abort on error for bulk retry download"
- />
- Abort on error
+ <label className="flex items-center gap-1.5 text-xs text-zinc-500">
+ <span>Action</span>
+ <select
+ value={action}
+ onChange={(e) => setAction(e.target.value as BulkAction)}
+ aria-label="bulk action"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-1.5 py-1 text-xs text-zinc-900 dark:text-zinc-100"
+ >
+ {BULK_ACTION_OPTIONS.map((opt) => (
+ <option key={opt.value} value={opt.value}>
+ {opt.label}
+ </option>
+ ))}
+ </select>
</label>
+
+ {/* Contextual controls for the selected action */}
+ {action === "transcribe" && (
+ <QueueControl
+ value={transcribeQueue}
+ onChange={setTranscribeQueue}
+ defaultQueueKey={defaultTranscribeQueue}
+ existingQueues={existingQueues}
+ actionLabel="bulk transcribe"
+ />
+ )}
+ {action === "retry" && (
+ <>
+ <QueueControl
+ value={retryQueue}
+ onChange={setRetryQueue}
+ defaultQueueKey={defaultDownloadQueue}
+ existingQueues={existingQueues}
+ actionLabel="bulk retry download"
+ />
+ <label className="flex items-center gap-1 text-xs text-zinc-500">
+ <input
+ type="checkbox"
+ checked={retryAbortOnError}
+ onChange={(e) => setRetryAbortOnError(e.target.checked)}
+ aria-label="abort on error for bulk retry download"
+ />
+ Abort on error
+ </label>
+ </>
+ )}
+ {action === "delete" && (
+ <label className="flex items-center gap-1.5 text-xs text-red-700 dark:text-red-400">
+ <span>
+ Type <code className="font-mono">delete</code> to confirm
+ </span>
+ <input
+ type="text"
+ value={deleteConfirm}
+ onChange={(e) => setDeleteConfirm(e.target.value)}
+ aria-label="type delete to confirm"
+ className="w-24 rounded border border-red-300 dark:border-red-800 bg-white dark:bg-zinc-900 px-1.5 py-1 text-xs"
+ />
+ </label>
+ )}
+
<button
type="button"
- disabled={pending}
- onClick={() =>
- doStreamingBulk((s, ids) =>
- bulkRetryDownloadAction(s, ids, retryQueue, retryAbortOnError),
- )
- }
- className="px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50"
- >
- Retry download
- </button>
- <button
- type="button"
- disabled={pending}
- onClick={() => {
- if (
- !confirm(
- `Mark ${selected.size} video${selected.size === 1 ? "" : "s"} untranscribable?`,
- )
- )
- return;
- doSummaryBulk(bulkMarkUntranscribableAction);
- }}
- className="px-2 py-1 rounded border border-amber-300 dark:border-amber-800 text-amber-700 dark:text-amber-300 text-xs hover:bg-amber-50 dark:hover:bg-amber-950 disabled:opacity-50"
+ disabled={applyDisabled}
+ onClick={handleApply}
+ aria-label="apply bulk action"
+ className={`px-3 py-1 rounded border text-xs disabled:opacity-50 ${
+ action === "delete"
+ ? "border-red-300 dark:border-red-800 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950"
+ : action === "untranscribable"
+ ? "border-amber-300 dark:border-amber-800 text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950"
+ : "border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ }`}
>
- Mark untranscribable
+ Apply
</button>
</div>
</div>
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -309,6 +309,27 @@ export async function setPrimaryTranscriptAction(
export type DeleteDirActionResult = { error: string } | undefined;
+// Validated, non-redirecting directory delete shared by the single-video form
+// action and the bulk action. Pure filesystem op — queues no job, so it never
+// triggers a transcode.
+export async function deleteOneVideoDir(
+ slug: string,
+ videoId: string,
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const videoDir = videoDirOf(slug, videoId);
+ // Belt-and-suspenders: ensure videoDir is inside the channel's data dir.
+ const dataDir = path.resolve(getPaths().channelsDir, slug, "data");
+ const resolved = path.resolve(videoDir);
+ if (path.dirname(resolved) !== dataDir) {
+ return {
+ ok: false,
+ error: "Refusing to delete: video path resolved outside the data dir",
+ };
+ }
+ await rm(resolved, { recursive: true, force: true });
+ return { ok: true };
+}
+
export async function deleteVideoDirAction(
slug: string,
videoId: string,
@@ -321,14 +342,8 @@ export async function deleteVideoDirAction(
error: `Type the video id "${videoId}" exactly to confirm deletion`,
};
}
- const videoDir = videoDirOf(slug, videoId);
- // Belt-and-suspenders: ensure videoDir is inside the channel's data dir.
- const dataDir = path.resolve(getPaths().channelsDir, slug, "data");
- const resolved = path.resolve(videoDir);
- if (path.dirname(resolved) !== dataDir) {
- return { error: "Refusing to delete: video path resolved outside the data dir" };
- }
- await rm(resolved, { recursive: true, force: true });
+ const result = await deleteOneVideoDir(slug, videoId);
+ if (!result.ok) return { error: result.error };
revalidatePath(`/channels/${slug}`);
requestChannelSnapshot(getPaths(), slug);
redirect(`/channels/${slug}`);
diff --git a/editor/e2e/bulk-actions.spec.ts b/editor/e2e/bulk-actions.spec.ts
@@ -1,16 +1,35 @@
+import { readFile, writeFile } from "node:fs/promises";
import { test, expect } from "@playwright/test";
-import { resetData } from "./helpers";
+import { pathExists, resetData, resolvePath } from "./helpers";
// The channel video list's checkbox bulk bar must queue like every other batch
// feature: ONE managed batch job (whisper-bucket / retry-bucket), not one
// single-video job per selection. /jobs lists most-recent-first, so the newest
// row after a bulk click tells us exactly what was queued.
+//
+// The bar is a single Action dropdown + Apply button: pick the action, fill in
+// its contextual controls (queue, etc.), then click Apply.
async function selectAllAndOpenBar(page: import("@playwright/test").Page) {
await page.getByLabel("select all visible").check();
await expect(page.getByLabel("bulk action bar")).toBeVisible();
}
+async function chooseAction(
+ page: import("@playwright/test").Page,
+ label: string,
+) {
+ await page.getByLabel("bulk action", { exact: true }).selectOption({ label });
+}
+
+function applyButton(page: import("@playwright/test").Page) {
+ return page.getByLabel("apply bulk action");
+}
+
+async function clickApply(page: import("@playwright/test").Page) {
+ await applyButton(page).click();
+}
+
// A streaming bulk action clears the selection (hiding the bar) once its server
// action returns — i.e. once the batch job is registered. Wait for that before
// reading /jobs so a fast-failing job can't lose the race against navigation.
@@ -25,12 +44,13 @@ test("bulk transcribe submits a single batch job on the transcription queue", as
await page.goto("/channels/test-transcribe");
await selectAllAndOpenBar(page);
+ // Transcribe is the default action, so its queue control is already shown.
// Default queue for bulk transcribe matches "Transcribe missing".
await expect(
page.getByLabel("queue for bulk transcribe", { exact: true }),
).toHaveValue("transcription");
- await page.getByRole("button", { name: "Transcribe", exact: true }).click();
+ await clickApply(page);
await expectSubmitted(page);
await page.goto("/jobs");
@@ -56,7 +76,7 @@ test("bulk transcribe honors a custom queue", async ({ page }) => {
.getByRole("button", { name: "save custom queue for bulk transcribe" })
.click();
- await page.getByRole("button", { name: "Transcribe", exact: true }).click();
+ await clickApply(page);
await expectSubmitted(page);
await page.goto("/jobs");
@@ -72,9 +92,8 @@ test("bulk retry download submits a single retry-bucket job on the platform queu
await page.goto("/channels/test-transcribe");
await selectAllAndOpenBar(page);
- await page
- .getByRole("button", { name: "Retry download", exact: true })
- .click();
+ await chooseAction(page, "Retry download");
+ await clickApply(page);
await expectSubmitted(page);
await page.goto("/jobs");
@@ -85,3 +104,84 @@ test("bulk retry download submits a single retry-bucket job on the platform queu
await expect(newest).toContainText("test-transcribe");
await expect(newest).toContainText("platform:odysee");
});
+
+const dataDir = "test-transcripts/channels/test-transcribe/data";
+const failedTranscriptionsFile =
+ "test-transcripts/channels/test-transcribe/failed-transcriptions";
+const failedTranscodingsFile =
+ "test-transcripts/channels/test-transcribe/failed-transcodings";
+
+// Delete directories and Clear failed markers are synchronous, summary-style
+// actions (no queued job) — the point of bulk delete is to clear failed
+// downloads WITHOUT triggering a transcode.
+
+test("Select failed + Delete directories removes the dirs and queues no job", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ // Mark vidA as a failed download.
+ await writeFile(resolvePath(failedTranscriptionsFile), "vidA\n");
+
+ await page.goto("/channels/test-transcribe");
+
+ // The quick-select helper grabs exactly the failed video.
+ await page.getByRole("button", { name: "Select failed", exact: true }).click();
+ await expect(page.getByLabel("bulk action bar")).toBeVisible();
+ await expect(page.getByText("1 selected", { exact: true })).toBeVisible();
+
+ await chooseAction(page, "Delete directories");
+
+ // Apply stays disabled until "delete" is typed into the confirm box.
+ const apply = applyButton(page);
+ await expect(apply).toBeDisabled();
+ await page.getByLabel("type delete to confirm").fill("delete");
+ await expect(apply).toBeEnabled();
+ await apply.click();
+ await expectSubmitted(page);
+
+ // vidA's directory is gone; the others are untouched.
+ expect(await pathExists(`${dataDir}/vidA`)).toBe(false);
+ expect(await pathExists(`${dataDir}/vidB`)).toBe(true);
+ expect(await pathExists(`${dataDir}/vidC`)).toBe(true);
+
+ // It disappears from the list immediately (live-filesystem read).
+ await expect(page.getByLabel("select vidA")).toHaveCount(0);
+ await expect(page.getByLabel("select vidB")).toBeVisible();
+
+ // Crucially: NO managed job was queued (no transcode/download triggered).
+ await page.goto("/jobs");
+ await expect(
+ page.getByRole("row").filter({ hasText: "test-transcribe" }),
+ ).toHaveCount(0);
+});
+
+test("Clear failed markers prunes both failure files and clears the glyphs", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ // vidA failed transcription, vidB failed transcoding.
+ await writeFile(resolvePath(failedTranscriptionsFile), "vidA\n");
+ await writeFile(resolvePath(failedTranscodingsFile), "vidB\n");
+
+ await page.goto("/channels/test-transcribe");
+
+ // Two failure glyphs are shown before clearing.
+ await expect(page.getByTitle("failure recorded")).toHaveCount(2);
+
+ await page.getByRole("button", { name: "Select failed", exact: true }).click();
+ await expect(page.getByText("2 selected", { exact: true })).toBeVisible();
+
+ await chooseAction(page, "Clear failed markers");
+ await clickApply(page);
+ await expectSubmitted(page);
+
+ // The failure files no longer list the cleared ids (fresh-loaded each render,
+ // so the glyphs clear immediately too).
+ await expect(page.getByTitle("failure recorded")).toHaveCount(0);
+ expect(await readFile(resolvePath(failedTranscriptionsFile), "utf8")).not.toContain(
+ "vidA",
+ );
+ expect(await readFile(resolvePath(failedTranscodingsFile), "utf8")).not.toContain(
+ "vidB",
+ );
+});