commit 409313a8d36b4a7a024f5ac94ed52ccd2663a7a3
parent da1ba11d3c19c24a459062f8a23a8da7f6aaa4e9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 16 May 2026 21:20:12 -0400
QOL: sync all, JSON preview, managed DL sleep
Diffstat:
16 files changed, 526 insertions(+), 27 deletions(-)
diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts
@@ -16,8 +16,14 @@ export type ChannelConfig = {
lastSyncedAt?: string;
lastFullDownloadAt?: string;
excludeFromBuild?: boolean;
+ // Per-channel override for the global setting of the same name. When
+ // omitted, the global SiteSettings value is used. 0 disables the sleep
+ // for this channel.
+ sleepBetweenDownloadsSeconds?: number;
};
+export const CHANNEL_SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS = 600;
+
export const HANDLING_VALUES: ReadonlyArray<ChannelHandling> = [
"youtube",
"transcribe",
@@ -66,5 +72,15 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null {
if (typeof r.excludeFromBuild === "boolean") {
config.excludeFromBuild = r.excludeFromBuild;
}
+ if (
+ typeof r.sleepBetweenDownloadsSeconds === "number" &&
+ Number.isFinite(r.sleepBetweenDownloadsSeconds) &&
+ r.sleepBetweenDownloadsSeconds >= 0
+ ) {
+ config.sleepBetweenDownloadsSeconds = Math.min(
+ Math.floor(r.sleepBetweenDownloadsSeconds),
+ CHANNEL_SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS,
+ );
+ }
return config;
}
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -14,8 +14,16 @@ export type SiteSettings = {
// `yt-dlp --cookies-from-browser` ONLY on the auth-retry attempt of the
// managed per-video download flow. Empty string = disabled.
cookiesFromBrowser: string;
+ // Pause (seconds) inserted between per-video yt-dlp invocations in
+ // managed batch downloads. yt-dlp's own `-t sleep` only paces requests
+ // within one invocation, so without this the managed loop hammers the
+ // source IP back-to-back. 0 disables. Per-channel override available.
+ sleepBetweenDownloadsSeconds: number;
};
+export const SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS = 600;
+export const SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS = 10;
+
export const TRANSCRIPT_PAGE_HARD_CAP_BYTES = 20 * 1024 * 1024;
export const TRANSCRIPT_PAGE_MIN_BYTES = 256 * 1024;
export const TRANSCRIPT_PAGE_DEFAULT_BYTES = 8 * 1024 * 1024;
@@ -52,9 +60,22 @@ function defaults(): SiteSettings {
transcribeArgs: [...DEFAULT_TRANSCRIBE_ARGS],
transcribeModel: paths.whisperModel,
cookiesFromBrowser: "",
+ sleepBetweenDownloadsSeconds: SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS,
};
}
+export function clampSleepBetweenDownloadsSeconds(value: unknown): number {
+ const n =
+ typeof value === "number" && Number.isFinite(value)
+ ? Math.floor(value)
+ : SLEEP_BETWEEN_DOWNLOADS_DEFAULT_SECONDS;
+ if (n < 0) return 0;
+ if (n > SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS) {
+ return SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS;
+ }
+ return n;
+}
+
export function getSettings(): SiteSettings {
const file = getPaths().settingsFile;
let parsed: Partial<SiteSettings> = {};
@@ -77,6 +98,9 @@ export function getSettings(): SiteSettings {
if (typeof merged.cookiesFromBrowser !== "string") {
merged.cookiesFromBrowser = "";
}
+ merged.sleepBetweenDownloadsSeconds = clampSleepBetweenDownloadsSeconds(
+ merged.sleepBetweenDownloadsSeconds,
+ );
return merged;
}
@@ -127,6 +151,9 @@ export async function writeSettings(next: SiteSettings): Promise<void> {
typeof next.cookiesFromBrowser === "string"
? next.cookiesFromBrowser.trim()
: "",
+ sleepBetweenDownloadsSeconds: clampSleepBetweenDownloadsSeconds(
+ next.sleepBetweenDownloadsSeconds,
+ ),
};
const tmp = `${file}.tmp-${process.pid}`;
await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n");
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -86,6 +86,21 @@ function channelRoot(opts: RunYtdlpOpts): string {
return path.join(opts.paths.channelsDir, opts.channelSlug);
}
+function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
+ if (signal.aborted) return Promise.resolve();
+ return new Promise((resolve) => {
+ const t = setTimeout(() => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+ const onAbort = () => {
+ clearTimeout(t);
+ resolve();
+ };
+ signal.addEventListener("abort", onAbort, { once: true });
+ });
+}
+
function configArgs(config: ChannelConfig): string[] {
const args: string[] = [];
if (config.ytdlpExtraArgs?.length) args.push(...config.ytdlpExtraArgs);
@@ -286,7 +301,11 @@ async function downloadPlaylistManaged(
return;
}
- const globalCookies = getSettings().cookiesFromBrowser;
+ const settings = getSettings();
+ const globalCookies = settings.cookiesFromBrowser;
+ const sleepSeconds =
+ opts.channelConfig.sleepBetweenDownloadsSeconds ??
+ settings.sleepBetweenDownloadsSeconds;
if (opts.ignoreArchive) {
opts.onLog(
"Ignoring archive: archive entries will NOT be appended for successful downloads in this run.\n",
@@ -301,6 +320,7 @@ async function downloadPlaylistManaged(
let failedCount = 0;
const abortOnError = opts.abortOnError !== false;
let firstFailure: Error | null = null;
+ let processedCount = 0;
await Promise.all(
items.map((url) =>
@@ -325,6 +345,18 @@ async function downloadPlaylistManaged(
);
}
}
+ processedCount++;
+ const isLast = processedCount >= items.length;
+ const willAbortLoop = firstFailure !== null && abortOnError;
+ if (
+ sleepSeconds > 0 &&
+ !isLast &&
+ !willAbortLoop &&
+ !opts.signal.aborted
+ ) {
+ opts.onLog(`Sleeping ${sleepSeconds}s before next download...\n`);
+ await abortableSleep(sleepSeconds * 1000, opts.signal);
+ }
}),
),
);
diff --git a/editor/app/build/page.tsx b/editor/app/build/page.tsx
@@ -1,16 +1,42 @@
import type { Metadata } from "next";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { RunningJobsList } from "../jobs/components/RunningJobsList";
import { BuildButtons } from "./components/BuildButtons";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Build" };
+const BUILD_KINDS = new Set([
+ "build-index",
+ "build-export",
+ "normalize-transcripts",
+ "archive-transcripts",
+ "archive-combined-transcripts",
+]);
+
export default function BuildPage() {
- const existingQueues = getRegistry().activeQueueNames();
+ const registry = getRegistry();
+ const existingQueues = registry.activeQueueNames();
+ const activeJobs = registry
+ .list()
+ .filter(
+ (j) =>
+ BUILD_KINDS.has(j.kind) &&
+ (j.status === "running" || j.status === "queued"),
+ )
+ .map((j) => ({
+ id: j.id,
+ kind: j.kind,
+ status: j.status as "queued" | "running",
+ queueKey: j.queueKey,
+ channelSlug: j.channelSlug,
+ videoId: j.videoId,
+ }));
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold">Build</h1>
+ <RunningJobsList jobs={activeJobs} />
<BuildButtons existingQueues={existingQueues} />
</div>
);
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -23,6 +23,7 @@ import {
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { ChannelFormClient } from "../components/ChannelFormClient";
import { DeleteChannelForm } from "../components/DeleteChannelForm";
+import { RunningJobsList } from "../../jobs/components/RunningJobsList";
import { StagesView } from "./components/StagesView";
import { StatusHeader } from "./components/StatusHeader";
import { CleanupStage } from "./components/stages/CleanupStage";
@@ -225,6 +226,18 @@ export default async function ChannelDetailPage({
transcodeApplies={transcodeApplies}
/>
+ <RunningJobsList
+ jobs={runningJobs.map((j) => ({
+ id: j.id,
+ kind: j.kind,
+ status: j.status as "queued" | "running",
+ queueKey: j.queueKey,
+ channelSlug: j.channelSlug,
+ videoId: j.videoId,
+ }))}
+ hideChannelSlug
+ />
+
<StagesView stages={stages} stageOrder={stageOrder} panels={panels} />
</div>
);
diff --git a/editor/app/channels/[slug]/videos/[id]/components/TextFilePreview.tsx b/editor/app/channels/[slug]/videos/[id]/components/TextFilePreview.tsx
@@ -36,7 +36,15 @@ export function TextFilePreview({ src, filename }: Props) {
})
.then((text) => {
if (controller.signal.aborted) return;
- setPhase({ kind: "ready", text });
+ let display = text;
+ if (filename.toLowerCase().endsWith(".json")) {
+ try {
+ display = JSON.stringify(JSON.parse(text), null, 2);
+ } catch {
+ // Keep raw text on parse failure (empty file, truncated, etc).
+ }
+ }
+ setPhase({ kind: "ready", text: display });
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx
@@ -12,6 +12,7 @@ import {
platformQueueKey,
} from "yt-dlp-transcript-common/lib/platform";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { RunningJobsList } from "../../../../jobs/components/RunningJobsList";
import { VideoPanel, type VideoFile } from "./components/VideoPanel";
export const dynamic = "force-dynamic";
@@ -96,7 +97,24 @@ export default async function VideoDetailPage({
path.join(getPaths().channelsDir, slug, "data", id),
);
- const existingQueues = getRegistry().activeQueueNames();
+ const registry = getRegistry();
+ const existingQueues = registry.activeQueueNames();
+ const activeJobs = registry
+ .list()
+ .filter(
+ (j) =>
+ j.channelSlug === slug &&
+ j.videoId === id &&
+ (j.status === "running" || j.status === "queued"),
+ )
+ .map((j) => ({
+ id: j.id,
+ kind: j.kind,
+ status: j.status as "queued" | "running",
+ queueKey: j.queueKey,
+ channelSlug: j.channelSlug,
+ videoId: j.videoId,
+ }));
const defaultQueueKey = platformQueueKey(
config.platform ?? detectPlatform(config.url),
);
@@ -142,6 +160,8 @@ export default async function VideoDetailPage({
</div>
</header>
+ <RunningJobsList jobs={activeJobs} hideChannelSlug hideVideoId />
+
<VideoPanel
slug={slug}
videoId={id}
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -8,11 +8,17 @@ import {
channelExists,
createChannel,
deleteChannel,
+ listChannels,
readChannelConfig,
writeChannelConfig,
} from "yt-dlp-transcript-common/controller/channels";
import { generateChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
-import { parseChannelForm } from "./components/parseChannelForm";
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import {
+ CHANNEL_FORM_FIELDS,
+ parseChannelForm,
+} from "./components/parseChannelForm";
+import { syncAction } from "./[slug]/pipelineActions";
export type ActionResult = { error: string } | undefined;
@@ -55,19 +61,15 @@ export async function updateChannelAction(
const paths = getPaths();
const existing = await readChannelConfig(paths, slug);
if (!existing) return { error: `Channel "${slug}" not found` };
- // Preserve writeback fields the form doesn't expose.
- const merged = {
- ...existing,
- ...parsed.config,
- };
- // The form parser only emits boolean toggles when set, so an unchecked
- // box leaves them undefined in `parsed.config`. The merge keeps the
- // previous value, which is wrong — explicitly clear them here.
- if (parsed.config.keepSourceVideo !== true) delete merged.keepSourceVideo;
- if (existing.lastSyncedAt) merged.lastSyncedAt = existing.lastSyncedAt;
- if (existing.lastFullDownloadAt) {
- merged.lastFullDownloadAt = existing.lastFullDownloadAt;
- }
+ // The form parser only emits keys whose form value is meaningful, so a
+ // cleared input is absent from `parsed.config`. A plain spread would keep
+ // the stale value from `existing`. Clear every form-managed key from the
+ // baseline first, then layer the parsed config — non-form fields like
+ // subLangs / lastSyncedAt / lastFullDownloadAt / excludeFromBuild are
+ // preserved automatically.
+ const merged: typeof existing = { ...existing };
+ for (const key of CHANNEL_FORM_FIELDS) delete merged[key];
+ Object.assign(merged, parsed.config);
await writeChannelConfig(paths, slug, merged);
revalidatePath("/channels");
revalidatePath(`/channels/${slug}`);
@@ -112,6 +114,50 @@ export async function toggleChannelBuildInclusionAction(
return undefined;
}
+export type SyncAllResult = {
+ queued: string[];
+ skipped: { slug: string; reason: string }[];
+};
+
+export async function syncAllChannelsAction(): Promise<SyncAllResult> {
+ const paths = getPaths();
+ const channels = await listChannels(paths);
+ const active = new Set(
+ getRegistry()
+ .list()
+ .filter(
+ (j) =>
+ j.kind === "sync" &&
+ (j.status === "queued" || j.status === "running") &&
+ j.channelSlug,
+ )
+ .map((j) => j.channelSlug as string),
+ );
+ const queued: string[] = [];
+ const skipped: { slug: string; reason: string }[] = [];
+ for (const c of channels) {
+ if (!c.config.url) {
+ skipped.push({ slug: c.slug, reason: "no url" });
+ continue;
+ }
+ if (active.has(c.slug)) {
+ skipped.push({ slug: c.slug, reason: "already running" });
+ continue;
+ }
+ const result = await syncAction(c.slug);
+ if (!result.ok) {
+ skipped.push({ slug: c.slug, reason: result.error });
+ continue;
+ }
+ queued.push(c.slug);
+ // Nobody will read the stream here — cancel it so the buffered chunks
+ // can be GC'd. The job keeps running and writes to its log file via
+ // runManagedFunction's onLog regardless.
+ void result.stream.cancel();
+ }
+ return { queued, skipped };
+}
+
export async function deleteChannelAction(
slug: string,
_prev: ActionResult,
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -145,6 +145,32 @@ export function ChannelForm({
className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono"
/>
</label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">
+ Sleep between managed downloads
+ <span className="text-zinc-500 font-normal">
+ {" "}
+ (seconds, optional)
+ </span>
+ </span>
+ <input
+ type="number"
+ name="sleepBetweenDownloadsSeconds"
+ defaultValue={
+ c?.sleepBetweenDownloadsSeconds != null
+ ? String(c.sleepBetweenDownloadsSeconds)
+ : ""
+ }
+ min={0}
+ max={600}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ />
+ <span className="text-xs text-zinc-500">
+ Pause inserted between per-video yt-dlp invocations for this
+ channel's managed downloads. Leave blank to use the global default
+ from /settings. 0 disables.
+ </span>
+ </label>
</CollapsibleSection>
<div className="flex gap-2">
<button
diff --git a/editor/app/channels/components/SyncAllChannelsButton.tsx b/editor/app/channels/components/SyncAllChannelsButton.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import {
+ syncAllChannelsAction,
+ type SyncAllResult,
+} from "../actions";
+
+type Status =
+ | { kind: "idle" }
+ | { kind: "running" }
+ | { kind: "done"; result: SyncAllResult }
+ | { kind: "error"; message: string };
+
+export function SyncAllChannelsButton() {
+ const [status, setStatus] = useState<Status>({ kind: "idle" });
+
+ async function handleClick() {
+ setStatus({ kind: "running" });
+ try {
+ const result = await syncAllChannelsAction();
+ setStatus({ kind: "done", result });
+ } catch (e) {
+ setStatus({ kind: "error", message: (e as Error).message });
+ }
+ }
+
+ const running = status.kind === "running";
+ return (
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={handleClick}
+ disabled={running}
+ aria-label="sync all channels"
+ className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {running ? "Syncing…" : "Sync all"}
+ </button>
+ {status.kind === "done" && (
+ <span
+ aria-label="sync all result"
+ className="text-xs text-zinc-500"
+ title={
+ status.result.skipped.length === 0
+ ? undefined
+ : status.result.skipped
+ .map((s) => `${s.slug}: ${s.reason}`)
+ .join("\n")
+ }
+ >
+ Queued {status.result.queued.length} · skipped{" "}
+ {status.result.skipped.length} ·{" "}
+ <Link
+ href="/jobs"
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ view jobs
+ </Link>
+ </span>
+ )}
+ {status.kind === "error" && (
+ <span
+ role="alert"
+ aria-label="sync all error"
+ className="text-xs text-red-700 dark:text-red-400"
+ >
+ {status.message}
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/channels/components/parseChannelForm.ts b/editor/app/channels/components/parseChannelForm.ts
@@ -11,6 +11,19 @@ export type ParsedChannelForm = {
config: ChannelConfig;
};
+// ChannelConfig keys the form authoritatively controls. updateChannelAction
+// clears these from the existing config before layering parsed.config on top,
+// so a cleared input actually clears the stored value instead of inheriting
+// the previous one. New form fields just get added here.
+export const CHANNEL_FORM_FIELDS = [
+ "platform",
+ "url",
+ "audioFormat",
+ "keepSourceVideo",
+ "ytdlpExtraArgs",
+ "sleepBetweenDownloadsSeconds",
+] as const satisfies ReadonlyArray<keyof ChannelConfig>;
+
// Maps FormData -> ChannelConfig. Throws on invalid input so the server
// action can render an error message.
export function parseChannelForm(formData: FormData): ParsedChannelForm {
@@ -47,6 +60,20 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
.filter(Boolean)
: undefined;
+ const sleepRaw = String(
+ formData.get("sleepBetweenDownloadsSeconds") ?? "",
+ ).trim();
+ let sleepBetweenDownloadsSeconds: number | undefined;
+ if (sleepRaw) {
+ const n = Number.parseInt(sleepRaw, 10);
+ if (!Number.isFinite(n) || n < 0 || n > 600) {
+ throw new Error(
+ "Sleep between managed downloads must be 0–600 seconds",
+ );
+ }
+ sleepBetweenDownloadsSeconds = n;
+ }
+
const config: ChannelConfig = {
handling: handlingRaw,
name,
@@ -56,6 +83,9 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
if (audioFormat) config.audioFormat = audioFormat;
if (keepSourceVideo) config.keepSourceVideo = true;
if (ytdlpExtraArgs?.length) config.ytdlpExtraArgs = ytdlpExtraArgs;
+ if (sleepBetweenDownloadsSeconds != null) {
+ config.sleepBetweenDownloadsSeconds = sleepBetweenDownloadsSeconds;
+ }
return { name, slug, config };
}
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -5,6 +5,7 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { ChannelAvailabilityButton } from "./components/ChannelAvailabilityButton";
import { ChannelBuildToggle } from "./components/ChannelBuildToggle";
import { ChannelSyncButton } from "./components/ChannelSyncButton";
+import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton";
export const dynamic = "force-dynamic";
@@ -16,12 +17,15 @@ export default async function ChannelsPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Channels</h1>
- <Link
- href="/channels/new"
- className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90"
- >
- New channel
- </Link>
+ <div className="flex items-center gap-2">
+ <SyncAllChannelsButton />
+ <Link
+ href="/channels/new"
+ className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90"
+ >
+ New channel
+ </Link>
+ </div>
</div>
{channels.length === 0 ? (
<p className="text-sm text-zinc-500 border border-dashed border-zinc-300 dark:border-zinc-700 rounded p-4">
diff --git a/editor/app/jobs/components/RunningJobsList.tsx b/editor/app/jobs/components/RunningJobsList.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { JobLogTail } from "../[id]/components/JobLogTail";
+
+export type RunningJobsListItem = {
+ id: string;
+ kind: string;
+ status: "queued" | "running";
+ queueKey: string;
+ channelSlug?: string;
+ videoId?: string;
+};
+
+type Props = {
+ jobs: RunningJobsListItem[];
+ hideChannelSlug?: boolean;
+ hideVideoId?: boolean;
+};
+
+export function RunningJobsList({ jobs, hideChannelSlug, hideVideoId }: Props) {
+ if (jobs.length === 0) return null;
+ return (
+ <section
+ aria-label="Active jobs"
+ className="flex flex-col gap-2 border border-zinc-200 dark:border-zinc-800 rounded-md p-3 bg-zinc-50 dark:bg-zinc-900/40"
+ >
+ <h2 className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
+ Active jobs
+ </h2>
+ <ul className="flex flex-col gap-2">
+ {jobs.map((j) => (
+ <JobRow
+ key={j.id}
+ job={j}
+ hideChannelSlug={hideChannelSlug}
+ hideVideoId={hideVideoId}
+ />
+ ))}
+ </ul>
+ </section>
+ );
+}
+
+function JobRow({
+ job,
+ hideChannelSlug,
+ hideVideoId,
+}: {
+ job: RunningJobsListItem;
+ hideChannelSlug?: boolean;
+ hideVideoId?: boolean;
+}) {
+ const [showLog, setShowLog] = useState(false);
+ const statusClass =
+ job.status === "running"
+ ? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
+ : "bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200";
+ return (
+ <li className="flex flex-col gap-2 border-t border-zinc-200 dark:border-zinc-800 pt-2 first:border-t-0 first:pt-0">
+ <div className="flex flex-wrap items-center gap-2 text-sm">
+ <span
+ className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded ${statusClass}`}
+ >
+ {job.status}
+ </span>
+ <span className="font-mono text-xs">{job.kind}</span>
+ <Link
+ href={`/jobs/${job.id}`}
+ className="font-mono text-xs underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ Job {job.id}
+ </Link>
+ {!hideChannelSlug && job.channelSlug && (
+ <Link
+ href={`/channels/${job.channelSlug}`}
+ className="font-mono text-xs underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {job.channelSlug}
+ </Link>
+ )}
+ {!hideVideoId && job.channelSlug && job.videoId && (
+ <Link
+ href={`/channels/${job.channelSlug}/videos/${encodeURIComponent(job.videoId)}`}
+ className="font-mono text-xs underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {job.videoId}
+ </Link>
+ )}
+ {job.status === "queued" && (
+ <span className="text-xs text-zinc-500">
+ queue <code className="font-mono">{job.queueKey || "—"}</code>
+ </span>
+ )}
+ <button
+ type="button"
+ onClick={() => setShowLog((s) => !s)}
+ aria-expanded={showLog}
+ aria-label={`${showLog ? "hide" : "show"} log for job ${job.id}`}
+ className="ml-auto px-2 py-1 rounded-md bg-zinc-200 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-xs font-medium hover:opacity-90"
+ >
+ {showLog ? "Hide log" : "Show log"}
+ </button>
+ </div>
+ {showLog && <JobLogTail jobId={job.id} initiallyRunning />}
+ </li>
+ );
+}
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache";
import {
+ SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS,
TRANSCRIPT_PAGE_HARD_CAP_BYTES,
TRANSCRIPT_PAGE_MIN_BYTES,
validateTranscribeArgs,
@@ -25,6 +26,9 @@ export async function saveSettingsAction(
const cookiesFromBrowser = String(
formData.get("cookiesFromBrowser") ?? "",
).trim();
+ const sleepRaw = String(
+ formData.get("sleepBetweenDownloadsSeconds") ?? "",
+ ).trim();
const transcribeArgsRaw = String(formData.get("transcribeArgs") ?? "");
const transcribeArgs = transcribeArgsRaw
.split("\n")
@@ -54,6 +58,23 @@ export async function saveSettingsAction(
};
}
+ const sleepParsed = Number.parseInt(sleepRaw, 10);
+ if (!Number.isFinite(sleepParsed)) {
+ return {
+ ok: false,
+ error: "sleepBetweenDownloadsSeconds must be a number",
+ };
+ }
+ if (
+ sleepParsed < 0 ||
+ sleepParsed > SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS
+ ) {
+ return {
+ ok: false,
+ error: `sleepBetweenDownloadsSeconds must be between 0 and ${SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS}`,
+ };
+ }
+
const next: SiteSettings = {
siteTitle,
siteDescription,
@@ -64,6 +85,7 @@ export async function saveSettingsAction(
transcribeModel,
transcribeArgs,
cookiesFromBrowser,
+ sleepBetweenDownloadsSeconds: sleepParsed,
};
await writeSettings(next);
revalidatePath("/settings");
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -93,6 +93,13 @@ export function SettingsForm({ initial }: Props) {
defaultValue={initial.cookiesFromBrowser}
hint="Browser spec passed to yt-dlp --cookies-from-browser only when the primary download attempt fails with an auth/age error and the channel doesn't have its own cookies set. e.g. firefox, chrome:Default. Leave blank to disable."
/>
+ <Field
+ label="Sleep between managed downloads (seconds)"
+ name="sleepBetweenDownloadsSeconds"
+ defaultValue={String(initial.sleepBetweenDownloadsSeconds)}
+ type="number"
+ hint="Pause inserted between per-video yt-dlp invocations in managed batch downloads (download-from-playlist, download-missing). 0 disables. Default 10s. Each channel can override this in its Advanced settings."
+ />
<div className="flex items-center gap-3">
<button
type="submit"
diff --git a/editor/e2e/video-page.spec.ts b/editor/e2e/video-page.spec.ts
@@ -296,14 +296,53 @@ test("text files render a preview, not the audio player", async ({ page }) => {
"fake cue",
);
- // audio.json — text file, same shape.
+ // audio.json — text file, same shape. Content is pretty-printed.
await expect(fileList.getByLabel("media player for audio.json")).toHaveCount(0);
const jsonPreview = fileList.getByLabel("preview audio.json");
await expect(jsonPreview).toBeVisible();
await jsonPreview.getByText("Preview contents").click();
- await expect(fileList.getByLabel("preview of audio.json")).toContainText(
- '"hello":"world"',
+ const jsonPre = fileList.getByLabel("preview of audio.json");
+ await expect(jsonPre).toBeVisible();
+ // The on-disk file is a single line; the preview should re-indent it.
+ expect(await jsonPre.textContent()).toBe('{\n "hello": "world"\n}');
+});
+
+test("JSON preview pretty-prints minified content", async ({ page }) => {
+ await resetData("one-youtube-channel-with-data");
+ const dir = resolvePath(
+ "test-transcripts/channels/test-youtube/data/20240101_test1234567",
+ );
+ await writeFile(`${dir}/nested.json`, '{"a":1,"b":[2,3],"c":{"d":"e"}}\n');
+
+ await page.goto("/channels/test-youtube/videos/20240101_test1234567");
+
+ const fileList = page.getByLabel("files for 20240101_test1234567");
+ await fileList.getByLabel("preview nested.json").getByText("Preview contents").click();
+ const pre = fileList.getByLabel("preview of nested.json");
+ expect(await pre.textContent()).toBe(
+ '{\n "a": 1,\n "b": [\n 2,\n 3\n ],\n "c": {\n "d": "e"\n }\n}',
+ );
+});
+
+test("JSON preview falls back to raw text when the file is not valid JSON", async ({
+ page,
+}) => {
+ await resetData("one-youtube-channel-with-data");
+ const dir = resolvePath(
+ "test-transcripts/channels/test-youtube/data/20240101_test1234567",
);
+ const raw = "not valid json {{{";
+ await writeFile(`${dir}/broken.json`, raw);
+
+ await page.goto("/channels/test-youtube/videos/20240101_test1234567");
+
+ const fileList = page.getByLabel("files for 20240101_test1234567");
+ await fileList.getByLabel("preview broken.json").getByText("Preview contents").click();
+ const pre = fileList.getByLabel("preview of broken.json");
+ await expect(pre).toBeVisible();
+ expect(await pre.textContent()).toBe(raw);
+ // No error state — the fetch succeeded; only parsing failed.
+ await expect(fileList.getByRole("alert")).toHaveCount(0);
});
test("audioFiles count excludes audio.vtt and audio.json", async ({ page }) => {