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:
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");
+});