Archilyzer · Source

archilyzer

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

commit b560cde8a391aeb1c0d959f4d8cebe282d554520
parent c717a41f8a23270321d894ed965ab62d3d25f6d0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 30 May 2026 22:23:00 -0400

even more shard fixes

Diffstat:
Acommon/lib/queueKeys.ts | 32++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/[slug]/availabilityActions.ts | 3++-
Meditor/app/channels/[slug]/bulkVideoActions.ts | 138++++++++++---------------------------------------------------------------------
Meditor/app/channels/[slug]/components/VideoListPane.tsx | 192++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Meditor/app/channels/[slug]/page.tsx | 4++++
Meditor/app/channels/[slug]/pipelineActions.ts | 16++++------------
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 13++++---------
Meditor/app/channels/[slug]/whisperActions.ts | 28++++++++++++++--------------
Aeditor/e2e/bulk-actions.spec.ts | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 316 insertions(+), 198 deletions(-)

diff --git a/common/lib/queueKeys.ts b/common/lib/queueKeys.ts @@ -0,0 +1,32 @@ +import type { ChannelConfig } from "./channelConfig"; +import { + TRANSCRIPTION_QUEUE, + platformQueueKey, + queueKeyForUrl, +} from "./platform"; + +export { TRANSCRIPTION_QUEUE }; + +// Per-channel queue for channel-local bookkeeping jobs (clean/clear/verify). +export function channelQueueKey(slug: string): string { + return `channel:${slug}`; +} + +// Queue a network-bound download/pipeline job lands on: the channel's platform +// queue, falling back to a per-domain queue for unrecognized hosts. +export function downloadQueueKey(config: ChannelConfig): string { + return config.platform + ? platformQueueKey(config.platform) + : queueKeyForUrl(config.url); +} + +// Single definition of how a UI-supplied queue override resolves against a +// feature's default queue. `undefined` (no control wired up) → the default; +// `""` → immediate (the registry runs `queueKey === ""` right away); any other +// string → that named queue (trimmed). +export function resolveQueueKey( + defaultKey: string, + override: string | undefined, +): string { + return override === undefined ? defaultKey : override.trim(); +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Bulk checkbox transcribe/retry now queue like every other batch.** Selecting videos in the channel's list and clicking **Transcribe** (or **Retry download**) used to fire one queued single-video job per selection onto the channel's *platform* queue — diverging from "Transcribe missing"/"Download", which submit one batch job on the shared `transcription`/platform queue. The checkbox actions now submit a **single** batch job through the same path (so bulk and the stage buttons serialize together instead of contending), and the resulting job shows up in the page's running-jobs list. The selection bar gained the matching controls: a **queue** selector for each action (defaulting to `transcription` for transcribe and the platform queue for retry), a **Parallel** concurrency input for transcribe, and an **Abort on error** toggle for retry. Default-queue resolution for all batch features now lives in one shared helper (`common/lib/queueKeys.ts`) so they can't drift apart again. "Mark untranscribable" is unchanged (instant metadata write). - **Shard controls overhauled: "Save" button, 1-based `i / N` inputs, remaining-only transcribe slices, and always-on logging.** The shard inputs now read **`i / N`** (this shard's number first, 1-based — a 2-way split is `1/2` and `2/2`) and each box has its own hover title so they're no longer easy to mix up. A new **Save** button persists a slice to `shard-<op>.json` *without* starting the job — handy for dividing the remaining videos across machines (that share an identical synced copy) up front, and for confirming the slice actually saved; the saved-shard pill updates immediately. **Transcribe** sharding now splits only the videos that still need a transcript (e.g. 6 untranscribed videos split 2 ways → 3 each) instead of slicing the whole channel's video list. And every Download/Transcribe/Availability run now logs whether it's running a computed/saved shard slice or the full set, so a run that silently ignored shard inputs is no longer indistinguishable from one that honored it. - **Shard configs now show up immediately, and a shard job's progress bar totals only its slice.** Previously, setting a shard (N/i) and running **Download videos** / **Transcribe missing** did persist the slice to `shard-*.json`, but the saved-shard indicator never updated until you manually reloaded the whole page — so it looked like nothing saved. The indicator on the Download/Transcribe/Diagnostics controls (and channel counts, buckets, and failed-lists generally) now refresh as soon as a run finishes — including after a cancel — without a reload. The download slice is still taken over the *missing* set and snapshotted to the file, so a resume run with the same N/i reuses that exact slice instead of re-slicing the now-smaller set. A sharded job's progress bar on `/jobs/active` now totals only its own slice rather than the whole channel. - **Multi-site support (major change).** One editor instance can now power several public sites (e.g. "Jeralyzer" and "Rekietalyzer") over a single shared channel pool — a channel's downloads are stored once and reused by every site that includes it. A new **Sites** area (sidebar) manages each site's `sites/<id>/site.json`: its branding (title, header, description, tagline), social links, channel-group layout, and **which channels it exposes** (with a per-site group for each member, so the same channel can sit in different groups on different sites) plus its Cloudflare Pages project. Site-level branding/groups/social have moved **out** of global Settings, which now holds only operational config plus a new **Admin title** for the editor's own shell; the per-channel "Group" field is gone (grouping is configured per site). The **Charts** tab is per-site (pick the site at the top; each site has its own dashboard at `sites/<id>/chart-templates.json`). **Build index** / **Build stats dataset** now build the shared per-channel data once and a filtered bundle per site; the Deploy page's **Build static export** and **Deploy** gained a site selector and deploy each site to its own Cloudflare project. Upgrading an existing single-site install: the Sites page shows a one-click **Migrate** button (also a `migrate-to-sites` CLI) that lifts your current branding, channel groups, per-channel group assignments, and chart dashboard into a first site and reduces `settings.json` to operational config. diff --git a/editor/app/channels/[slug]/availabilityActions.ts b/editor/app/channels/[slug]/availabilityActions.ts @@ -6,6 +6,7 @@ import { platformQueueKey, queueKeyForUrl, } from "yt-dlp-transcript-common/lib/platform"; +import { resolveQueueKey } from "yt-dlp-transcript-common/lib/queueKeys"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { CHECK_AVAILABILITY_MODES, @@ -44,7 +45,7 @@ export async function checkAvailabilityAction( const limit = sanitizeConcurrency(concurrency); return runManagedFunction({ kind: "check-availability", - queueKey: queueKey === undefined ? defaultQueue : queueKey.trim(), + queueKey: resolveQueueKey(defaultQueue, queueKey), paths, channelSlug: slug, fn: async (onLog, signal) => { diff --git a/editor/app/channels/[slug]/bulkVideoActions.ts b/editor/app/channels/[slug]/bulkVideoActions.ts @@ -1,21 +1,10 @@ "use server"; -import path from "node:path"; -import { readdir } from "node:fs/promises"; import { revalidatePath } from "next/cache"; -import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { - platformQueueKey, - queueKeyForUrl, -} from "yt-dlp-transcript-common/lib/platform"; -import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; -import { isRealAudioFile } from "yt-dlp-transcript-common/lib/videoStatus"; -import { - downloadVideoPipelineAction, - markVideoUntranscribableAction, - transcribeOneAction, -} from "./videos/[id]/videoActions"; +import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; +import { transcribeBucketAction } from "./whisperActions"; +import { retryBucketAction } from "./pipelineActions"; +import { markVideoUntranscribableAction } from "./videos/[id]/videoActions"; export type BulkActionSummary = { ok: boolean; @@ -24,124 +13,31 @@ export type BulkActionSummary = { failures: Array<{ videoId: string; error: string }>; }; -function dataDirOf(slug: string, videoId: string): string { - return path.join(getPaths().channelsDir, slug, "data", videoId); -} - -async function loadConfig(slug: string): Promise<ChannelConfig | null> { - return readChannelConfig(getPaths(), slug); -} - -function resolveQueueKey( - config: ChannelConfig, - override?: string, -): string | undefined { - if (override === undefined) return undefined; - const trimmed = override.trim(); - if (!trimmed) { - return config.platform - ? platformQueueKey(config.platform) - : queueKeyForUrl(config.url); - } - return trimmed; -} - -async function firstAudioFile(slug: string, videoId: string): Promise<string | null> { - const dir = dataDirOf(slug, videoId); - let entries: string[]; - try { - entries = await readdir(dir); - } catch { - return null; - } - const audio = entries.filter(isRealAudioFile).sort(); - return audio[0] ?? null; -} - -async function hasTranscript(slug: string, videoId: string): Promise<boolean> { - const dir = dataDirOf(slug, videoId); - try { - const entries = await readdir(dir); - return ( - entries.includes("transcript.json") || - entries.includes("transcript.en.vtt") - ); - } catch { - return false; - } -} - +// Bulk transcribe/retry submit ONE managed batch job (the same path as +// "Transcribe missing" / "Retry bucket"), not one job per selected video. This +// keeps the checkbox actions on the same queue and concurrency model as the +// rest of the app so they never desync. The selection bar fires these, +// refreshes, and lets the resulting job surface in the page's RunningJobsList. export async function bulkTranscribeAction( slug: string, videoIds: string[], queueKey?: string, -): Promise<BulkActionSummary> { - const config = await loadConfig(slug); - if (!config) { - return { - ok: false, - attempted: 0, - succeeded: 0, - failures: [{ videoId: "", error: `Channel "${slug}" not found` }], - }; - } - const resolved = resolveQueueKey(config, queueKey); - const failures: BulkActionSummary["failures"] = []; - let succeeded = 0; - for (const id of videoIds) { - if (await hasTranscript(slug, id)) { - failures.push({ videoId: id, error: "transcript already present" }); - continue; - } - const audio = await firstAudioFile(slug, id); - if (!audio) { - failures.push({ videoId: id, error: "no audio file on disk" }); - continue; - } - const r = await transcribeOneAction(slug, id, audio, resolved); - if (r.ok) succeeded++; - else failures.push({ videoId: id, error: r.error }); - } - revalidatePath(`/channels/${slug}`); - return { - ok: failures.length === 0, - attempted: videoIds.length, - succeeded, - failures, - }; + concurrency?: number, +): Promise<StreamActionResult> { + return transcribeBucketAction(slug, videoIds, queueKey, concurrency); } export async function bulkRetryDownloadAction( slug: string, videoIds: string[], queueKey?: string, -): Promise<BulkActionSummary> { - const config = await loadConfig(slug); - if (!config) { - return { - ok: false, - attempted: 0, - succeeded: 0, - failures: [{ videoId: "", error: `Channel "${slug}" not found` }], - }; - } - const resolved = resolveQueueKey(config, queueKey); - const failures: BulkActionSummary["failures"] = []; - let succeeded = 0; - for (const id of videoIds) { - const r = await downloadVideoPipelineAction(slug, id, resolved); - if (r.ok) succeeded++; - else failures.push({ videoId: id, error: r.error }); - } - revalidatePath(`/channels/${slug}`); - return { - ok: failures.length === 0, - attempted: videoIds.length, - succeeded, - failures, - }; + abortOnError?: boolean, +): Promise<StreamActionResult> { + return retryBucketAction(slug, videoIds, queueKey, abortOnError); } +// Marking videos untranscribable is an instant metadata write, not a queued +// batch, so it stays synchronous and reports a per-id summary. export async function bulkMarkUntranscribableAction( slug: string, videoIds: string[], diff --git a/editor/app/channels/[slug]/components/VideoListPane.tsx b/editor/app/channels/[slug]/components/VideoListPane.tsx @@ -3,8 +3,11 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; +import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import type { VideoRow, VideoFilter } from "../lib/videoRows"; import { filterRows } from "../lib/videoRows"; +import { ConcurrencyControl } from "../../../components/ConcurrencyControl"; +import { QueueControl } from "../../../components/QueueControl"; import { bulkMarkUntranscribableAction, bulkRetryDownloadAction, @@ -18,8 +21,20 @@ type Props = { selectedVideoId: string | null; initialFilter: VideoFilter; initialQuery: string; + defaultTranscribeQueue: string; + defaultDownloadQueue: string; + existingQueues: string[]; + defaultConcurrency: number; }; +function parseConcurrency(s: string): number | undefined { + const trimmed = s.trim(); + if (trimmed === "") return undefined; + const n = Number(trimmed); + if (!Number.isFinite(n) || n < 1) return undefined; + return Math.floor(n); +} + const FILTER_OPTIONS: { value: VideoFilter; label: string }[] = [ { value: "all", label: "All" }, { value: "needs_action", label: "Needs action" }, @@ -51,6 +66,10 @@ export function VideoListPane({ selectedVideoId, initialFilter, initialQuery, + defaultTranscribeQueue, + defaultDownloadQueue, + existingQueues, + defaultConcurrency, }: Props) { const router = useRouter(); const searchParams = useSearchParams(); @@ -59,6 +78,11 @@ export function VideoListPane({ const [selected, setSelected] = useState<Set<string>>(new Set()); const [pending, startTransition] = useTransition(); const [lastResult, setLastResult] = useState<BulkActionSummary | null>(null); + const [error, setError] = useState<string | null>(null); + const [transcribeQueue, setTranscribeQueue] = useState(defaultTranscribeQueue); + const [transcribeConcurrency, setTranscribeConcurrency] = useState(""); + const [retryQueue, setRetryQueue] = useState(defaultDownloadQueue); + const [retryAbortOnError, setRetryAbortOnError] = useState(false); const visibleRows = useMemo(() => { const filtered = filterRows(rows, filter); @@ -117,12 +141,43 @@ export function VideoListPane({ const baseParams = new URLSearchParams(searchParams.toString()); - function doBulk( + // Transcribe/retry submit ONE managed batch job; we don't consume its stream + // (the job runs to completion regardless) and just refresh so it appears in + // the page's running-jobs list like any other batch. + function doStreamingBulk( + action: (slug: string, ids: string[]) => Promise<StreamActionResult>, + ) { + const ids = Array.from(selected); + if (ids.length === 0) return; + setLastResult(null); + setError(null); + startTransition(async () => { + try { + const result = await action(slug, ids); + if (!result.ok) { + setError(result.error); + return; + } + // Fire-and-forget: cancel the stream so we don't hold the page open; + // the job keeps running and shows up at the top of the page. + void result.stream.cancel(); + setSelected(new Set()); + } catch (e) { + setError((e as Error).message); + } finally { + router.refresh(); + } + }); + } + + // Mark-untranscribable is a synchronous metadata write with a per-id summary. + function doSummaryBulk( action: (slug: string, ids: string[]) => Promise<BulkActionSummary>, ) { const ids = Array.from(selected); if (ids.length === 0) return; setLastResult(null); + setError(null); startTransition(async () => { const result = await action(slug, ids); setLastResult(result); @@ -239,48 +294,103 @@ export function VideoListPane({ className="sticky bottom-0 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md p-2 flex flex-wrap items-center gap-2 text-sm shadow-sm" > <span className="font-medium">{selected.size} selected</span> - <div className="flex flex-wrap gap-1.5 ml-auto"> - <button - type="button" - disabled={pending} - onClick={() => doBulk(bulkTranscribeAction)} - 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> - <button - type="button" - disabled={pending} - onClick={() => doBulk(bulkRetryDownloadAction)} - 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?`, + <button + type="button" + disabled={pending} + onClick={() => setSelected(new Set())} + className="px-2 py-1 rounded border border-transparent text-xs text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50" + > + Clear + </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" + /> + <ConcurrencyControl + value={transcribeConcurrency} + onChange={setTranscribeConcurrency} + defaultLimit={defaultConcurrency} + actionLabel="bulk transcribe" + /> + <button + type="button" + disabled={pending} + onClick={() => + doStreamingBulk((s, ids) => + bulkTranscribeAction( + s, + ids, + transcribeQueue, + parseConcurrency(transcribeConcurrency), + ), ) - ) - return; - doBulk(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" - > - Mark untranscribable - </button> - <button - type="button" - disabled={pending} - onClick={() => setSelected(new Set())} - className="px-2 py-1 rounded border border-transparent text-xs text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50" - > - Clear - </button> + } + 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> + <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" + > + Mark untranscribable + </button> + </div> </div> + {error && ( + <p + role="status" + aria-label="bulk action error" + className="basis-full text-xs text-amber-700 dark:text-amber-300" + > + {error} + </p> + )} {lastResult && ( <p role="status" diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -389,6 +389,10 @@ export default async function ChannelDetailPage({ selectedVideoId={selectedVideoId} initialFilter={filter} initialQuery={queryRaw} + defaultTranscribeQueue={TRANSCRIPTION_QUEUE} + defaultDownloadQueue={platformDefaultQueueKey} + existingQueues={existingQueues} + defaultConcurrency={paths.parallelTranscribeLimit} /> } right={ diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts @@ -3,14 +3,13 @@ import { revalidatePath } from "next/cache"; import { HANDLING_VALUES, - type ChannelConfig, type ChannelHandling, } from "yt-dlp-transcript-common/lib/channelConfig"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { - platformQueueKey, - queueKeyForUrl, -} from "yt-dlp-transcript-common/lib/platform"; + downloadQueueKey, + resolveQueueKey, +} from "yt-dlp-transcript-common/lib/queueKeys"; import { countNotYetDownloaded, readChannelConfig, @@ -24,12 +23,6 @@ import { } from "yt-dlp-transcript-common/jobs/streamCommand"; import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks"; -function defaultQueueKey(config: ChannelConfig): string { - return config.platform - ? platformQueueKey(config.platform) - : queueKeyForUrl(config.url); -} - async function runPipelineAction( slug: string, mode: @@ -60,8 +53,7 @@ async function runPipelineAction( } return runManagedFunction({ kind, - queueKey: - queueKey === undefined ? defaultQueueKey(channelConfig) : queueKey.trim(), + queueKey: resolveQueueKey(downloadQueueKey(channelConfig), queueKey), paths, channelSlug: slug, fn: async (onLog, signal, setProgress, ctx) => { diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts @@ -12,9 +12,9 @@ import { AUDIO_FORMAT_VALUES } from "yt-dlp-transcript-common/lib/channelConfig" import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { isRealAudioFile } from "yt-dlp-transcript-common/lib/videoStatus"; import { - platformQueueKey, - queueKeyForUrl, -} from "yt-dlp-transcript-common/lib/platform"; + downloadQueueKey, + resolveQueueKey, +} from "yt-dlp-transcript-common/lib/queueKeys"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { setDoNotClean } from "yt-dlp-transcript-common/lib/doNotClean-server"; import { pruneFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions"; @@ -31,12 +31,7 @@ import { import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks"; function videoQueueKey(config: ChannelConfig, override: string | undefined): string { - if (override === undefined) { - return config.platform - ? platformQueueKey(config.platform) - : queueKeyForUrl(config.url); - } - return override.trim(); + return resolveQueueKey(downloadQueueKey(config), override); } function videoDirOf(slug: string, videoId: string): string { diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts @@ -6,7 +6,11 @@ import { AUDIO_FORMAT_VALUES, type AudioFormat, } from "yt-dlp-transcript-common/lib/channelConfig"; -import { TRANSCRIPTION_QUEUE } from "yt-dlp-transcript-common/lib/platform"; +import { + TRANSCRIPTION_QUEUE, + channelQueueKey, + resolveQueueKey, +} from "yt-dlp-transcript-common/lib/queueKeys"; import { countNotYetTranscribed, readChannelStat, @@ -29,10 +33,6 @@ import { } from "yt-dlp-transcript-common/jobs/streamCommand"; import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks"; -function defaultQueueKey(slug: string): string { - return `channel:${slug}`; -} - function sanitizeConcurrency(c: number | undefined): number | undefined { if (c === undefined) return undefined; if (!Number.isFinite(c) || c < 1) return undefined; @@ -59,7 +59,7 @@ export async function transcribeMissingAction( const fmt = sanitizeAudioFormat(audioFormat); return runManagedFunction({ kind: "whisper-all", - queueKey: queueKey === undefined ? TRANSCRIPTION_QUEUE : queueKey.trim(), + queueKey: resolveQueueKey(TRANSCRIPTION_QUEUE, queueKey), paths, channelSlug: slug, fn: async (onLog, signal, setProgress, ctx) => { @@ -112,7 +112,7 @@ export async function transcribeBucketAction( } return runManagedFunction({ kind: "whisper-bucket-downloaded-no-transcript", - queueKey: queueKey === undefined ? TRANSCRIPTION_QUEUE : queueKey.trim(), + queueKey: resolveQueueKey(TRANSCRIPTION_QUEUE, queueKey), paths, channelSlug: slug, fn: async (onLog, signal, setProgress, ctx) => { @@ -152,7 +152,7 @@ export async function clearFailedTranscriptionsAction( const paths = getPaths(); return runManagedFunction({ kind: "clear-failed-transcriptions", - queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(), + queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, fn: async (onLog) => { @@ -176,7 +176,7 @@ export async function transcodeFailuresAction( const paths = getPaths(); return runManagedFunction({ kind: "transcode-failures", - queueKey: queueKey === undefined ? TRANSCRIPTION_QUEUE : queueKey.trim(), + queueKey: resolveQueueKey(TRANSCRIPTION_QUEUE, queueKey), paths, channelSlug: slug, fn: async (onLog, signal) => { @@ -202,7 +202,7 @@ export async function transcodeUntranscodedAction( const paths = getPaths(); return runManagedFunction({ kind: "transcode-untranscoded", - queueKey: queueKey === undefined ? TRANSCRIPTION_QUEUE : queueKey.trim(), + queueKey: resolveQueueKey(TRANSCRIPTION_QUEUE, queueKey), paths, channelSlug: slug, fn: async (onLog, signal) => { @@ -227,7 +227,7 @@ export async function removeFailedTranscodingsAction( const paths = getPaths(); return runManagedFunction({ kind: "remove-failed-transcodings", - queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(), + queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, fn: async (onLog, signal) => { @@ -252,7 +252,7 @@ export async function clearFailedTranscodingsAction( const paths = getPaths(); return runManagedFunction({ kind: "clear-failed-transcodings", - queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(), + queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, fn: async (onLog) => { @@ -273,7 +273,7 @@ export async function cleanExtraAudioFormatsAction( const paths = getPaths(); return runManagedFunction({ kind: "clean-extra-audio-formats", - queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(), + queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, fn: async (onLog, signal) => { @@ -298,7 +298,7 @@ export async function cleanAudioAction( const paths = getPaths(); return runManagedFunction({ kind: "clean-audio-transcribed", - queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(), + queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), paths, channelSlug: slug, fn: async (onLog, signal) => { diff --git a/editor/e2e/bulk-actions.spec.ts b/editor/e2e/bulk-actions.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "@playwright/test"; +import { resetData } 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. + +async function selectAllAndOpenBar(page: import("@playwright/test").Page) { + await page.getByLabel("select all visible").check(); + await expect(page.getByLabel("bulk action bar")).toBeVisible(); +} + +// 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. +async function expectSubmitted(page: import("@playwright/test").Page) { + await expect(page.getByLabel("bulk action bar")).toBeHidden(); +} + +test("bulk transcribe submits a single batch job on the transcription queue", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + await page.goto("/channels/test-transcribe"); + await selectAllAndOpenBar(page); + + // 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 expectSubmitted(page); + + await page.goto("/jobs"); + const newest = page.getByRole("row").nth(1); // row 0 is the header + // One batch job, not three transcribe-one jobs. + await expect(newest).toContainText("whisper-bucket-downloaded-no-transcript"); + await expect(newest).toContainText("test-transcribe"); + await expect(newest).toContainText("transcription"); +}); + +test("bulk transcribe honors a custom queue", async ({ page }) => { + await resetData("one-transcribe-channel-with-audio"); + await page.goto("/channels/test-transcribe"); + await selectAllAndOpenBar(page); + + await page + .getByLabel("queue for bulk transcribe", { exact: true }) + .selectOption({ label: "Custom…" }); + await page + .getByLabel("custom queue for bulk transcribe", { exact: true }) + .fill("qBulk"); + await page + .getByRole("button", { name: "save custom queue for bulk transcribe" }) + .click(); + + await page.getByRole("button", { name: "Transcribe", exact: true }).click(); + await expectSubmitted(page); + + await page.goto("/jobs"); + const newest = page.getByRole("row").nth(1); + await expect(newest).toContainText("whisper-bucket-downloaded-no-transcript"); + await expect(newest).toContainText("qBulk"); +}); + +test("bulk retry download submits a single retry-bucket job on the platform queue", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + await page.goto("/channels/test-transcribe"); + await selectAllAndOpenBar(page); + + await page + .getByRole("button", { name: "Retry download", exact: true }) + .click(); + await expectSubmitted(page); + + await page.goto("/jobs"); + const newest = page.getByRole("row").nth(1); + // One retry-bucket job, not three download-one-pipeline jobs. The channel URL + // is odysee.com, so the default queue is platform:odysee. + await expect(newest).toContainText("retry-bucket"); + await expect(newest).toContainText("test-transcribe"); + await expect(newest).toContainText("platform:odysee"); +});