Archilyzer · Source

archilyzer

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

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:
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/[slug]/bulkVideoActions.ts | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/components/VideoListPane.tsx | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 31+++++++++++++++++++++++--------
Meditor/e2e/bulk-actions.spec.ts | 112++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
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", + ); +});