Archilyzer · Source

archilyzer

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

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:
Mcommon/lib/channelConfig.ts | 16++++++++++++++++
Mcommon/lib/settings.ts | 27+++++++++++++++++++++++++++
Mcommon/ytdlp/runYtdlp.ts | 34+++++++++++++++++++++++++++++++++-
Meditor/app/build/page.tsx | 28+++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/page.tsx | 13+++++++++++++
Meditor/app/channels/[slug]/videos/[id]/components/TextFilePreview.tsx | 10+++++++++-
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 22+++++++++++++++++++++-
Meditor/app/channels/actions.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Meditor/app/channels/components/ChannelForm.tsx | 26++++++++++++++++++++++++++
Aeditor/app/channels/components/SyncAllChannelsButton.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/parseChannelForm.ts | 30++++++++++++++++++++++++++++++
Meditor/app/channels/page.tsx | 16++++++++++------
Aeditor/app/jobs/components/RunningJobsList.tsx | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/settings/actions.ts | 22++++++++++++++++++++++
Meditor/app/settings/components/SettingsForm.tsx | 7+++++++
Meditor/e2e/video-page.spec.ts | 45++++++++++++++++++++++++++++++++++++++++++---
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 }) => {