commit b5c3dbf99024054c0f2000440601466008d864d9
parent 9f9564c00ca208cae61ad274bf42af6cbc122804
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 2 May 2026 09:35:30 -0400
more editor features
Diffstat:
48 files changed, 2885 insertions(+), 262 deletions(-)
diff --git a/common/components/StreamActionLog.tsx b/common/components/StreamActionLog.tsx
@@ -14,6 +14,11 @@ type Props = {
*/
label?: string;
cancelAction?: (id: string) => Promise<{ ok: boolean }>;
+ /**
+ * Rendered inline with the action button (after Cancel and the job-id
+ * span). Used to attach a per-button QueueControl.
+ */
+ extraControls?: React.ReactNode;
};
type StatusPoll = {
@@ -28,6 +33,7 @@ export function StreamActionLog({
runningLabel = "Running…",
label,
cancelAction,
+ extraControls,
}: Props) {
const accessibleName = label ?? buttonLabel;
const [running, setRunning] = useState(false);
@@ -152,6 +158,7 @@ export function StreamActionLog({
{jobId && (
<span className="text-xs text-zinc-500 font-mono">job {jobId}</span>
)}
+ {extraControls}
</div>
{error && (
<div
diff --git a/common/controller/channels.ts b/common/controller/channels.ts
@@ -1,16 +1,20 @@
import path from "node:path";
import { readdir, readFile, writeFile, rename, rm, stat, mkdir } from "node:fs/promises";
import type { Dirent } from "node:fs";
-import { readArchive } from "../lib/archive";
-import { parseChannelConfig, type ChannelConfig } from "../lib/channelConfig";
+import {
+ parseChannelConfig,
+ type ChannelConfig,
+ type ChannelHandling,
+} from "../lib/channelConfig";
import type { Paths } from "../lib/paths";
export type ChannelStat = {
slug: string;
config: ChannelConfig;
- archiveCount: number | null;
playlistCount: number | null;
videoCount: number;
+ transcriptCount: number;
+ downloadCount: number;
};
async function exists(p: string): Promise<boolean> {
@@ -22,10 +26,42 @@ async function exists(p: string): Promise<boolean> {
}
}
-async function countArchive(p: string): Promise<number | null> {
- if (!(await exists(p))) return null;
- const parsed = await readArchive(p);
- return parsed.ids.size;
+async function countDataFiles(
+ dataDir: string,
+ handling: ChannelHandling,
+): Promise<{ videos: number; transcripts: number; downloads: number }> {
+ let dirs: Dirent[];
+ try {
+ dirs = await readdir(dataDir, { withFileTypes: true });
+ } catch {
+ return { videos: 0, transcripts: 0, downloads: 0 };
+ }
+ const videoDirs = dirs.filter((d) => d.isDirectory());
+ const flags = await Promise.all(
+ videoDirs.map(async (d) => {
+ const videoDir = path.join(dataDir, d.name);
+ if (handling === "youtube") {
+ // For YouTube channels the destination yt-dlp produces is the auto-sub
+ // .vtt file — same file counts as both "downloaded" and "transcript".
+ const has = await exists(path.join(videoDir, "transcript.en.vtt"));
+ return { transcript: has, download: has };
+ }
+ // Transcribe channels: audio.* is the yt-dlp download, transcript.json
+ // is the whisper output. They diverge until whisper has run.
+ const entries = await readdir(videoDir).catch(() => [] as string[]);
+ return {
+ transcript: entries.includes("transcript.json"),
+ download: entries.some((e) => e.startsWith("audio.")),
+ };
+ }),
+ );
+ let transcripts = 0;
+ let downloads = 0;
+ for (const f of flags) {
+ if (f.transcript) transcripts++;
+ if (f.download) downloads++;
+ }
+ return { videos: videoDirs.length, transcripts, downloads };
}
async function countPlaylist(p: string): Promise<number | null> {
@@ -79,19 +115,14 @@ export async function listChannels(paths: Paths): Promise<ChannelStat[]> {
if (!config) continue;
const channelDir = path.join(paths.channelsDir, slug);
const dataDir = path.join(channelDir, "data");
- let videoCount = 0;
- try {
- const dirs = await readdir(dataDir, { withFileTypes: true });
- videoCount = dirs.filter((d) => d.isDirectory()).length;
- } catch {
- videoCount = 0;
- }
+ const counts = await countDataFiles(dataDir, config.handling);
out.push({
slug,
config,
- archiveCount: await countArchive(path.join(channelDir, "archive")),
playlistCount: await countPlaylist(path.join(channelDir, "playlist")),
- videoCount,
+ videoCount: counts.videos,
+ transcriptCount: counts.transcripts,
+ downloadCount: counts.downloads,
});
}
return out.sort((a, b) => a.slug.localeCompare(b.slug));
diff --git a/common/controller/cleanAudioFromTranscribed.ts b/common/controller/cleanAudioFromTranscribed.ts
@@ -0,0 +1,58 @@
+import path from "node:path";
+import fs from "fs-extra";
+import type { Paths } from "../lib/paths";
+
+const { pathExists, readdir, remove } = fs;
+
+export type CleanAudioOptions = {
+ channelSlug: string;
+ paths: Paths;
+ onLog?: (msg: string) => void;
+ signal?: AbortSignal;
+};
+
+export type CleanAudioResult = {
+ inspected: number;
+ cleanedDirs: number;
+ removedFiles: number;
+};
+
+export async function cleanAudioFromTranscribed({
+ channelSlug,
+ paths,
+ onLog,
+ signal,
+}: CleanAudioOptions): Promise<CleanAudioResult> {
+ const log = onLog ?? ((m: string) => console.log(m));
+ const dataDir = path.join(paths.channelsDir, channelSlug, "data");
+ if (!(await pathExists(dataDir))) {
+ log(`No data directory for ${channelSlug}`);
+ return { inspected: 0, cleanedDirs: 0, removedFiles: 0 };
+ }
+ const dirs = await readdir(dataDir);
+
+ let cleanedDirs = 0;
+ let removedFiles = 0;
+
+ for (const id of dirs) {
+ if (signal?.aborted) break;
+ const videoDir = path.join(dataDir, id);
+ const entries = await readdir(videoDir).catch(() => [] as string[]);
+ if (!entries.includes("transcript.json")) continue;
+ const audioFiles = entries.filter(
+ (e) => e.startsWith("audio.") && !e.includes(".tmp-"),
+ );
+ if (audioFiles.length === 0) continue;
+ for (const f of audioFiles) {
+ await remove(path.join(videoDir, f));
+ log(`Removed ${id}/${f}`);
+ removedFiles++;
+ }
+ cleanedDirs++;
+ }
+
+ log(
+ `Cleaned ${removedFiles} audio file(s) from ${cleanedDirs} of ${dirs.length} video dir(s).`,
+ );
+ return { inspected: dirs.length, cleanedDirs, removedFiles };
+}
diff --git a/common/controller/failedTranscriptions.ts b/common/controller/failedTranscriptions.ts
@@ -0,0 +1,22 @@
+import { readFile, rename, writeFile } from "node:fs/promises";
+
+export async function pruneFailedTranscriptions(
+ failureListFile: string,
+ idsToRemove: Set<string>,
+): Promise<{ remaining: number; pruned: number }> {
+ let raw: string;
+ try {
+ raw = await readFile(failureListFile, "utf8");
+ } catch {
+ return { remaining: 0, pruned: 0 };
+ }
+ const original = raw.split("\n").filter(Boolean);
+ const filtered = original.filter((id) => !idsToRemove.has(id));
+ const tmp = `${failureListFile}.tmp-${process.pid}`;
+ await writeFile(tmp, filtered.length ? filtered.join("\n") + "\n" : "");
+ await rename(tmp, failureListFile);
+ return {
+ remaining: filtered.length,
+ pruned: original.length - filtered.length,
+ };
+}
diff --git a/common/controller/transcode.ts b/common/controller/transcode.ts
@@ -0,0 +1,57 @@
+import path from "node:path";
+import { rename, rm } from "node:fs/promises";
+import { execa } from "execa";
+import type { Paths } from "../lib/paths";
+import type { AudioFormat } from "../lib/channelConfig";
+
+const CODEC_ARGS: Record<AudioFormat, string[]> = {
+ m4a: ["-c:a", "aac"],
+ mp3: ["-c:a", "libmp3lame"],
+ opus: ["-c:a", "libopus"],
+};
+
+export type TranscodeAudioOptions = {
+ paths: Paths;
+ videoDir: string;
+ sourceFilename: string;
+ targetFormat: AudioFormat;
+ onLog: (s: string) => void;
+ signal: AbortSignal;
+};
+
+export async function transcodeAudio(opts: TranscodeAudioOptions): Promise<void> {
+ const src = path.join(opts.videoDir, opts.sourceFilename);
+ const out = path.join(opts.videoDir, `audio.${opts.targetFormat}`);
+ if (src === out) {
+ throw new Error("Source and target are the same file");
+ }
+ // Keep the target extension last so ffmpeg can infer the output format.
+ const tmp = path.join(
+ opts.videoDir,
+ `audio.tmp-${process.pid}.${opts.targetFormat}`,
+ );
+ const args = [
+ "-y",
+ "-i",
+ src,
+ "-vn",
+ ...CODEC_ARGS[opts.targetFormat],
+ tmp,
+ ];
+ opts.onLog(`$ ${opts.paths.ffmpegBin} ${args.join(" ")}\n`);
+ const child = execa(opts.paths.ffmpegBin, args, {
+ cancelSignal: opts.signal,
+ all: true,
+ buffer: false,
+ reject: false,
+ });
+ child.all?.on("data", (c: Buffer) => opts.onLog(c.toString("utf8")));
+ const result = await child;
+ if (result.exitCode !== 0) {
+ await rm(tmp, { force: true });
+ if (opts.signal.aborted) return;
+ throw new Error(`ffmpeg exited with code ${result.exitCode}`);
+ }
+ await rename(tmp, out);
+ opts.onLog(`Wrote ${out}\n`);
+}
diff --git a/common/controller/transcodeFailures.ts b/common/controller/transcodeFailures.ts
@@ -0,0 +1,120 @@
+import path from "node:path";
+import fs from "fs-extra";
+import pLimit from "p-limit";
+import type { Paths } from "../lib/paths";
+import type { AudioFormat } from "../lib/channelConfig";
+import { transcodeAudio } from "./transcode";
+
+const { pathExists, readdir, readFile, ensureFile } = fs;
+
+const AUDIO_PREFERENCE = ["audio.m4a", "audio.opus", "audio.mp3"];
+
+function pickSourceAudio(
+ entries: string[],
+ targetFormat: AudioFormat,
+): string | null {
+ const target = `audio.${targetFormat}`;
+ const candidates = entries.filter(
+ (e) => e.startsWith("audio.") && e !== target && !e.includes(".tmp-"),
+ );
+ if (candidates.length === 0) return null;
+ for (const preferred of AUDIO_PREFERENCE) {
+ if (preferred !== target && candidates.includes(preferred)) {
+ return preferred;
+ }
+ }
+ return [...candidates].sort()[0];
+}
+
+export type TranscodeFailuresOptions = {
+ channelSlug: string;
+ paths: Paths;
+ targetFormat: AudioFormat;
+ concurrency?: number;
+ onLog?: (msg: string) => void;
+ signal?: AbortSignal;
+};
+
+export type TranscodeFailuresResult = {
+ attempted: number;
+ succeeded: number;
+ failed: number;
+ skipped: number;
+};
+
+export async function runTranscodeFailures({
+ channelSlug,
+ paths,
+ targetFormat,
+ concurrency,
+ onLog,
+ signal,
+}: TranscodeFailuresOptions): Promise<TranscodeFailuresResult> {
+ const log = onLog ?? ((m: string) => console.log(m));
+ const channelDir = path.join(paths.channelsDir, channelSlug);
+ const dataDir = path.join(channelDir, "data");
+ const failureListFile = path.join(channelDir, "failed-transcriptions");
+ const limit = pLimit(concurrency ?? 1);
+
+ await ensureFile(failureListFile);
+ const ids = (await readFile(failureListFile, "utf-8"))
+ .split("\n")
+ .filter(Boolean);
+
+ let attempted = 0;
+ let succeeded = 0;
+ let failed = 0;
+ let skipped = 0;
+
+ await Promise.all(
+ ids.map((id) =>
+ limit(async () => {
+ if (signal?.aborted) {
+ skipped++;
+ return;
+ }
+ const videoDir = path.join(dataDir, id);
+ if (!(await pathExists(videoDir))) {
+ log(`Skipping ${id}: directory no longer exists`);
+ skipped++;
+ return;
+ }
+ const entries = await readdir(videoDir);
+ if (entries.includes(`audio.${targetFormat}`)) {
+ log(`Skipping ${id}: audio.${targetFormat} already exists`);
+ skipped++;
+ return;
+ }
+ const source = pickSourceAudio(entries, targetFormat);
+ if (!source) {
+ log(`Skipping ${id}: no source audio file found`);
+ skipped++;
+ return;
+ }
+ attempted++;
+ try {
+ log(`Transcoding ${id}: ${source} → audio.${targetFormat}`);
+ await transcodeAudio({
+ paths,
+ videoDir,
+ sourceFilename: source,
+ targetFormat,
+ onLog: log,
+ signal: signal ?? new AbortController().signal,
+ });
+ succeeded++;
+ } catch (err) {
+ if (signal?.aborted) {
+ log(`Transcode ${id} cancelled`);
+ skipped++;
+ return;
+ }
+ log(`FAILED TO TRANSCODE ${id}: ${String(err)}`);
+ failed++;
+ }
+ }),
+ ),
+ );
+
+ return { attempted, succeeded, failed, skipped };
+}
diff --git a/common/controller/transcribeOne.ts b/common/controller/transcribeOne.ts
@@ -0,0 +1,86 @@
+import path from "node:path";
+import fs from "fs-extra";
+import { execa } from "execa";
+import type { Paths } from "../lib/paths";
+
+const { pathExists, readdir, rename } = fs;
+
+const AUDIO_PREFERENCE = ["audio.mp3", "audio.m4a", "audio.opus"];
+
+async function resolveAudioFile(
+ videoDir: string,
+ requested: string,
+): Promise<string | null> {
+ if (await pathExists(path.join(videoDir, requested))) return requested;
+ const entries = await readdir(videoDir);
+ const candidates = entries.filter((e) => e.startsWith("audio."));
+ if (candidates.length === 0) return null;
+ for (const preferred of AUDIO_PREFERENCE) {
+ if (candidates.includes(preferred)) return preferred;
+ }
+ return [...candidates].sort()[0];
+}
+
+export type TranscribeOneOptions = {
+ paths: Paths;
+ videoDir: string;
+ videoId: string;
+ audioFilename: string;
+ onLog?: (msg: string) => void;
+ signal?: AbortSignal;
+};
+
+export type TranscribeOneOutcome = "transcribed" | "already-exists";
+
+export async function transcribeOneVideo(
+ opts: TranscribeOneOptions,
+): Promise<TranscribeOneOutcome> {
+ const log = opts.onLog ?? ((m: string) => console.log(m));
+ const transcriptPath = path.join(opts.videoDir, "transcript.json");
+
+ if (await pathExists(transcriptPath)) {
+ log(`Transcription for ${opts.videoId} already exists`);
+ return "already-exists";
+ }
+
+ const resolvedAudio = await resolveAudioFile(opts.videoDir, opts.audioFilename);
+ if (!resolvedAudio) {
+ throw new Error(`no audio file found in ${opts.videoDir}`);
+ }
+ if (resolvedAudio !== opts.audioFilename) {
+ log(`Using ${resolvedAudio} instead of ${opts.audioFilename}`);
+ }
+
+ log(`Transcribe ${opts.videoId} start (${resolvedAudio})`);
+ const start = Date.now();
+ // Whisper writes "transcript.json" relative to cwd. Use a tmp name and
+ // rename on success so a SIGTERM mid-write can't leave a half-baked file.
+ const tmpBase = `transcript.tmp-${process.pid}`;
+ const child = execa(
+ opts.paths.whisperBin,
+ [
+ "-ojf",
+ "-l",
+ "en",
+ "-m",
+ opts.paths.whisperModel,
+ "-of",
+ tmpBase,
+ resolvedAudio,
+ ],
+ {
+ cwd: opts.videoDir,
+ cancelSignal: opts.signal,
+ all: true,
+ buffer: false,
+ },
+ );
+ child.all?.on("data", (c: Buffer) => log(c.toString("utf8")));
+ await child;
+ const tmpPath = path.join(opts.videoDir, `${tmpBase}.json`);
+ await rename(tmpPath, transcriptPath);
+ log(
+ `Transcribe ${opts.videoId} done in ${((Date.now() - start) / 1000).toFixed(2)}s`,
+ );
+ return "transcribed";
+}
diff --git a/common/controller/whisperBatch.ts b/common/controller/whisperBatch.ts
@@ -1,10 +1,11 @@
import path from "node:path";
import fs from "fs-extra";
-import { execa } from "execa";
import pLimit from "p-limit";
import type { Paths } from "../lib/paths";
+import { transcribeOneVideo } from "./transcribeOne";
+import { pruneFailedTranscriptions } from "./failedTranscriptions";
-const { pathExists, readdir, appendFile, readFile, ensureFile, rename } = fs;
+const { pathExists, readdir, appendFile, readFile, ensureFile } = fs;
export type WhisperBatchMode = "all" | "retry-failures";
@@ -30,7 +31,7 @@ export async function runWhisperBatch({
paths,
mode,
concurrency,
- audioFilename = "audio.m4a",
+ audioFilename = "audio.mp3",
onLog,
signal,
}: WhisperBatchOptions): Promise<WhisperBatchResult> {
@@ -38,25 +39,26 @@ export async function runWhisperBatch({
const channelDir = path.join(paths.channelsDir, channelSlug);
const dataDir = path.join(channelDir, "data");
const failureListFile = path.join(channelDir, "failed-transcriptions");
- const transcriptFilename = "transcript.json";
const limit = pLimit(concurrency ?? paths.parallelTranscribeLimit);
await ensureFile(failureListFile);
+ const originalFailures = (await readFile(failureListFile, "utf-8"))
+ .split("\n")
+ .filter(Boolean);
+
let videoDirs: string[];
if (mode === "retry-failures") {
- const lines = await readFile(failureListFile, "utf-8");
- videoDirs = lines.split("\n").filter(Boolean);
+ videoDirs = [...originalFailures];
} else {
videoDirs = await readdir(dataDir);
}
- const failedSet = new Set<string>(
- (await readFile(failureListFile, "utf-8")).split("\n").filter(Boolean),
- );
+ const failedSet = new Set<string>(originalFailures);
+ const succeeded = new Set<string>();
let attempted = 0;
- let succeeded = 0;
+ let succeededCount = 0;
let failed = 0;
let skipped = 0;
@@ -68,47 +70,38 @@ export async function runWhisperBatch({
return;
}
const videoPath = path.join(dataDir, videoDir);
- const transcriptFilePath = path.join(videoPath, transcriptFilename);
if (mode === "all" && failedSet.has(videoDir)) {
log(`Skipping previously failed transcription for ${videoDir}`);
skipped++;
return;
}
- if (await pathExists(transcriptFilePath)) {
+ if (mode === "retry-failures" && !(await pathExists(videoPath))) {
+ log(`Skipping ${videoDir}: directory no longer exists`);
+ skipped++;
+ succeeded.add(videoDir);
+ return;
+ }
+ if (await pathExists(path.join(videoPath, "transcript.json"))) {
log(`Transcription for ${videoDir} already exists`);
skipped++;
+ if (mode === "retry-failures") {
+ // already-good entry — drop it from the failure list too
+ succeeded.add(videoDir);
+ }
return;
}
- log(`Transcribe ${videoDir} start`);
- const start = Date.now();
attempted++;
try {
- // Whisper writes "transcript.json" relative to cwd. Use a tmp name
- // and rename so a SIGTERM mid-write can't leave a half-baked file.
- const tmpBase = `transcript.tmp-${process.pid}`;
- await execa(
- paths.whisperBin,
- [
- "-ojf",
- "-l",
- "en",
- "-m",
- paths.whisperModel,
- "-of",
- tmpBase,
- audioFilename,
- ],
- {
- cwd: videoPath,
- cancelSignal: signal,
- },
- );
- const tmpPath = path.join(videoPath, `${tmpBase}.json`);
- await rename(tmpPath, transcriptFilePath);
- log(
- `Transcribe ${videoDir} done in ${((Date.now() - start) / 1000).toFixed(2)}s`,
- );
- succeeded++;
+ await transcribeOneVideo({
+ paths,
+ videoDir: videoPath,
+ videoId: videoDir,
+ audioFilename,
+ onLog: log,
+ signal,
+ });
+ succeeded.add(videoDir);
+ succeededCount++;
} catch (err) {
if (signal?.aborted) {
log(`Transcribe ${videoDir} cancelled`);
@@ -125,5 +118,15 @@ export async function runWhisperBatch({
),
);
- return { attempted, succeeded, failed, skipped };
+ if (mode === "retry-failures") {
+ const { remaining, pruned } = await pruneFailedTranscriptions(
+ failureListFile,
+ succeeded,
+ );
+ log(
+ `Pruned ${pruned} from failed-transcriptions; ${remaining} remain.`,
+ );
+ }
+
+ return { attempted, succeeded: succeededCount, failed, skipped };
}
diff --git a/common/jobs/listJobs.ts b/common/jobs/listJobs.ts
@@ -7,6 +7,7 @@ export type JobListEntry = {
id: string;
kind?: string;
channelSlug?: string;
+ videoId?: string;
queueKey?: string;
status: JobStatus | "archived";
queuedAt: number;
@@ -55,6 +56,7 @@ export async function listAllJobs(paths: Paths): Promise<JobListEntry[]> {
id,
kind: live_.kind,
channelSlug: live_.channelSlug,
+ videoId: live_.videoId,
queueKey: live_.queueKey,
status: live_.status,
queuedAt: live_.queuedAt,
@@ -87,6 +89,7 @@ export async function listAllJobs(paths: Paths): Promise<JobListEntry[]> {
id: r.id,
kind: r.kind,
channelSlug: r.channelSlug,
+ videoId: r.videoId,
queueKey: r.queueKey,
status: r.status,
queuedAt: r.queuedAt,
diff --git a/common/jobs/registry.ts b/common/jobs/registry.ts
@@ -12,6 +12,7 @@ export type JobRecord = {
kind: string;
queueKey: string;
channelSlug?: string;
+ videoId?: string;
status: JobStatus;
queuedAt: number;
startedAt?: number;
@@ -64,18 +65,29 @@ class JobRegistry {
// Append a job to its queue. If it's the only entry, start it immediately
// and mark "running". Otherwise leave it as "queued" and remember the
// start/cancel callbacks for when it becomes head-of-queue or is cancelled.
+ //
+ // queueKey === "" is special: the job bypasses queue serialization entirely
+ // and runs immediately, parallel to any other work. Cancellation still
+ // works (running → SIGTERM/abort) but there's no queue map entry to clean
+ // up in finalize.
enqueue(
record: JobRecord,
callbacks: { start: StartFn; onCancel: CancelFn },
): { willRunNow: boolean; position: number } {
+ this.starts.set(record, callbacks.start);
+ this.cancels.set(record, callbacks.onCancel);
+ if (record.queueKey === "") {
+ record.status = "running";
+ record.startedAt = Date.now();
+ callbacks.start();
+ return { willRunNow: true, position: 0 };
+ }
let q = this.queues.get(record.queueKey);
if (!q) {
q = [];
this.queues.set(record.queueKey, q);
}
q.push(record);
- this.starts.set(record, callbacks.start);
- this.cancels.set(record, callbacks.onCancel);
if (q.length === 1) {
record.status = "running";
record.startedAt = Date.now();
diff --git a/common/jobs/streamCommand.ts b/common/jobs/streamCommand.ts
@@ -14,6 +14,7 @@ type CommonOpts = {
queueKey: string;
paths: Paths;
channelSlug?: string;
+ videoId?: string;
};
export type RunManagedCommandOpts = CommonOpts & {
@@ -39,6 +40,7 @@ function makeJob(
queueKey: string,
paths: Paths,
channelSlug?: string,
+ videoId?: string,
): { id: string; logPath: string; record: JobRecord } {
const id = newJobId();
const logPath = path.join(paths.jobsDir, `${id}.log`);
@@ -47,6 +49,7 @@ function makeJob(
kind,
queueKey,
channelSlug,
+ videoId,
status: "queued",
queuedAt: Date.now(),
logPath,
@@ -65,6 +68,7 @@ export async function runManagedCommand(
opts.queueKey,
opts.paths,
opts.channelSlug,
+ opts.videoId,
);
let controller: ReadableStreamDefaultController<string> | null = null;
@@ -153,6 +157,7 @@ export async function runManagedFunction(
opts.queueKey,
opts.paths,
opts.channelSlug,
+ opts.videoId,
);
let controller: ReadableStreamDefaultController<string> | null = null;
diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts
@@ -1,10 +1,15 @@
+import { PLATFORM_VALUES, type Platform } from "./platform";
+
export type ChannelHandling = "youtube" | "transcribe";
+export type AudioFormat = "m4a" | "mp3" | "opus";
+
export type ChannelConfig = {
handling: ChannelHandling;
+ platform?: Platform;
name?: string;
url?: string;
- audioFormat?: "m4a" | "mp3" | "opus";
+ audioFormat?: AudioFormat;
cookiesFromBrowser?: string;
ytdlpExtraArgs?: string[];
lastSyncedAt?: string;
@@ -16,7 +21,7 @@ export const HANDLING_VALUES: ReadonlyArray<ChannelHandling> = [
"transcribe",
];
-export const AUDIO_FORMAT_VALUES: ReadonlyArray<NonNullable<ChannelConfig["audioFormat"]>> = [
+export const AUDIO_FORMAT_VALUES: ReadonlyArray<AudioFormat> = [
"m4a",
"mp3",
"opus",
@@ -27,6 +32,12 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null {
const r = raw as Record<string, unknown>;
if (r.handling !== "youtube" && r.handling !== "transcribe") return null;
const config: ChannelConfig = { handling: r.handling };
+ if (
+ typeof r.platform === "string" &&
+ PLATFORM_VALUES.includes(r.platform as Platform)
+ ) {
+ config.platform = r.platform as Platform;
+ }
if (typeof r.name === "string") config.name = r.name;
if (typeof r.url === "string") config.url = r.url;
if (
diff --git a/common/lib/paths.ts b/common/lib/paths.ts
@@ -16,6 +16,7 @@ export type Paths = {
ytdlpBin: string;
whisperBin: string;
whisperModel: string;
+ ffmpegBin: string;
parallelTranscribeLimit: number;
};
@@ -52,6 +53,7 @@ export function getPaths(): Paths {
"models",
"ggml-base.en.bin",
),
+ ffmpegBin: process.env.FFMPEG_BIN ?? "ffmpeg",
parallelTranscribeLimit: process.env.PARALLEL_TRANSCRIBE_LIMIT
? Number(process.env.PARALLEL_TRANSCRIBE_LIMIT)
: 4,
diff --git a/common/lib/platform.ts b/common/lib/platform.ts
@@ -0,0 +1,28 @@
+export type Platform = "youtube" | "rumble" | "odysee";
+
+export const PLATFORM_VALUES: ReadonlyArray<Platform> = [
+ "youtube",
+ "rumble",
+ "odysee",
+];
+
+export function detectPlatform(
+ url: string | undefined | null,
+): Platform | null {
+ if (!url) return null;
+ try {
+ const host = new URL(url).hostname.toLowerCase();
+ if (host.endsWith("youtube.com") || host === "youtu.be") return "youtube";
+ if (host.endsWith("rumble.com")) return "rumble";
+ if (host.endsWith("odysee.com")) return "odysee";
+ } catch {
+ /* fall through */
+ }
+ return null;
+}
+
+export function platformQueueKey(
+ platform: Platform | null | undefined,
+): string {
+ return `platform:${platform ?? "unknown"}`;
+}
diff --git a/common/lib/transcripts.ts b/common/lib/transcripts.ts
@@ -3,7 +3,8 @@ import type { Cue } from "./vtt";
import { readFile } from "fs-extra";
import { getPaths } from "./paths";
-export type Platform = "youtube" | "rumble" | "odysee";
+export type { Platform } from "./platform";
+import type { Platform } from "./platform";
export type TranscriptSummary = {
slug: string;
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -1,16 +1,28 @@
import path from "node:path";
-import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
+import {
+ mkdir,
+ readdir,
+ readFile,
+ rename,
+ rm,
+ stat,
+ writeFile,
+} from "node:fs/promises";
import { execa } from "execa";
import { readArchive } from "../lib/archive";
import {
parseChannelConfig,
+ type AudioFormat,
type ChannelConfig,
+ type ChannelHandling,
} from "../lib/channelConfig";
import type { Paths } from "../lib/paths";
export type YtdlpMode =
| "store-playlist"
| "download-from-playlist"
+ | "download-missing"
+ | "download-one-audio"
| "sync";
export type RunYtdlpOpts = {
@@ -20,6 +32,19 @@ export type RunYtdlpOpts = {
paths: Paths;
onLog: (s: string) => void;
signal: AbortSignal;
+ // download-missing only: drop --download-archive so videos already listed
+ // in the archive still get re-fetched (recovery from a wrong/stale archive).
+ ignoreArchive?: boolean;
+ // download-from-playlist / download-missing: when false, omit
+ // --abort-on-error so yt-dlp continues past per-video failures. Defaults
+ // to true to preserve historical behavior.
+ abortOnError?: boolean;
+ // download-one-audio only: full webpage URL of the single video to fetch.
+ singleVideoUrl?: string;
+ // download-one-audio only: override channelConfig.audioFormat for this run.
+ audioFormatOverride?: AudioFormat;
+ // download-one-audio only: appended after configArgs, before the URL.
+ extraYtdlpArgs?: string[];
};
export async function runYtdlp(opts: RunYtdlpOpts): Promise<void> {
@@ -33,6 +58,12 @@ export async function runYtdlp(opts: RunYtdlpOpts): Promise<void> {
case "download-from-playlist":
await downloadFromPlaylist(opts);
return;
+ case "download-missing":
+ await downloadMissing(opts);
+ return;
+ case "download-one-audio":
+ await downloadOneAudio(opts);
+ return;
case "sync":
await sync(opts);
return;
@@ -54,14 +85,20 @@ function configArgs(config: ChannelConfig): string[] {
function handlingArgs(config: ChannelConfig): string[] {
if (config.handling === "youtube") {
- return ["--write-auto-subs", "--write-info-json", "--skip-download", "-t", "sleep"];
+ return [
+ "--write-auto-subs",
+ "--write-info-json",
+ "--skip-download",
+ "-t",
+ "sleep",
+ ];
}
- // transcribe: download audio only; whisper runs as a follow-up job (Phase 7).
- const fmt = config.audioFormat ?? "m4a";
+ // transcribe: download audio only or otherwise the smallest combined format; whisper runs as a follow-up job (Phase 7).
+ const fmt = config.audioFormat ?? "mp3";
return [
"--write-info-json",
"-f",
- "bestaudio",
+ "bestaudio/worst",
"-x",
"--audio-format",
fmt,
@@ -124,6 +161,7 @@ async function storePlaylist(opts: RunYtdlpOpts): Promise<void> {
async function downloadFromPlaylist(opts: RunYtdlpOpts): Promise<void> {
const root = channelRoot(opts);
+ const dataDir = path.join(root, "data");
const playlistPath = path.join(root, "playlist");
const archivePath = path.join(root, "archive");
const toFetchPath = path.join(root, "playlist.tofetch");
@@ -145,11 +183,14 @@ async function downloadFromPlaylist(opts: RunYtdlpOpts): Promise<void> {
// yt-dlp doesn't re-fetch metadata for entries we know are done. The
// --break-on-existing flag stays as defense in depth.
const archive = await readArchive(archivePath);
+ const rumbleIndex = urls.some(isRumbleUrl)
+ ? await buildRumbleSlugIndex(dataDir)
+ : null;
const tofetch: string[] = [];
let skipped = 0;
for (const url of urls) {
- const id = extractVideoId(url);
- if (id && archive.ids.has(id)) {
+ const archiveId = lookupArchiveId(url, rumbleIndex);
+ if (archiveId && archive.ids.has(archiveId)) {
skipped++;
continue;
}
@@ -176,8 +217,94 @@ async function downloadFromPlaylist(opts: RunYtdlpOpts): Promise<void> {
"--force-write-archive",
"--download-archive",
"archive",
- "--break-on-existing",
- "--abort-on-error",
+ ...(opts.abortOnError === false ? [] : ["--abort-on-error"]),
+ "-a",
+ "playlist.tofetch",
+ ...configArgs(opts.channelConfig),
+ ];
+ await runChildAndStream(opts, root, args);
+
+ await rm(toFetchPath, { force: true });
+ await touchLastFullDownload(opts);
+}
+
+async function downloadMissing(opts: RunYtdlpOpts): Promise<void> {
+ const root = channelRoot(opts);
+ const dataDir = path.join(root, "data");
+ const playlistPath = path.join(root, "playlist");
+ const toFetchPath = path.join(root, "playlist.tofetch");
+
+ let playlistText: string;
+ try {
+ playlistText = await readFile(playlistPath, "utf8");
+ } catch {
+ throw new Error(
+ `No saved playlist at ${playlistPath}. Run "Store playlist" first.`,
+ );
+ }
+ const urls = playlistText
+ .split("\n")
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ // Prefilter by destination-file existence rather than the archive: skip
+ // URLs whose expected output (transcript.en.vtt for youtube handling,
+ // audio.* for transcribe handling) is already on disk. Catches videos
+ // missing from a stale or absent archive file.
+ const rumbleIndex = urls.some(isRumbleUrl)
+ ? await buildRumbleSlugIndex(dataDir)
+ : null;
+ const tofetch: string[] = [];
+ let alreadyComplete = 0;
+ let unidentifiable = 0;
+ for (const url of urls) {
+ const slug = extractVideoId(url);
+ if (!slug) {
+ unidentifiable++;
+ tofetch.push(url);
+ continue;
+ }
+ const dirId =
+ rumbleIndex && isRumbleUrl(url) ? rumbleIndex.get(slug) : slug;
+ if (
+ dirId &&
+ (await destinationExists(dataDir, dirId, opts.channelConfig.handling))
+ ) {
+ alreadyComplete++;
+ continue;
+ }
+ tofetch.push(url);
+ }
+ opts.onLog(
+ `Prefilter: ${tofetch.length} missing destination files, ${alreadyComplete} already complete (of ${urls.length} total)${
+ unidentifiable ? `, ${unidentifiable} could not be identified` : ""
+ }.\n`,
+ );
+
+ if (tofetch.length === 0) {
+ await rm(toFetchPath, { force: true });
+ opts.onLog("Nothing to fetch.\n");
+ await touchLastFullDownload(opts);
+ return;
+ }
+
+ await writeFile(toFetchPath, tofetch.join("\n") + "\n");
+
+ if (opts.ignoreArchive) {
+ opts.onLog(
+ "Ignoring archive: --download-archive omitted, videos will be fetched even if listed in the archive file.\n",
+ );
+ }
+ const archiveArgs = opts.ignoreArchive
+ ? []
+ : ["--force-write-archive", "--download-archive", "archive"];
+ const args: string[] = [
+ "--ignore-config",
+ "--restrict-filenames",
+ ...OUTPUT_ARGS,
+ ...handlingArgs(opts.channelConfig),
+ ...archiveArgs,
+ ...(opts.abortOnError === false ? [] : ["--abort-on-error"]),
"-a",
"playlist.tofetch",
...configArgs(opts.channelConfig),
@@ -188,6 +315,54 @@ async function downloadFromPlaylist(opts: RunYtdlpOpts): Promise<void> {
await touchLastFullDownload(opts);
}
+async function downloadOneAudio(opts: RunYtdlpOpts): Promise<void> {
+ if (!opts.singleVideoUrl) {
+ throw new Error("download-one-audio requires singleVideoUrl");
+ }
+ const root = channelRoot(opts);
+ await mkdir(root, { recursive: true });
+ const fmt =
+ opts.audioFormatOverride ?? opts.channelConfig.audioFormat ?? "mp3";
+ const args: string[] = [
+ "--ignore-config",
+ "--restrict-filenames",
+ ...OUTPUT_ARGS,
+ "--write-info-json",
+ "-f",
+ "bestaudio/worst",
+ "-x",
+ "--audio-format",
+ fmt,
+ ...configArgs(opts.channelConfig),
+ ...(opts.extraYtdlpArgs ?? []),
+ "--",
+ opts.singleVideoUrl,
+ ];
+ await runChildAndStream(opts, root, args);
+}
+
+async function destinationExists(
+ dataDir: string,
+ id: string,
+ handling: ChannelHandling,
+): Promise<boolean> {
+ const dir = path.join(dataDir, id);
+ if (handling === "youtube") {
+ return fileExists(path.join(dir, "transcript.en.vtt"));
+ }
+ const entries = await readdir(dir).catch(() => [] as string[]);
+ return entries.some((e) => e.startsWith("audio."));
+}
+
+async function fileExists(p: string): Promise<boolean> {
+ try {
+ await stat(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
async function sync(opts: RunYtdlpOpts): Promise<void> {
const root = channelRoot(opts);
await mkdir(root, { recursive: true });
@@ -246,11 +421,7 @@ async function touchLastSync(opts: RunYtdlpOpts): Promise<void> {
async function touchLastFullDownload(opts: RunYtdlpOpts): Promise<void> {
const file = path.join(channelRoot(opts), "config.json");
- await updateConfigField(
- file,
- "lastFullDownloadAt",
- new Date().toISOString(),
- );
+ await updateConfigField(file, "lastFullDownloadAt", new Date().toISOString());
}
async function updateConfigField(
@@ -272,6 +443,60 @@ async function updateConfigField(
await rename(tmp, configPath);
}
+function isRumbleUrl(url: string): boolean {
+ try {
+ return new URL(url).hostname.toLowerCase().endsWith("rumble.com");
+ } catch {
+ return false;
+ }
+}
+
+// Rumble's URL slug (the part between "rumble.com/" and the first "-") is a
+// different identifier from yt-dlp's RumbleEmbed extractor `id`, which is what
+// ends up in the archive file and as the on-disk data dir name. To prefilter
+// Rumble playlist URLs against the archive / data dir, we walk every existing
+// data/{id}/metadata.info.json once, read its `webpage_url`, and build a
+// slug → id map.
+async function buildRumbleSlugIndex(
+ dataDir: string,
+): Promise<Map<string, string>> {
+ const index = new Map<string, string>();
+ const dirs = await readdir(dataDir).catch(() => [] as string[]);
+ for (const id of dirs) {
+ let raw: string;
+ try {
+ raw = await readFile(
+ path.join(dataDir, id, "metadata.info.json"),
+ "utf8",
+ );
+ } catch {
+ continue;
+ }
+ let url: unknown;
+ try {
+ url = JSON.parse(raw)?.webpage_url;
+ } catch {
+ continue;
+ }
+ if (typeof url !== "string") continue;
+ const slug = extractVideoId(url);
+ if (slug) index.set(slug, id);
+ }
+ return index;
+}
+
+function lookupArchiveId(
+ url: string,
+ rumbleIndex: Map<string, string> | null,
+): string | null {
+ const slug = extractVideoId(url);
+ if (!slug) return null;
+ if (rumbleIndex && isRumbleUrl(url)) {
+ return rumbleIndex.get(slug) ?? null;
+ }
+ return slug;
+}
+
export function extractVideoId(url: string): string | null {
try {
const u = new URL(url);
diff --git a/editor/app/_components/QueueControl.tsx b/editor/app/_components/QueueControl.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useState } from "react";
+
+const CUSTOM_OPTION = "__custom__";
+
+type Props = {
+ value: string;
+ onChange: (next: string) => void;
+ defaultQueueKey: string;
+ existingQueues: string[];
+ actionLabel: string;
+};
+
+export function QueueControl({
+ value,
+ onChange,
+ defaultQueueKey,
+ existingQueues,
+ actionLabel,
+}: Props) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState(value);
+
+ if (editing) {
+ return (
+ <div className="flex items-center gap-1 text-xs">
+ <input
+ type="text"
+ value={draft}
+ onChange={(e) => setDraft(e.target.value)}
+ aria-label={`custom queue for ${actionLabel}`}
+ autoFocus
+ placeholder="leave empty for immediate"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onChange(draft.trim());
+ setEditing(false);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ setEditing(false);
+ setDraft(value);
+ }
+ }}
+ className="w-44 font-mono px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900"
+ />
+ <button
+ type="button"
+ aria-label={`save custom queue for ${actionLabel}`}
+ onClick={() => {
+ onChange(draft.trim());
+ setEditing(false);
+ }}
+ className="px-2 py-1 rounded bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900"
+ >
+ Set
+ </button>
+ <button
+ type="button"
+ onClick={() => {
+ setEditing(false);
+ setDraft(value);
+ }}
+ className="text-zinc-500 underline"
+ >
+ Cancel
+ </button>
+ </div>
+ );
+ }
+
+ // Compose options: blank (immediate) first, then default queue, current
+ // value, and any active queues, deduplicated.
+ const named = Array.from(
+ new Set(
+ [defaultQueueKey, value, ...existingQueues].filter((s) => s.length > 0),
+ ),
+ ).sort();
+
+ return (
+ <label className="flex items-center gap-1 text-xs text-zinc-500">
+ <span className="sr-only">queue for {actionLabel}</span>
+ <select
+ value={value}
+ aria-label={`queue for ${actionLabel}`}
+ onChange={(e) => {
+ const next = e.target.value;
+ if (next === CUSTOM_OPTION) {
+ setDraft(value);
+ setEditing(true);
+ return;
+ }
+ onChange(next);
+ }}
+ className="font-mono px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100"
+ >
+ <option value="">(immediate)</option>
+ {named.map((opt) => (
+ <option key={opt} value={opt}>
+ {opt}
+ </option>
+ ))}
+ <option value={CUSTOM_OPTION}>Custom…</option>
+ </select>
+ </label>
+ );
+}
diff --git a/editor/app/_components/QueuePicker.tsx b/editor/app/_components/QueuePicker.tsx
@@ -1,93 +0,0 @@
-"use client";
-
-import { useState } from "react";
-
-type Props = {
- value: string;
- onChange: (next: string) => void;
- defaultQueueKey: string;
- existingQueues: string[];
-};
-
-const NEW_QUEUE_OPTION = "__new__";
-
-export function QueuePicker({
- value,
- onChange,
- defaultQueueKey,
- existingQueues,
-}: Props) {
- const [creating, setCreating] = useState(false);
- const [draft, setDraft] = useState("");
-
- const options = Array.from(
- new Set([defaultQueueKey, ...existingQueues, value].filter(Boolean)),
- ).sort();
-
- if (creating) {
- return (
- <div className="flex items-center gap-2">
- <label className="flex items-center gap-2 text-xs uppercase tracking-wide text-zinc-500">
- New queue name
- <input
- type="text"
- value={draft}
- onChange={(e) => setDraft(e.target.value)}
- autoFocus
- placeholder="new-queue-name"
- className="text-sm font-mono px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 normal-case tracking-normal text-zinc-900 dark:text-zinc-100"
- />
- </label>
- <button
- type="button"
- onClick={() => {
- const trimmed = draft.trim();
- if (trimmed) {
- onChange(trimmed);
- setCreating(false);
- setDraft("");
- }
- }}
- className="text-xs px-2 py-1 rounded bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900"
- >
- Save queue
- </button>
- <button
- type="button"
- onClick={() => {
- setCreating(false);
- setDraft("");
- }}
- className="text-xs text-zinc-500 underline"
- >
- Discard new queue
- </button>
- </div>
- );
- }
-
- return (
- <label className="flex items-center gap-2 text-xs uppercase tracking-wide text-zinc-500">
- Queue
- <select
- value={value}
- onChange={(e) => {
- const next = e.target.value;
- if (next === NEW_QUEUE_OPTION) {
- setCreating(true);
- return;
- }
- onChange(next);
- }}
- className="text-sm font-mono px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 normal-case tracking-normal text-zinc-900 dark:text-zinc-100"
- >
- {options.map((opt) => (
- <option key={opt} value={opt}>
- {opt}
- </option>
- ))}
- <option value={NEW_QUEUE_OPTION}>+ new queue…</option>
- </select>
- </label>
- );
-}
diff --git a/editor/app/build/_components/BuildButtons.tsx b/editor/app/build/_components/BuildButtons.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import { QueuePicker } from "../../_components/QueuePicker";
+import { QueueControl } from "../../_components/QueueControl";
import { cancelJobAction } from "../../jobs/actions";
import { buildExportAction, buildIndexAction } from "../buildAction";
@@ -10,18 +10,14 @@ type Props = {
existingQueues: string[];
};
+const DEFAULT_BUILD_QUEUE = "build";
+
export function BuildButtons({ existingQueues }: Props) {
- const defaultQueueKey = "build";
- const [queueKey, setQueueKey] = useState(defaultQueueKey);
+ const [indexQueue, setIndexQueue] = useState(DEFAULT_BUILD_QUEUE);
+ const [exportQueue, setExportQueue] = useState(DEFAULT_BUILD_QUEUE);
return (
<div className="flex flex-col gap-8">
- <QueuePicker
- value={queueKey}
- onChange={setQueueKey}
- defaultQueueKey={defaultQueueKey}
- existingQueues={existingQueues}
- />
<section className="flex flex-col gap-3">
<div>
<h2 className="text-lg font-semibold">Build index</h2>
@@ -32,10 +28,19 @@ export function BuildButtons({ existingQueues }: Props) {
</p>
</div>
<StreamActionLog
- trigger={() => buildIndexAction(queueKey)}
+ trigger={() => buildIndexAction(indexQueue)}
cancelAction={cancelJobAction}
buttonLabel="Build index"
runningLabel="Building index…"
+ extraControls={
+ <QueueControl
+ value={indexQueue}
+ onChange={setIndexQueue}
+ defaultQueueKey={DEFAULT_BUILD_QUEUE}
+ existingQueues={existingQueues}
+ actionLabel="Build index"
+ />
+ }
/>
</section>
@@ -49,10 +54,19 @@ export function BuildButtons({ existingQueues }: Props) {
</p>
</div>
<StreamActionLog
- trigger={() => buildExportAction(queueKey)}
+ trigger={() => buildExportAction(exportQueue)}
cancelAction={cancelJobAction}
buttonLabel="Build static export"
runningLabel="Building static export…"
+ extraControls={
+ <QueueControl
+ value={exportQueue}
+ onChange={setExportQueue}
+ defaultQueueKey={DEFAULT_BUILD_QUEUE}
+ existingQueues={existingQueues}
+ actionLabel="Build static export"
+ />
+ }
/>
</section>
</div>
diff --git a/editor/app/build/buildAction.ts b/editor/app/build/buildAction.ts
@@ -17,7 +17,7 @@ export async function buildIndexAction(
const paths = getPaths();
const result = await runManagedFunction({
kind: "build-index",
- queueKey: queueKey?.trim() || DEFAULT_BUILD_QUEUE,
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
paths,
fn: async (onLog) => {
await buildIndex({ paths, onLog });
@@ -33,7 +33,7 @@ export async function buildExportAction(
const paths = getPaths();
return runManagedCommand({
kind: "build-export",
- queueKey: queueKey?.trim() || DEFAULT_BUILD_QUEUE,
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
paths,
cwd: paths.exportDir,
command: "pnpm",
diff --git a/editor/app/build/page.tsx b/editor/app/build/page.tsx
@@ -1,8 +1,11 @@
+import type { Metadata } from "next";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { BuildButtons } from "./_components/BuildButtons";
export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Build" };
+
export default function BuildPage() {
const existingQueues = getRegistry().activeQueueNames();
return (
diff --git a/editor/app/channels/[slug]/_components/PipelinePanel.tsx b/editor/app/channels/[slug]/_components/PipelinePanel.tsx
@@ -2,10 +2,11 @@
import { useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import { QueuePicker } from "../../../_components/QueuePicker";
+import { QueueControl } from "../../../_components/QueueControl";
import { cancelJobAction } from "../../../jobs/actions";
import {
downloadAction,
+ downloadMissingAction,
storePlaylistAction,
syncAction,
} from "../pipelineActions";
@@ -13,12 +14,23 @@ import {
type Props = {
slug: string;
hasUrl: boolean;
+ defaultQueueKey: string;
existingQueues: string[];
};
-export function PipelinePanel({ slug, hasUrl, existingQueues }: Props) {
- const defaultQueueKey = `channel:${slug}`;
- const [queueKey, setQueueKey] = useState(defaultQueueKey);
+export function PipelinePanel({
+ slug,
+ hasUrl,
+ defaultQueueKey,
+ existingQueues,
+}: Props) {
+ const [storeQueue, setStoreQueue] = useState(defaultQueueKey);
+ const [downloadQueue, setDownloadQueue] = useState(defaultQueueKey);
+ const [missingQueue, setMissingQueue] = useState(defaultQueueKey);
+ const [syncQueue, setSyncQueue] = useState(defaultQueueKey);
+ const [ignoreArchive, setIgnoreArchive] = useState(false);
+ const [downloadAbortOnError, setDownloadAbortOnError] = useState(true);
+ const [missingAbortOnError, setMissingAbortOnError] = useState(true);
if (!hasUrl) {
return (
@@ -30,22 +42,25 @@ export function PipelinePanel({ slug, hasUrl, existingQueues }: Props) {
}
return (
<div className="flex flex-col gap-6">
- <QueuePicker
- value={queueKey}
- onChange={setQueueKey}
- defaultQueueKey={defaultQueueKey}
- existingQueues={existingQueues}
- />
<div className="flex flex-col gap-2">
<Heading
title="Store playlist"
desc="Fetch the channel's full URL list (--flat-playlist --skip-download --print url) and save it for later runs."
/>
<StreamActionLog
- trigger={() => storePlaylistAction(slug, queueKey)}
+ trigger={() => storePlaylistAction(slug, storeQueue)}
cancelAction={cancelJobAction}
buttonLabel="Store playlist"
runningLabel="Storing playlist…"
+ extraControls={
+ <QueueControl
+ value={storeQueue}
+ onChange={setStoreQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Store playlist"
+ />
+ }
/>
</div>
<div className="flex flex-col gap-2">
@@ -53,11 +68,85 @@ export function PipelinePanel({ slug, hasUrl, existingQueues }: Props) {
title="Download from playlist"
desc="Read the saved playlist, prefilter against the archive, then run yt-dlp on whatever's left. Use for first-time imports and resumes."
/>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={downloadAbortOnError}
+ onChange={(e) => setDownloadAbortOnError(e.target.checked)}
+ aria-label="abort on error for Download from playlist"
+ />
+ Abort on error
+ <span className="text-xs text-zinc-500">
+ (stop the run on the first per-video failure)
+ </span>
+ </label>
<StreamActionLog
- trigger={() => downloadAction(slug, queueKey)}
+ trigger={() =>
+ downloadAction(slug, downloadQueue, downloadAbortOnError)
+ }
cancelAction={cancelJobAction}
buttonLabel="Download from playlist"
runningLabel="Downloading…"
+ extraControls={
+ <QueueControl
+ value={downloadQueue}
+ onChange={setDownloadQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Download from playlist"
+ />
+ }
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Heading
+ title="Download videos"
+ desc="Read the saved playlist, then re-download any video whose destination file (transcript.en.vtt or audio.*) is missing on disk. Useful when the archive is out of sync with the data directory."
+ />
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={ignoreArchive}
+ onChange={(e) => setIgnoreArchive(e.target.checked)}
+ />
+ Ignore archive
+ <span className="text-xs text-zinc-500">
+ (re-fetch even if listed in the archive file)
+ </span>
+ </label>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={missingAbortOnError}
+ onChange={(e) => setMissingAbortOnError(e.target.checked)}
+ aria-label="abort on error for Download videos"
+ />
+ Abort on error
+ <span className="text-xs text-zinc-500">
+ (stop the run on the first per-video failure)
+ </span>
+ </label>
+ <StreamActionLog
+ trigger={() =>
+ downloadMissingAction(
+ slug,
+ missingQueue,
+ ignoreArchive,
+ missingAbortOnError,
+ )
+ }
+ cancelAction={cancelJobAction}
+ buttonLabel="Download videos"
+ runningLabel="Downloading missing…"
+ extraControls={
+ <QueueControl
+ value={missingQueue}
+ onChange={setMissingQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Download videos"
+ />
+ }
/>
</div>
<div className="flex flex-col gap-2">
@@ -66,10 +155,19 @@ export function PipelinePanel({ slug, hasUrl, existingQueues }: Props) {
desc="Quick incremental fetch using --lazy-playlist + --break-on-existing. Run regularly to grab the newest videos."
/>
<StreamActionLog
- trigger={() => syncAction(slug, queueKey)}
+ trigger={() => syncAction(slug, syncQueue)}
cancelAction={cancelJobAction}
buttonLabel="Sync"
runningLabel="Syncing…"
+ extraControls={
+ <QueueControl
+ value={syncQueue}
+ onChange={setSyncQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Sync"
+ />
+ }
/>
</div>
</div>
diff --git a/editor/app/channels/[slug]/_components/WhisperPanel.tsx b/editor/app/channels/[slug]/_components/WhisperPanel.tsx
@@ -1,11 +1,18 @@
"use client";
import { useState } from "react";
+import Link from "next/link";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import { QueuePicker } from "../../../_components/QueuePicker";
+import {
+ AUDIO_FORMAT_VALUES,
+ type AudioFormat,
+} from "yt-dlp-transcript-common/lib/channelConfig";
+import { QueueControl } from "../../../_components/QueueControl";
import { cancelJobAction } from "../../../jobs/actions";
import {
+ cleanAudioAction,
retryFailuresAction,
+ transcodeFailuresAction,
transcribeMissingAction,
verifyAction,
type VerifyResult,
@@ -14,41 +21,122 @@ import {
type Props = {
slug: string;
existingQueues: string[];
+ failedVideoIds: string[];
};
-export function WhisperPanel({ slug, existingQueues }: Props) {
+export function WhisperPanel({ slug, existingQueues, failedVideoIds }: Props) {
const defaultQueueKey = `channel:${slug}`;
- const [queueKey, setQueueKey] = useState(defaultQueueKey);
+ const [missingQueue, setMissingQueue] = useState(defaultQueueKey);
+ const [retryQueue, setRetryQueue] = useState(defaultQueueKey);
+ const [transcodeFormat, setTranscodeFormat] = useState<AudioFormat>("mp3");
+ const [transcodeQueue, setTranscodeQueue] = useState(defaultQueueKey);
+ const [cleanQueue, setCleanQueue] = useState(defaultQueueKey);
return (
<div className="flex flex-col gap-6">
- <QueuePicker
- value={queueKey}
- onChange={setQueueKey}
- defaultQueueKey={defaultQueueKey}
- existingQueues={existingQueues}
- />
<div className="flex flex-col gap-2">
<Heading
title="Transcribe missing"
desc="Run whisper-cli over every video that has audio but no transcript.json. Failures append to channels/<slug>/failed-transcriptions."
/>
<StreamActionLog
- trigger={() => transcribeMissingAction(slug, queueKey)}
+ trigger={() => transcribeMissingAction(slug, missingQueue)}
cancelAction={cancelJobAction}
buttonLabel="Transcribe missing"
runningLabel="Transcribing…"
+ extraControls={
+ <QueueControl
+ value={missingQueue}
+ onChange={setMissingQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Transcribe missing"
+ />
+ }
/>
</div>
<div className="flex flex-col gap-2">
<Heading
title="Retry failures"
- desc="Re-run whisper for any video listed in failed-transcriptions."
+ desc="Re-run whisper for any video listed below; successes will be removed from the list."
/>
<StreamActionLog
- trigger={() => retryFailuresAction(slug, queueKey)}
+ trigger={() => retryFailuresAction(slug, retryQueue)}
cancelAction={cancelJobAction}
buttonLabel="Retry failures"
runningLabel="Retrying…"
+ extraControls={
+ <QueueControl
+ value={retryQueue}
+ onChange={setRetryQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Retry failures"
+ />
+ }
+ />
+ <FailedList slug={slug} ids={failedVideoIds} />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Heading
+ title="Transcode failures"
+ desc="Re-encode the audio for every video in the failure list to a different format. Useful when a codec is incompatible with whisper. Existing audio files are kept; the new file is written as audio.<format>."
+ />
+ <label className="flex items-center gap-2 text-sm">
+ Target format
+ <select
+ value={transcodeFormat}
+ onChange={(e) =>
+ setTranscodeFormat(e.target.value as AudioFormat)
+ }
+ aria-label="transcode failures target format"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ {AUDIO_FORMAT_VALUES.map((f) => (
+ <option key={f} value={f}>
+ {f}
+ </option>
+ ))}
+ </select>
+ </label>
+ <StreamActionLog
+ trigger={() =>
+ transcodeFailuresAction(slug, transcodeFormat, transcodeQueue)
+ }
+ cancelAction={cancelJobAction}
+ buttonLabel={`Transcode failures → ${transcodeFormat}`}
+ runningLabel="Transcoding…"
+ label="Transcode failures"
+ extraControls={
+ <QueueControl
+ value={transcodeQueue}
+ onChange={setTranscodeQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Transcode failures"
+ />
+ }
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Heading
+ title="Clean audio for transcribed videos"
+ desc="Delete audio.* files in any video directory that has a transcript.json. Useful for reclaiming disk space once transcription is complete."
+ />
+ <StreamActionLog
+ trigger={() => cleanAudioAction(slug, cleanQueue)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Clean audio"
+ runningLabel="Cleaning…"
+ label="Clean audio"
+ extraControls={
+ <QueueControl
+ value={cleanQueue}
+ onChange={setCleanQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Clean audio"
+ />
+ }
/>
</div>
<div className="flex flex-col gap-2">
@@ -127,6 +215,36 @@ function Bucket({ label, items }: { label: string; items: string[] }) {
);
}
+function FailedList({ slug, ids }: { slug: string; ids: string[] }) {
+ if (ids.length === 0) {
+ return (
+ <p className="text-sm text-zinc-500" aria-label="failed transcriptions empty">
+ No failed transcriptions.
+ </p>
+ );
+ }
+ return (
+ <div
+ aria-label="failed transcriptions list"
+ className="rounded border border-zinc-200 dark:border-zinc-800 max-h-72 overflow-auto"
+ >
+ <ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
+ {ids.map((id) => (
+ <li key={id} className="px-3 py-1.5">
+ <Link
+ href={`/channels/${slug}/videos/${encodeURIComponent(id)}`}
+ aria-label={`failed transcription ${id}`}
+ className="font-mono text-xs underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {id}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ </div>
+ );
+}
+
function Heading({ title, desc }: { title: string; desc: string }) {
return (
<div>
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -1,7 +1,14 @@
+import path from "node:path";
+import { readFile } from "node:fs/promises";
+import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ detectPlatform,
+ platformQueueKey,
+} from "yt-dlp-transcript-common/lib/platform";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { ChannelFormClient } from "../_components/ChannelFormClient";
import { DeleteChannelForm } from "../_components/DeleteChannelForm";
@@ -9,12 +16,34 @@ import { PipelinePanel } from "./_components/PipelinePanel";
import { WhisperPanel } from "./_components/WhisperPanel";
import {
deleteChannelAction,
+ gotoVideoAction,
updateChannelAction,
type ActionResult,
} from "../actions";
+async function loadFailedVideoIds(slug: string): Promise<string[]> {
+ const file = path.join(getPaths().channelsDir, slug, "failed-transcriptions");
+ try {
+ const raw = await readFile(file, "utf8");
+ return raw.split("\n").filter(Boolean);
+ } catch {
+ return [];
+ }
+}
+
export const dynamic = "force-dynamic";
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}): Promise<Metadata> {
+ const { slug } = await params;
+ const config = await readChannelConfig(getPaths(), slug);
+ const subject = config?.name ?? slug;
+ return { title: `${subject} — Channel` };
+}
+
export default async function ChannelDetailPage({
params,
}: {
@@ -27,6 +56,10 @@ export default async function ChannelDetailPage({
const update = updateChannelAction.bind(null, slug);
const del = deleteChannelAction.bind(null, slug);
const existingQueues = getRegistry().activeQueueNames();
+ const defaultQueueKey = platformQueueKey(
+ config.platform ?? detectPlatform(config.url),
+ );
+ const failedVideoIds = await loadFailedVideoIds(slug);
return (
<div className="flex flex-col gap-6">
@@ -58,6 +91,7 @@ export default async function ChannelDetailPage({
<PipelinePanel
slug={slug}
hasUrl={!!config.url}
+ defaultQueueKey={defaultQueueKey}
existingQueues={existingQueues}
/>
</section>
@@ -65,11 +99,42 @@ export default async function ChannelDetailPage({
{config.handling === "transcribe" && (
<section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
<h2 className="text-lg font-semibold">Transcription</h2>
- <WhisperPanel slug={slug} existingQueues={existingQueues} />
+ <WhisperPanel
+ slug={slug}
+ existingQueues={existingQueues}
+ failedVideoIds={failedVideoIds}
+ />
</section>
)}
<section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
+ <h2 className="text-lg font-semibold">Inspect a video</h2>
+ <form
+ action={gotoVideoAction.bind(null, slug)}
+ className="flex flex-wrap items-center gap-2 text-sm"
+ aria-label="Go to video"
+ >
+ <label htmlFor="goto-video-id" className="text-zinc-600 dark:text-zinc-400">
+ Video ID
+ </label>
+ <input
+ id="goto-video-id"
+ name="id"
+ type="text"
+ placeholder="dQw4w9WgXcQ"
+ required
+ className="font-mono rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1"
+ />
+ <button
+ type="submit"
+ className="px-3 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 font-medium hover:opacity-90"
+ >
+ Open
+ </button>
+ </form>
+ </section>
+
+ <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400">
Danger zone
</h2>
diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts
@@ -1,7 +1,12 @@
"use server";
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 {
+ detectPlatform,
+ platformQueueKey,
+} from "yt-dlp-transcript-common/lib/platform";
import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
import { runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdlp";
import {
@@ -9,15 +14,16 @@ import {
type StreamActionResult,
} from "yt-dlp-transcript-common/jobs/streamCommand";
-function defaultQueueKey(slug: string): string {
- return `channel:${slug}`;
+function defaultQueueKey(config: ChannelConfig): string {
+ return platformQueueKey(config.platform ?? detectPlatform(config.url));
}
async function runPipelineAction(
slug: string,
- mode: "store-playlist" | "download-from-playlist" | "sync",
+ mode: "store-playlist" | "download-from-playlist" | "sync" | "download-missing",
kind: string,
queueKey?: string,
+ options?: { ignoreArchive?: boolean; abortOnError?: boolean },
): Promise<StreamActionResult> {
const paths = getPaths();
const channelConfig = await readChannelConfig(paths, slug);
@@ -29,7 +35,8 @@ async function runPipelineAction(
}
return runManagedFunction({
kind,
- queueKey: queueKey?.trim() || defaultQueueKey(slug),
+ queueKey:
+ queueKey === undefined ? defaultQueueKey(channelConfig) : queueKey.trim(),
paths,
channelSlug: slug,
fn: async (onLog, signal) => {
@@ -40,6 +47,8 @@ async function runPipelineAction(
paths,
onLog,
signal,
+ ignoreArchive: options?.ignoreArchive,
+ abortOnError: options?.abortOnError,
});
revalidatePath(`/channels/${slug}`);
revalidatePath("/channels");
@@ -57,12 +66,29 @@ export async function storePlaylistAction(
export async function downloadAction(
slug: string,
queueKey?: string,
+ abortOnError?: boolean,
): Promise<StreamActionResult> {
return runPipelineAction(
slug,
"download-from-playlist",
"download-from-playlist",
queueKey,
+ { abortOnError },
+ );
+}
+
+export async function downloadMissingAction(
+ slug: string,
+ queueKey?: string,
+ ignoreArchive?: boolean,
+ abortOnError?: boolean,
+): Promise<StreamActionResult> {
+ return runPipelineAction(
+ slug,
+ "download-missing",
+ "download-missing",
+ queueKey,
+ { ignoreArchive, abortOnError },
);
}
diff --git a/editor/app/channels/[slug]/videos/[id]/_components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/_components/VideoPanel.tsx
@@ -0,0 +1,472 @@
+"use client";
+
+import { useActionState, useState, useTransition } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import {
+ AUDIO_FORMAT_VALUES,
+ type AudioFormat,
+ type ChannelHandling,
+} from "yt-dlp-transcript-common/lib/channelConfig";
+import { QueueControl } from "../../../../../_components/QueueControl";
+import { cancelJobAction } from "../../../../../jobs/actions";
+import {
+ deleteVideoDirAction,
+ deleteVideoFileAction,
+ markVideoUntranscribableAction,
+ redownloadVideoAction,
+ transcodeAudioAction,
+ transcribeOneAction,
+ type DeleteDirActionResult,
+} from "../videoActions";
+
+export type VideoFile = {
+ name: string;
+ size: number;
+ mtime: number;
+};
+
+type Props = {
+ slug: string;
+ videoId: string;
+ files: VideoFile[];
+ handling: ChannelHandling;
+ audioFormat: AudioFormat;
+ defaultQueueKey: string;
+ existingQueues: string[];
+};
+
+function isAudioFile(name: string): boolean {
+ return name.startsWith("audio.");
+}
+
+function audioExt(name: string): string {
+ const dot = name.lastIndexOf(".");
+ return dot >= 0 ? name.slice(dot + 1) : "";
+}
+
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+}
+
+function parseExtraArgs(raw: string): string[] {
+ return raw
+ .split(/\s+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+}
+
+export function VideoPanel({
+ slug,
+ videoId,
+ files,
+ handling,
+ audioFormat,
+ defaultQueueKey,
+ existingQueues,
+}: Props) {
+ const audioFiles = files.filter((f) => isAudioFile(f.name));
+ const hasTranscriptJson = files.some((f) => f.name === "transcript.json");
+ const hasYtVtt = files.some((f) => f.name === "transcript.en.vtt");
+ const hasTranscript = hasTranscriptJson || hasYtVtt;
+ const noAudio = audioFiles.length === 0;
+
+ return (
+ <div className="flex flex-col gap-6">
+ <RedownloadSection
+ slug={slug}
+ videoId={videoId}
+ handling={handling}
+ defaultFormat={audioFormat}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ noAudio={noAudio}
+ />
+
+ {handling === "transcribe" && !hasTranscript && (
+ <MarkUntranscribableSection slug={slug} videoId={videoId} />
+ )}
+
+ <section className="flex flex-col gap-3">
+ <h2 className="text-lg font-semibold">Files</h2>
+ {files.length === 0 ? (
+ <p className="text-sm text-zinc-500">Directory is empty.</p>
+ ) : (
+ <ul
+ aria-label={`files for ${videoId}`}
+ className="flex flex-col gap-3"
+ >
+ {files.map((f) => (
+ <li
+ key={f.name}
+ className="border border-zinc-200 dark:border-zinc-800 rounded-md p-3 flex flex-col gap-2"
+ >
+ <div className="flex items-baseline justify-between gap-3 flex-wrap">
+ <span className="font-mono text-sm" aria-label={`file name ${f.name}`}>
+ {f.name}
+ </span>
+ <span className="text-xs text-zinc-500">
+ {formatSize(f.size)} · {new Date(f.mtime).toLocaleString()}
+ </span>
+ </div>
+ {isAudioFile(f.name) && (
+ <AudioFileActions
+ slug={slug}
+ videoId={videoId}
+ file={f}
+ existingQueues={existingQueues}
+ hasTranscript={hasTranscript}
+ />
+ )}
+ <DeleteFileButton
+ slug={slug}
+ videoId={videoId}
+ filename={f.name}
+ />
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+
+ <DeleteVideoDirSection slug={slug} videoId={videoId} />
+ </div>
+ );
+}
+
+function RedownloadSection({
+ slug,
+ videoId,
+ handling,
+ defaultFormat,
+ defaultQueueKey,
+ existingQueues,
+ noAudio,
+}: {
+ slug: string;
+ videoId: string;
+ handling: ChannelHandling;
+ defaultFormat: AudioFormat;
+ defaultQueueKey: string;
+ existingQueues: string[];
+ noAudio: boolean;
+}) {
+ const [format, setFormat] = useState<AudioFormat>(defaultFormat);
+ const [extraArgsRaw, setExtraArgsRaw] = useState("");
+ const [queueKey, setQueueKey] = useState(defaultQueueKey);
+ const desc = noAudio
+ ? handling === "youtube"
+ ? "YouTube channels normally only fetch auto-subs. Use this when the video has no auto-subs and you want to transcribe it manually."
+ : "Fetch the audio for this video using yt-dlp."
+ : "Re-fetch the audio for this video. The existing audio file is kept on disk; yt-dlp may overwrite it with the new one if names collide.";
+ const actionLabel = `${noAudio ? "Download" : "Redownload"} audio for ${videoId}`;
+ return (
+ <section className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded-md p-4">
+ <Heading
+ title={noAudio ? "Download audio" : "Redownload audio"}
+ desc={desc}
+ />
+ <div className="flex flex-wrap items-center gap-3">
+ <label className="flex items-center gap-2 text-sm">
+ Format
+ <select
+ value={format}
+ onChange={(e) => setFormat(e.target.value as AudioFormat)}
+ aria-label="redownload audio format"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ {AUDIO_FORMAT_VALUES.map((f) => (
+ <option key={f} value={f}>
+ {f}
+ </option>
+ ))}
+ </select>
+ </label>
+ <label className="flex items-center gap-2 text-sm flex-1 min-w-[16rem]">
+ Extra yt-dlp args
+ <input
+ type="text"
+ value={extraArgsRaw}
+ onChange={(e) => setExtraArgsRaw(e.target.value)}
+ aria-label="redownload extra yt-dlp args"
+ placeholder="--cookies-from-browser firefox --quiet"
+ className="flex-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono"
+ />
+ </label>
+ </div>
+ <StreamActionLog
+ trigger={() =>
+ redownloadVideoAction(slug, videoId, {
+ audioFormat: format,
+ extraArgs: parseExtraArgs(extraArgsRaw),
+ queueKey,
+ })
+ }
+ cancelAction={cancelJobAction}
+ buttonLabel={`${noAudio ? "Download" : "Redownload"} audio (${format})`}
+ runningLabel={noAudio ? "Downloading…" : "Redownloading…"}
+ label={actionLabel}
+ extraControls={
+ <QueueControl
+ value={queueKey}
+ onChange={setQueueKey}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel={actionLabel}
+ />
+ }
+ />
+ </section>
+ );
+}
+
+function AudioFileActions({
+ slug,
+ videoId,
+ file,
+ existingQueues,
+ hasTranscript,
+}: {
+ slug: string;
+ videoId: string;
+ file: VideoFile;
+ existingQueues: string[];
+ hasTranscript: boolean;
+}) {
+ const ext = audioExt(file.name);
+ const transcodeTargets = AUDIO_FORMAT_VALUES.filter((f) => f !== ext);
+ const [target, setTarget] = useState<AudioFormat>(transcodeTargets[0] ?? "opus");
+ // Per-file Transcribe and Transcode default to "" — single-file jobs run
+ // immediately, in parallel with channel-wide batches.
+ const [transcribeQueue, setTranscribeQueue] = useState("");
+ const [transcodeQueue, setTranscodeQueue] = useState("");
+
+ return (
+ <div className="flex flex-col gap-3">
+ <div className="flex flex-col gap-1">
+ <Heading
+ title="Transcribe"
+ desc={
+ hasTranscript
+ ? "A transcript already exists for this video. Delete transcript.json (or transcript.en.vtt) on disk to re-run."
+ : "Run whisper-cli on this audio file. Writes transcript.json next to it."
+ }
+ />
+ {hasTranscript ? (
+ <p className="text-sm text-zinc-500">
+ Transcribe disabled — transcript already present.
+ </p>
+ ) : (
+ <StreamActionLog
+ trigger={() =>
+ transcribeOneAction(slug, videoId, file.name, transcribeQueue)
+ }
+ cancelAction={cancelJobAction}
+ buttonLabel={`Transcribe ${file.name}`}
+ runningLabel="Transcribing…"
+ label={`Transcribe ${file.name}`}
+ extraControls={
+ <QueueControl
+ value={transcribeQueue}
+ onChange={setTranscribeQueue}
+ defaultQueueKey=""
+ existingQueues={existingQueues}
+ actionLabel={`Transcribe ${file.name}`}
+ />
+ }
+ />
+ )}
+ </div>
+ {transcodeTargets.length > 0 && (
+ <div className="flex flex-col gap-1">
+ <Heading
+ title="Transcode"
+ desc="Re-encode this audio file with ffmpeg. The source file is kept; the output is named audio.<format>."
+ />
+ <label className="flex items-center gap-2 text-sm">
+ Target format
+ <select
+ value={target}
+ onChange={(e) => setTarget(e.target.value as AudioFormat)}
+ aria-label={`transcode target for ${file.name}`}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ {transcodeTargets.map((t) => (
+ <option key={t} value={t}>
+ {t}
+ </option>
+ ))}
+ </select>
+ </label>
+ <StreamActionLog
+ trigger={() =>
+ transcodeAudioAction(
+ slug,
+ videoId,
+ file.name,
+ target,
+ transcodeQueue,
+ )
+ }
+ cancelAction={cancelJobAction}
+ buttonLabel={`Transcode ${file.name} → ${target}`}
+ runningLabel="Transcoding…"
+ label={`Transcode ${file.name} to ${target}`}
+ extraControls={
+ <QueueControl
+ value={transcodeQueue}
+ onChange={setTranscodeQueue}
+ defaultQueueKey=""
+ existingQueues={existingQueues}
+ actionLabel={`Transcode ${file.name} to ${target}`}
+ />
+ }
+ />
+ </div>
+ )}
+ </div>
+ );
+}
+
+function MarkUntranscribableSection({
+ slug,
+ videoId,
+}: {
+ slug: string;
+ videoId: string;
+}) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ return (
+ <section className="flex flex-col gap-2 border border-zinc-200 dark:border-zinc-800 rounded-md p-4">
+ <Heading
+ title="Mark untranscribable"
+ desc="Writes an empty transcript.json so the pipeline stops retrying. Removes this video from the failed list. Reversible — just delete transcript.json."
+ />
+ <button
+ type="button"
+ disabled={pending}
+ aria-label={`mark video ${videoId} untranscribable`}
+ onClick={() => {
+ setError(null);
+ startTransition(async () => {
+ const res = await markVideoUntranscribableAction(slug, videoId);
+ if (!res.ok) setError(res.error);
+ });
+ }}
+ className="self-start 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"
+ >
+ {pending ? "Marking…" : "Mark untranscribable"}
+ </button>
+ {error && (
+ <span
+ className="text-sm text-red-600 dark:text-red-400"
+ aria-label="mark untranscribable error"
+ >
+ {error}
+ </span>
+ )}
+ </section>
+ );
+}
+
+function DeleteFileButton({
+ slug,
+ videoId,
+ filename,
+}: {
+ slug: string;
+ videoId: string;
+ filename: string;
+}) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ return (
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ disabled={pending}
+ aria-label={`delete file ${filename}`}
+ onClick={() => {
+ if (!confirm(`Delete ${filename}?`)) return;
+ setError(null);
+ startTransition(async () => {
+ const res = await deleteVideoFileAction(slug, videoId, filename);
+ if (!res.ok) setError(res.error);
+ });
+ }}
+ className="self-start px-2 py-1 rounded border border-red-300 dark:border-red-800 text-red-700 dark:text-red-400 text-xs hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50"
+ >
+ {pending ? "Deleting…" : "Delete file"}
+ </button>
+ {error && (
+ <span
+ className="text-xs text-red-600 dark:text-red-400"
+ aria-label={`delete error ${filename}`}
+ >
+ {error}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function DeleteVideoDirSection({
+ slug,
+ videoId,
+}: {
+ slug: string;
+ videoId: string;
+}) {
+ const action = deleteVideoDirAction.bind(null, slug, videoId);
+ const [state, formAction] = useActionState<DeleteDirActionResult, FormData>(
+ action,
+ undefined,
+ );
+ return (
+ <section className="flex flex-col gap-3 border-t border-red-200 dark:border-red-900 pt-4">
+ <Heading
+ title="Delete this video directory"
+ desc={`Removes data/${videoId}/ and all of its contents. Type the video id to confirm.`}
+ />
+ <form
+ action={formAction}
+ aria-label={`delete video directory ${videoId}`}
+ className="flex flex-wrap items-start gap-2"
+ >
+ <input
+ name="confirmId"
+ required
+ placeholder={videoId}
+ aria-label="confirm video id"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono"
+ />
+ <button
+ type="submit"
+ className="px-3 py-1.5 rounded-md bg-red-600 text-white text-sm font-medium hover:bg-red-700"
+ >
+ Delete directory
+ </button>
+ {state?.error && (
+ <p
+ className="basis-full text-sm text-red-600 dark:text-red-400"
+ aria-label="delete directory error"
+ >
+ {state.error}
+ </p>
+ )}
+ </form>
+ </section>
+ );
+}
+
+function Heading({ title, desc }: { title: string; desc: string }) {
+ return (
+ <div>
+ <h3 className="text-base font-semibold">{title}</h3>
+ <p className="text-sm text-zinc-500">{desc}</p>
+ </div>
+ );
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx
@@ -0,0 +1,170 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import path from "node:path";
+import { readdir, readFile, stat } from "node:fs/promises";
+import type { Dirent } from "node:fs";
+import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ detectPlatform,
+ platformQueueKey,
+} from "yt-dlp-transcript-common/lib/platform";
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { VideoPanel, type VideoFile } from "./_components/VideoPanel";
+
+export const dynamic = "force-dynamic";
+
+type VideoMeta = {
+ title?: string;
+ webpageUrl?: string;
+ uploader?: string;
+ uploadDate?: string;
+ duration?: number;
+};
+
+async function loadVideoDir(
+ slug: string,
+ videoId: string,
+): Promise<{ files: VideoFile[] } | null> {
+ const dir = path.join(getPaths().channelsDir, slug, "data", videoId);
+ let entries: Dirent[];
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch {
+ return null;
+ }
+ const files: VideoFile[] = [];
+ for (const e of entries) {
+ if (!e.isFile()) continue;
+ if (e.name.includes(".tmp-")) continue;
+ const s = await stat(path.join(dir, e.name));
+ files.push({ name: e.name, size: s.size, mtime: s.mtimeMs });
+ }
+ files.sort((a, b) => a.name.localeCompare(b.name));
+ return { files };
+}
+
+async function loadMeta(slug: string, videoId: string): Promise<VideoMeta> {
+ const file = path.join(
+ getPaths().channelsDir,
+ slug,
+ "data",
+ videoId,
+ "metadata.info.json",
+ );
+ try {
+ const raw = await readFile(file, "utf8");
+ const j = JSON.parse(raw);
+ return {
+ title: typeof j?.title === "string" ? j.title : undefined,
+ webpageUrl:
+ typeof j?.webpage_url === "string" ? j.webpage_url : undefined,
+ uploader: typeof j?.uploader === "string" ? j.uploader : undefined,
+ uploadDate:
+ typeof j?.upload_date === "string" ? j.upload_date : undefined,
+ duration: typeof j?.duration === "number" ? j.duration : undefined,
+ };
+ } catch {
+ return {};
+ }
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ slug: string; id: string }>;
+}): Promise<Metadata> {
+ const { slug, id } = await params;
+ const meta = await loadMeta(slug, id);
+ const subject = meta.title ?? id;
+ return { title: `${subject} — Video — ${slug}` };
+}
+
+export default async function VideoDetailPage({
+ params,
+}: {
+ params: Promise<{ slug: string; id: string }>;
+}) {
+ const { slug, id } = await params;
+ const config = await readChannelConfig(getPaths(), slug);
+ if (!config) notFound();
+ const dirData = await loadVideoDir(slug, id);
+ if (!dirData) notFound();
+ const meta = await loadMeta(slug, id);
+
+ const existingQueues = getRegistry().activeQueueNames();
+ const defaultQueueKey = platformQueueKey(
+ config.platform ?? detectPlatform(config.url),
+ );
+
+ return (
+ <div className="flex flex-col gap-6">
+ <div className="flex items-center gap-2 text-sm text-zinc-500">
+ <Link
+ href="/channels"
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ Channels
+ </Link>
+ <span>/</span>
+ <Link
+ href={`/channels/${slug}`}
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100 font-mono"
+ >
+ {slug}
+ </Link>
+ <span>/</span>
+ <span className="font-mono">{id}</span>
+ </div>
+
+ <header className="flex flex-col gap-2">
+ <h1 className="text-2xl font-semibold">{meta.title ?? id}</h1>
+ <div className="flex flex-wrap gap-3 text-sm text-zinc-500">
+ {meta.webpageUrl && (
+ <a
+ href={meta.webpageUrl}
+ target="_blank"
+ rel="noreferrer"
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ Open on {new URL(meta.webpageUrl).hostname}
+ </a>
+ )}
+ {meta.uploader && <span>{meta.uploader}</span>}
+ {meta.uploadDate && <span>{formatUploadDate(meta.uploadDate)}</span>}
+ {typeof meta.duration === "number" && (
+ <span>{formatDuration(meta.duration)}</span>
+ )}
+ </div>
+ </header>
+
+ <VideoPanel
+ slug={slug}
+ videoId={id}
+ files={dirData.files}
+ handling={config.handling}
+ audioFormat={config.audioFormat ?? "mp3"}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ />
+ </div>
+ );
+}
+
+function formatUploadDate(s: string): string {
+ // yt-dlp emits YYYYMMDD. Render as YYYY-MM-DD; pass through anything else.
+ if (/^\d{8}$/.test(s)) {
+ return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
+ }
+ return s;
+}
+
+function formatDuration(seconds: number): string {
+ const s = Math.round(seconds);
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
+ return `${m}:${String(sec).padStart(2, "0")}`;
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -0,0 +1,277 @@
+"use server";
+
+import path from "node:path";
+import { readFile, rename, rm, stat, writeFile } from "node:fs/promises";
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import type {
+ AudioFormat,
+ ChannelConfig,
+} from "yt-dlp-transcript-common/lib/channelConfig";
+import { AUDIO_FORMAT_VALUES } from "yt-dlp-transcript-common/lib/channelConfig";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ detectPlatform,
+ platformQueueKey,
+} from "yt-dlp-transcript-common/lib/platform";
+import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
+import { pruneFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions";
+import { transcodeAudio } from "yt-dlp-transcript-common/controller/transcode";
+import { transcribeOneVideo } from "yt-dlp-transcript-common/controller/transcribeOne";
+import { runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdlp";
+import {
+ runManagedFunction,
+ type StreamActionResult,
+} from "yt-dlp-transcript-common/jobs/streamCommand";
+
+function videoQueueKey(config: ChannelConfig, override: string | undefined): string {
+ if (override === undefined) {
+ return platformQueueKey(config.platform ?? detectPlatform(config.url));
+ }
+ return override.trim();
+}
+
+function videoDirOf(slug: string, videoId: string): string {
+ return path.join(getPaths().channelsDir, slug, "data", videoId);
+}
+
+async function loadConfigOrError(
+ slug: string,
+): Promise<
+ | { ok: true; config: ChannelConfig }
+ | { ok: false; error: string }
+> {
+ const config = await readChannelConfig(getPaths(), slug);
+ if (!config) return { ok: false, error: `Channel "${slug}" not found` };
+ return { ok: true, config };
+}
+
+export async function transcodeAudioAction(
+ slug: string,
+ videoId: string,
+ sourceFilename: string,
+ targetFormat: AudioFormat,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ if (!AUDIO_FORMAT_VALUES.includes(targetFormat)) {
+ return { ok: false, error: `Unsupported target format: ${targetFormat}` };
+ }
+ const r = await loadConfigOrError(slug);
+ if (!r.ok) return r;
+ const paths = getPaths();
+ const videoDir = videoDirOf(slug, videoId);
+ return runManagedFunction({
+ kind: "transcode-audio",
+ queueKey: videoQueueKey(r.config, queueKey),
+ paths,
+ channelSlug: slug,
+ videoId,
+ fn: async (onLog, signal) => {
+ await transcodeAudio({
+ paths,
+ videoDir,
+ sourceFilename,
+ targetFormat,
+ onLog,
+ signal,
+ });
+ revalidatePath(`/channels/${slug}/videos/${videoId}`);
+ },
+ });
+}
+
+export async function transcribeOneAction(
+ slug: string,
+ videoId: string,
+ audioFilename: string,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const r = await loadConfigOrError(slug);
+ if (!r.ok) return r;
+ const paths = getPaths();
+ const videoDir = videoDirOf(slug, videoId);
+ return runManagedFunction({
+ kind: "transcribe-one",
+ queueKey: videoQueueKey(r.config, queueKey),
+ paths,
+ channelSlug: slug,
+ videoId,
+ fn: async (onLog, signal) => {
+ await transcribeOneVideo({
+ paths,
+ videoDir,
+ videoId,
+ audioFilename,
+ onLog,
+ signal,
+ });
+ revalidatePath(`/channels/${slug}/videos/${videoId}`);
+ },
+ });
+}
+
+export async function redownloadVideoAction(
+ slug: string,
+ videoId: string,
+ options: {
+ audioFormat?: AudioFormat;
+ extraArgs?: string[];
+ queueKey?: string;
+ } = {},
+): Promise<StreamActionResult> {
+ const { audioFormat, extraArgs, queueKey } = options;
+ if (audioFormat && !AUDIO_FORMAT_VALUES.includes(audioFormat)) {
+ return { ok: false, error: `Unsupported audio format: ${audioFormat}` };
+ }
+ const r = await loadConfigOrError(slug);
+ if (!r.ok) return r;
+ const paths = getPaths();
+ const videoDir = videoDirOf(slug, videoId);
+ let url: string;
+ try {
+ const raw = await readFile(path.join(videoDir, "metadata.info.json"), "utf8");
+ const parsed = JSON.parse(raw);
+ if (typeof parsed?.webpage_url !== "string" || !parsed.webpage_url) {
+ return {
+ ok: false,
+ error: "metadata.info.json has no webpage_url; cannot identify the source video.",
+ };
+ }
+ url = parsed.webpage_url;
+ } catch {
+ return {
+ ok: false,
+ error: "metadata.info.json is missing or unreadable for this video.",
+ };
+ }
+ return runManagedFunction({
+ kind: "download-one-audio",
+ queueKey: videoQueueKey(r.config, queueKey),
+ paths,
+ channelSlug: slug,
+ videoId,
+ fn: async (onLog, signal) => {
+ await runYtdlp({
+ channelSlug: slug,
+ mode: "download-one-audio",
+ channelConfig: r.config,
+ paths,
+ onLog,
+ signal,
+ singleVideoUrl: url,
+ audioFormatOverride: audioFormat,
+ extraYtdlpArgs: extraArgs,
+ });
+ revalidatePath(`/channels/${slug}/videos/${videoId}`);
+ },
+ });
+}
+
+function safeJoinUnderDir(
+ baseDir: string,
+ filename: string,
+): string | null {
+ if (!filename || filename === "." || filename === "..") return null;
+ if (filename.includes("\0")) return null;
+ // Reject anything that has path separators or parent-dir traversal.
+ if (filename.includes("/") || filename.includes("\\")) return null;
+ const base = path.resolve(baseDir);
+ const resolved = path.resolve(base, filename);
+ if (path.dirname(resolved) !== base) return null;
+ return resolved;
+}
+
+export async function deleteVideoFileAction(
+ slug: string,
+ videoId: string,
+ filename: string,
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const videoDir = videoDirOf(slug, videoId);
+ const target = safeJoinUnderDir(videoDir, filename);
+ if (!target) {
+ return { ok: false, error: `Refusing to delete suspicious filename "${filename}"` };
+ }
+ let s;
+ try {
+ s = await stat(target);
+ } catch {
+ return { ok: false, error: `File not found: ${filename}` };
+ }
+ if (!s.isFile()) {
+ return { ok: false, error: `Not a regular file: ${filename}` };
+ }
+ await rm(target, { force: true });
+ revalidatePath(`/channels/${slug}/videos/${videoId}`);
+ return { ok: true };
+}
+
+export type DeleteDirActionResult = { error: string } | undefined;
+
+export async function deleteVideoDirAction(
+ slug: string,
+ videoId: string,
+ _prev: DeleteDirActionResult,
+ formData: FormData,
+): Promise<DeleteDirActionResult> {
+ const confirm = String(formData.get("confirmId") ?? "").trim();
+ if (confirm !== videoId) {
+ return {
+ error: `Type the video id "${videoId}" exactly to confirm deletion`,
+ };
+ }
+ const videoDir = videoDirOf(slug, videoId);
+ // Belt-and-suspenders: ensure videoDir is inside the channel's data dir.
+ const dataDir = path.resolve(getPaths().channelsDir, slug, "data");
+ const resolved = path.resolve(videoDir);
+ if (path.dirname(resolved) !== dataDir) {
+ return { error: "Refusing to delete: video path resolved outside the data dir" };
+ }
+ await rm(resolved, { recursive: true, force: true });
+ revalidatePath(`/channels/${slug}`);
+ redirect(`/channels/${slug}`);
+}
+
+export async function markVideoUntranscribableAction(
+ slug: string,
+ videoId: string,
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const r = await loadConfigOrError(slug);
+ if (!r.ok) return r;
+ if (r.config.handling !== "transcribe") {
+ return {
+ ok: false,
+ error: "Only available on transcribe channels.",
+ };
+ }
+ const videoDir = videoDirOf(slug, videoId);
+ try {
+ const s = await stat(videoDir);
+ if (!s.isDirectory()) {
+ return { ok: false, error: `Video directory not found: ${videoId}` };
+ }
+ } catch {
+ return { ok: false, error: `Video directory not found: ${videoId}` };
+ }
+ const transcriptPath = path.join(videoDir, "transcript.json");
+ try {
+ await stat(transcriptPath);
+ return {
+ ok: false,
+ error: "transcript.json already exists; delete it first to remark.",
+ };
+ } catch {
+ // good — file does not exist
+ }
+ const tmp = path.join(videoDir, `transcript.tmp-${process.pid}.json`);
+ await writeFile(tmp, '{"transcription":[]}\n');
+ await rename(tmp, transcriptPath);
+ const failureListFile = path.join(
+ getPaths().channelsDir,
+ slug,
+ "failed-transcriptions",
+ );
+ await pruneFailedTranscriptions(failureListFile, new Set([videoId]));
+ revalidatePath(`/channels/${slug}/videos/${videoId}`);
+ revalidatePath(`/channels/${slug}`);
+ return { ok: true };
+}
diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts
@@ -2,7 +2,13 @@
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ AUDIO_FORMAT_VALUES,
+ type AudioFormat,
+} from "yt-dlp-transcript-common/lib/channelConfig";
import { runWhisperBatch } from "yt-dlp-transcript-common/controller/whisperBatch";
+import { runTranscodeFailures } from "yt-dlp-transcript-common/controller/transcodeFailures";
+import { cleanAudioFromTranscribed } from "yt-dlp-transcript-common/controller/cleanAudioFromTranscribed";
import { verifyTranscripts } from "yt-dlp-transcript-common/controller/verifyTranscripts";
import {
runManagedFunction,
@@ -20,7 +26,7 @@ export async function transcribeMissingAction(
const paths = getPaths();
return runManagedFunction({
kind: "whisper-all",
- queueKey: queueKey?.trim() || defaultQueueKey(slug),
+ queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(),
paths,
channelSlug: slug,
fn: async (onLog, signal) => {
@@ -46,7 +52,7 @@ export async function retryFailuresAction(
const paths = getPaths();
return runManagedFunction({
kind: "whisper-retry",
- queueKey: queueKey?.trim() || defaultQueueKey(slug),
+ queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(),
paths,
channelSlug: slug,
fn: async (onLog, signal) => {
@@ -65,6 +71,58 @@ export async function retryFailuresAction(
});
}
+export async function transcodeFailuresAction(
+ slug: string,
+ targetFormat: AudioFormat,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ if (!AUDIO_FORMAT_VALUES.includes(targetFormat)) {
+ return { ok: false, error: `Unsupported target format: ${targetFormat}` };
+ }
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "transcode-failures",
+ queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(),
+ paths,
+ channelSlug: slug,
+ fn: async (onLog, signal) => {
+ const result = await runTranscodeFailures({
+ channelSlug: slug,
+ paths,
+ targetFormat,
+ onLog,
+ signal,
+ });
+ onLog(
+ `Transcode failures: ${result.succeeded} succeeded, ${result.failed} failed, ${result.skipped} skipped, ${result.attempted} attempted.`,
+ );
+ revalidatePath(`/channels/${slug}`);
+ },
+ });
+}
+
+export async function cleanAudioAction(
+ slug: string,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "clean-audio-transcribed",
+ queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(),
+ paths,
+ channelSlug: slug,
+ fn: async (onLog, signal) => {
+ await cleanAudioFromTranscribed({
+ channelSlug: slug,
+ paths,
+ onLog,
+ signal,
+ });
+ revalidatePath(`/channels/${slug}`);
+ },
+ });
+}
+
export type VerifyResult =
| { ok: true; duplicates: string[]; missing: string[] }
| { ok: false; error: string };
diff --git a/editor/app/channels/_components/ChannelForm.tsx b/editor/app/channels/_components/ChannelForm.tsx
@@ -73,6 +73,23 @@ export function ChannelForm({
hint="Channel or playlist URL passed to yt-dlp."
/>
<label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Platform</span>
+ <select
+ name="platform"
+ defaultValue={c?.platform ?? ""}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ <option value="">Auto (detect from URL)</option>
+ <option value="youtube">YouTube</option>
+ <option value="rumble">Rumble</option>
+ <option value="odysee">Odysee</option>
+ </select>
+ <span className="text-xs text-zinc-500">
+ Used as the default job queue, so all channels on the same platform
+ run sequentially.
+ </span>
+ </label>
+ <label className="flex flex-col gap-1 text-sm">
<span className="font-medium">
Audio format <span className="text-zinc-500 font-normal">(transcribe only)</span>
</span>
@@ -81,7 +98,7 @@ export function ChannelForm({
defaultValue={c?.audioFormat ?? ""}
className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
>
- <option value="">Default (m4a)</option>
+ <option value="">Default (mp3)</option>
<option value="m4a">m4a</option>
<option value="mp3">mp3</option>
<option value="opus">opus</option>
diff --git a/editor/app/channels/_components/parseChannelForm.ts b/editor/app/channels/_components/parseChannelForm.ts
@@ -1,4 +1,9 @@
import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
+import {
+ PLATFORM_VALUES,
+ detectPlatform,
+ type Platform,
+} from "yt-dlp-transcript-common/lib/platform";
export type ParsedChannelForm = {
name: string;
@@ -20,6 +25,12 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
const slug = String(formData.get("slug") ?? "").trim();
const url = stringOrUndef(formData, "url");
+ const platformRaw = String(formData.get("platform") ?? "");
+ const platform: Platform | undefined = PLATFORM_VALUES.includes(
+ platformRaw as Platform,
+ )
+ ? (platformRaw as Platform)
+ : (detectPlatform(url) ?? undefined);
const audioFormatRaw = String(formData.get("audioFormat") ?? "");
const audioFormat: ChannelConfig["audioFormat"] | undefined =
audioFormatRaw === "m4a" ||
@@ -40,6 +51,7 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
handling: handlingRaw,
name,
};
+ if (platform) config.platform = platform;
if (url) config.url = url;
if (audioFormat) config.audioFormat = audioFormat;
if (cookiesFromBrowser) config.cookiesFromBrowser = cookiesFromBrowser;
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -69,6 +69,15 @@ export async function updateChannelAction(
return undefined;
}
+export async function gotoVideoAction(
+ slug: string,
+ formData: FormData,
+): Promise<void> {
+ const id = String(formData.get("id") ?? "").trim();
+ if (!id) redirect(`/channels/${slug}`);
+ redirect(`/channels/${slug}/videos/${encodeURIComponent(id)}`);
+}
+
export async function deleteChannelAction(
slug: string,
_prev: ActionResult,
diff --git a/editor/app/channels/new/page.tsx b/editor/app/channels/new/page.tsx
@@ -1,7 +1,10 @@
+import type { Metadata } from "next";
import Link from "next/link";
import { ChannelFormClient } from "../_components/ChannelFormClient";
import { createChannelAction } from "../actions";
+export const metadata: Metadata = { title: "New channel — Channels" };
+
export default function NewChannelPage() {
return (
<div className="flex flex-col gap-4">
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -1,9 +1,12 @@
+import type { Metadata } from "next";
import Link from "next/link";
import { listChannels } from "yt-dlp-transcript-common/controller/channels";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Channels" };
+
export default async function ChannelsPage() {
const channels = await listChannels(getPaths());
return (
@@ -28,9 +31,9 @@ export default async function ChannelsPage() {
<Th>Slug</Th>
<Th>Name</Th>
<Th>Handling</Th>
- <Th className="text-right">Videos</Th>
+ <Th className="text-right">Transcripts</Th>
+ <Th className="text-right">Downloads</Th>
<Th className="text-right">Playlist</Th>
- <Th className="text-right">Archive</Th>
<Th>Last sync</Th>
</tr>
</thead>
@@ -50,18 +53,23 @@ export default async function ChannelsPage() {
</Td>
<Td>{c.config.name ?? ""}</Td>
<Td>{c.config.handling}</Td>
- <Td className="text-right">{c.videoCount}</Td>
<Td
className="text-right"
- ariaLabel={`playlist count for ${c.slug}`}
+ ariaLabel={`transcripts count for ${c.slug}`}
>
- {c.playlistCount ?? "—"}
+ {c.transcriptCount}
</Td>
<Td
className="text-right"
- ariaLabel={`archive count for ${c.slug}`}
+ ariaLabel={`downloads count for ${c.slug}`}
>
- {c.archiveCount ?? "—"}
+ {c.downloadCount}
+ </Td>
+ <Td
+ className="text-right"
+ ariaLabel={`playlist count for ${c.slug}`}
+ >
+ {c.playlistCount ?? "—"}
</Td>
<Td className="text-xs text-zinc-500">
{c.config.lastSyncedAt
diff --git a/editor/app/jobs/[id]/page.tsx b/editor/app/jobs/[id]/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from "next";
import Link from "next/link";
import path from "node:path";
import { notFound } from "next/navigation";
@@ -7,6 +8,26 @@ import { JobLogTail } from "./_components/JobLogTail";
export const dynamic = "force-dynamic";
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}): Promise<Metadata> {
+ const { id } = await params;
+ const jobs = await listAllJobs(getPaths());
+ const job = jobs.find((j) => j.id === id);
+ if (!job) return { title: `${id} — Job` };
+ const scope = job.channelSlug
+ ? job.videoId
+ ? `${job.channelSlug}/${job.videoId}`
+ : job.channelSlug
+ : null;
+ const subject = scope
+ ? `${job.kind ?? "job"} (${scope})`
+ : (job.kind ?? "job");
+ return { title: `${subject} — Job` };
+}
+
export default async function JobDetailPage({
params,
}: {
@@ -38,6 +59,17 @@ export default async function JobDetailPage({
>
{job.channelSlug}
</Link>
+ {job.videoId && (
+ <>
+ {" / Video: "}
+ <Link
+ href={`/channels/${job.channelSlug}/videos/${encodeURIComponent(job.videoId)}`}
+ className="underline font-mono hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {job.videoId}
+ </Link>
+ </>
+ )}
</p>
)}
<dl className="text-sm grid grid-cols-2 sm:grid-cols-4 gap-3">
diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from "next";
import Link from "next/link";
import { listAllJobs } from "yt-dlp-transcript-common/jobs/listJobs";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
@@ -7,6 +8,8 @@ import { JobsAutoRefresh } from "./_components/JobsAutoRefresh";
export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Jobs" };
+
function fmtDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const s = ms / 1000;
@@ -55,6 +58,7 @@ export default async function JobsPage() {
<th className="text-left font-medium px-3 py-2">ID</th>
<th className="text-left font-medium px-3 py-2">Kind</th>
<th className="text-left font-medium px-3 py-2">Channel</th>
+ <th className="text-left font-medium px-3 py-2">Video</th>
<th className="text-left font-medium px-3 py-2">Queue</th>
<th className="text-left font-medium px-3 py-2">Status</th>
<th className="text-left font-medium px-3 py-2">Started</th>
@@ -98,7 +102,23 @@ export default async function JobsPage() {
)}
</td>
<td className="px-3 py-2 font-mono text-xs">
- {j.queueKey ?? "—"}
+ {j.channelSlug && j.videoId ? (
+ <Link
+ href={`/channels/${j.channelSlug}/videos/${encodeURIComponent(j.videoId)}`}
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ {j.videoId}
+ </Link>
+ ) : (
+ "—"
+ )}
+ </td>
+ <td className="px-3 py-2 font-mono text-xs">
+ {j.queueKey === undefined
+ ? "—"
+ : j.queueKey === ""
+ ? "(immediate)"
+ : j.queueKey}
</td>
<td className="px-3 py-2">
<span
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -6,7 +6,10 @@ import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
const settings = getSettings();
return {
- title: settings.siteTitle,
+ title: {
+ default: settings.siteTitle,
+ template: `%s — ${settings.siteTitle}`,
+ },
description:
settings.siteDescription || "Editor for yt-dlp-transcript-browser",
};
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -1,5 +1,6 @@
import { stat } from "node:fs/promises";
import path from "node:path";
+import type { Metadata } from "next";
import Link from "next/link";
import { listChannels } from "yt-dlp-transcript-common/controller/channels";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
@@ -7,6 +8,8 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Dashboard" };
+
async function getLastBuildTime(paths: ReturnType<typeof getPaths>) {
try {
const manifestPath = path.join(
diff --git a/editor/app/settings/page.tsx b/editor/app/settings/page.tsx
@@ -1,9 +1,12 @@
+import type { Metadata } from "next";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { SettingsForm } from "./_components/SettingsForm";
export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Settings" };
+
export default function SettingsPage() {
const paths = getPaths();
const settings = getSettings();
diff --git a/editor/e2e/channels-counts.spec.ts b/editor/e2e/channels-counts.spec.ts
@@ -1,22 +1,30 @@
import { test, expect } from "@playwright/test";
import { resetData } from "./helpers";
-test("shows em-dash when neither file exists", async ({ page }) => {
+test("shows zero/em-dash when no data exists", async ({ page }) => {
await resetData("one-youtube-channel");
await page.goto("/channels");
await expect(
page.getByLabel("playlist count for test-youtube"),
).toContainText("—");
await expect(
- page.getByLabel("archive count for test-youtube"),
- ).toContainText("—");
+ page.getByLabel("transcripts count for test-youtube"),
+ ).toHaveText("0");
+ await expect(
+ page.getByLabel("downloads count for test-youtube"),
+ ).toHaveText("0");
});
-test("shows entry counts when files exist", async ({ page }) => {
+test("shows playlist length when playlist exists", async ({ page }) => {
await resetData("channel-with-counts");
await page.goto("/channels");
await expect(page.getByLabel("playlist count for counted")).toHaveText("7");
- await expect(page.getByLabel("archive count for counted")).toHaveText("3");
+ // Fixture has playlist + archive but no data/ dirs, so neither transcripts
+ // nor downloads have anything to count.
+ await expect(page.getByLabel("transcripts count for counted")).toHaveText(
+ "0",
+ );
+ await expect(page.getByLabel("downloads count for counted")).toHaveText("0");
});
test("counts update after store-playlist and download", async ({ page }) => {
@@ -25,6 +33,12 @@ test("counts update after store-playlist and download", async ({ page }) => {
await expect(
page.getByLabel("playlist count for test-pipeline"),
).toContainText("—");
+ await expect(
+ page.getByLabel("transcripts count for test-pipeline"),
+ ).toHaveText("0");
+ await expect(
+ page.getByLabel("downloads count for test-pipeline"),
+ ).toHaveText("0");
await page.goto("/channels/test-pipeline");
await page.getByRole("button", { name: "Store playlist" }).click();
@@ -38,8 +52,8 @@ test("counts update after store-playlist and download", async ({ page }) => {
page.getByLabel("playlist count for test-pipeline"),
).toHaveText("5");
await expect(
- page.getByLabel("archive count for test-pipeline"),
- ).toContainText("—");
+ page.getByLabel("downloads count for test-pipeline"),
+ ).toHaveText("0");
await page.goto("/channels/test-pipeline");
await page.getByRole("button", { name: "Download from playlist" }).click();
@@ -49,6 +63,11 @@ test("counts update after store-playlist and download", async ({ page }) => {
await page.goto("/channels");
await expect(
- page.getByLabel("archive count for test-pipeline"),
+ page.getByLabel("downloads count for test-pipeline"),
+ ).toHaveText("5");
+ // YouTube channels: each downloaded video also counts as a transcript
+ // because the .vtt is the destination file.
+ await expect(
+ page.getByLabel("transcripts count for test-pipeline"),
).toHaveText("5");
});
diff --git a/editor/e2e/fixtures/bin/fake-ffmpeg.mjs b/editor/e2e/fixtures/bin/fake-ffmpeg.mjs
@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+// E2E fake ffmpeg. Mimics the args common/controller/transcode.ts passes:
+// ffmpeg -y -i <src> -vn -c:a <codec> <out>
+// Writes a small placeholder to <out> so the real transcode pipeline (rename
+// from tmp → audio.<fmt>) succeeds.
+import { writeFile } from "node:fs/promises";
+
+const argv = process.argv.slice(2);
+const out = argv[argv.length - 1];
+if (!out || out.startsWith("-")) {
+ process.stderr.write(`[fake-ffmpeg] missing output path\n`);
+ process.exit(2);
+}
+
+const iIdx = argv.indexOf("-i");
+const src = iIdx >= 0 ? argv[iIdx + 1] : "<unknown>";
+
+await writeFile(out, `fake-ffmpeg transcoded from ${src}\n`);
+process.stdout.write(`[fake-ffmpeg] wrote ${out}\n`);
diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs
@@ -79,18 +79,31 @@ async function writeTranscript(videoDir) {
await writeFile(path.join(videoDir, "transcript.en.vtt"), vtt);
}
-async function downloadOne(url) {
+async function downloadOne(url, opts = {}) {
const id = urlIdYouTube(url);
if (!id) return false;
const videoDir = path.join("data", id);
- if (existsSync(path.join(videoDir, "metadata.info.json"))) {
+ const audioFmt = opts.audioFormat;
+ // For audio-extraction invocations a single-URL re-download to fill in a
+ // missing audio.<fmt> is legitimate even if metadata is already on disk.
+ const alreadyHasMetadata = existsSync(
+ path.join(videoDir, "metadata.info.json"),
+ );
+ if (alreadyHasMetadata && !audioFmt) {
process.stdout.write(`[download] ${id} already in archive — skipping\n`);
return false;
}
await ensureDir(videoDir);
process.stdout.write(`[download] Fetching ${id}\n`);
- await writeMetadata(videoDir, id);
- await writeTranscript(videoDir);
+ if (!alreadyHasMetadata) await writeMetadata(videoDir, id);
+ if (audioFmt) {
+ await writeFile(
+ path.join(videoDir, `audio.${audioFmt}`),
+ `fake-ytdlp synthesised audio for ${id}\n`,
+ );
+ } else {
+ await writeTranscript(videoDir);
+ }
await appendFile("archive", `youtube ${id}\n`);
process.stdout.write(`[download] ${id} done\n`);
return true;
@@ -102,14 +115,21 @@ async function modeStorePlaylist() {
}
}
-async function modeDownloadFromFile(file) {
+async function modeDownloadFromFile(file, opts = {}) {
const text = await readFile(file, "utf8");
const urls = text.split("\n").map((s) => s.trim()).filter(Boolean);
process.stdout.write(`[fake-ytdlp] -a ${file} (${urls.length} urls)\n`);
// Mark that yt-dlp was actually invoked so a test can assert prefilter
// skipped this step when the playlist was empty.
await appendFile("fake-ytdlp.invocations", `download:${urls.length}\n`);
- for (const url of urls) await downloadOne(url);
+ for (const url of urls) await downloadOne(url, opts);
+ process.stdout.write(`[fake-ytdlp] download complete\n`);
+}
+
+async function modeDownloadOneUrl(url, opts) {
+ process.stdout.write(`[fake-ytdlp] single-url download ${url}\n`);
+ await appendFile("fake-ytdlp.invocations", `download-one:${url}\n`);
+ await downloadOne(url, opts);
process.stdout.write(`[fake-ytdlp] download complete\n`);
}
@@ -150,9 +170,11 @@ async function main() {
return;
}
+ const audioFmt = has("-x") ? arg("--audio-format") : undefined;
+
const playlistFile = arg("-a");
if (playlistFile) {
- await modeDownloadFromFile(playlistFile);
+ await modeDownloadFromFile(playlistFile, { audioFormat: audioFmt });
return;
}
@@ -162,6 +184,18 @@ async function main() {
return;
}
+ // Single-URL invocation (download-one-audio): no -a, no playlist flags,
+ // last positional is the URL.
+ if (audioFmt) {
+ const url = lastNonFlag();
+ if (!url) {
+ process.stderr.write(`[fake-ytdlp] single-url mode missing URL\n`);
+ process.exit(2);
+ }
+ await modeDownloadOneUrl(url, { audioFormat: audioFmt });
+ return;
+ }
+
process.stderr.write(
`[fake-ytdlp] unknown invocation: ${argv.join(" ")}\n`,
);
diff --git a/editor/e2e/queues.spec.ts b/editor/e2e/queues.spec.ts
@@ -1,7 +1,23 @@
import { test, expect } from "@playwright/test";
import { resetData } from "./helpers";
-test("default queue is channel:<slug>", async ({ page }) => {
+async function setQueueViaCustom(
+ page: import("@playwright/test").Page,
+ actionLabel: string,
+ value: string,
+) {
+ await page
+ .getByLabel(`queue for ${actionLabel}`, { exact: true })
+ .selectOption({ label: "Custom…" });
+ await page
+ .getByLabel(`custom queue for ${actionLabel}`, { exact: true })
+ .fill(value);
+ await page
+ .getByRole("button", { name: `save custom queue for ${actionLabel}` })
+ .click();
+}
+
+test("default queue is platform:<platform>", async ({ page }) => {
await resetData("slow-pipeline-channel");
await page.goto("/channels/slow-channel");
await page.getByRole("button", { name: "Sync" }).click();
@@ -11,7 +27,7 @@ test("default queue is channel:<slug>", async ({ page }) => {
await page.goto("/jobs");
const firstRow = page.getByRole("row").nth(1); // 0 is header
- await expect(firstRow).toContainText("channel:slow-channel");
+ await expect(firstRow).toContainText("platform:youtube");
});
test("queues a second job in the same queue, runs sequentially", async ({
@@ -20,18 +36,16 @@ test("queues a second job in the same queue, runs sequentially", async ({
await resetData("two-slow-channels");
await page.goto("/channels/slow-a");
- await page
- .getByLabel("Queue")
- .selectOption({ label: "+ new queue…" });
- await page.getByLabel("New queue name").fill("qShared");
- await page.getByRole("button", { name: "Save queue" }).click();
+ await setQueueViaCustom(page, "Sync", "qShared");
await page.getByRole("button", { name: "Sync" }).click();
await expect(page.getByLabel("Sync output")).toContainText("test-slow", {
timeout: 15_000,
});
await page.goto("/channels/slow-b");
- await page.getByLabel("Queue").selectOption({ label: "qShared" });
+ await page
+ .getByLabel("queue for Sync", { exact: true })
+ .selectOption({ label: "qShared" });
await page.getByRole("button", { name: "Sync" }).click();
// Second job should display the queued banner.
await expect(page.getByLabel("Sync queue status")).toContainText("qShared", {
@@ -62,18 +76,14 @@ test("runs jobs in different queues in parallel", async ({ page }) => {
await resetData("two-slow-channels");
await page.goto("/channels/slow-a");
- await page.getByLabel("Queue").selectOption({ label: "+ new queue…" });
- await page.getByLabel("New queue name").fill("qA");
- await page.getByRole("button", { name: "Save queue" }).click();
+ await setQueueViaCustom(page, "Sync", "qA");
await page.getByRole("button", { name: "Sync" }).click();
await expect(page.getByLabel("Sync output")).toContainText("test-slow", {
timeout: 15_000,
});
await page.goto("/channels/slow-b");
- await page.getByLabel("Queue").selectOption({ label: "+ new queue…" });
- await page.getByLabel("New queue name").fill("qB");
- await page.getByRole("button", { name: "Save queue" }).click();
+ await setQueueViaCustom(page, "Sync", "qB");
await page.getByRole("button", { name: "Sync" }).click();
await expect(page.getByLabel("Sync output")).toContainText("test-slow", {
timeout: 15_000,
@@ -92,16 +102,16 @@ test("cancels a queued job without disturbing the one running ahead of it", asyn
await resetData("two-slow-channels");
await page.goto("/channels/slow-a");
- await page.getByLabel("Queue").selectOption({ label: "+ new queue…" });
- await page.getByLabel("New queue name").fill("qShared");
- await page.getByRole("button", { name: "Save queue" }).click();
+ await setQueueViaCustom(page, "Sync", "qShared");
await page.getByRole("button", { name: "Sync" }).click();
await expect(page.getByLabel("Sync output")).toContainText("test-slow", {
timeout: 15_000,
});
await page.goto("/channels/slow-b");
- await page.getByLabel("Queue").selectOption({ label: "qShared" });
+ await page
+ .getByLabel("queue for Sync", { exact: true })
+ .selectOption({ label: "qShared" });
await page.getByRole("button", { name: "Sync" }).click();
await expect(page.getByLabel("Sync queue status")).toBeVisible({
timeout: 10_000,
@@ -120,3 +130,36 @@ test("cancels a queued job without disturbing the one running ahead of it", asyn
const slowARow = page.getByRole("row").filter({ hasText: "slow-a" }).first();
await expect(slowARow.getByText("running", { exact: true })).toBeVisible();
});
+
+test("empty queue runs immediately even when another job is queued in the same name", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+
+ // Park slow-a's Sync into qShared and start it.
+ await page.goto("/channels/slow-a");
+ await setQueueViaCustom(page, "Sync", "qShared");
+ await page.getByRole("button", { name: "Sync" }).click();
+ await expect(page.getByLabel("Sync output")).toContainText("test-slow", {
+ timeout: 15_000,
+ });
+
+ // slow-b sets queue to immediate (the default empty option) and starts;
+ // it should bypass qShared and run right away.
+ await page.goto("/channels/slow-b");
+ await page
+ .getByLabel("queue for Sync", { exact: true })
+ .selectOption({ label: "(immediate)" });
+ await page.getByRole("button", { name: "Sync" }).click();
+ // No "queued" banner: it goes straight to running and produces output.
+ await expect(page.getByLabel("Sync output")).toContainText("test-slow", {
+ timeout: 15_000,
+ });
+
+ await page.goto("/jobs");
+ const slowARow = page.getByRole("row").filter({ hasText: "slow-a" }).first();
+ const slowBRow = page.getByRole("row").filter({ hasText: "slow-b" }).first();
+ await expect(slowARow.getByText("running", { exact: true })).toBeVisible();
+ await expect(slowBRow.getByText("running", { exact: true })).toBeVisible();
+ await expect(slowBRow).toContainText("(immediate)");
+});
diff --git a/editor/e2e/video-page.spec.ts b/editor/e2e/video-page.spec.ts
@@ -0,0 +1,335 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { test, expect } from "@playwright/test";
+import { pathExists, resetData, resolvePath } from "./helpers";
+
+test("per-video page renders metadata and file list", async ({ page }) => {
+ await resetData("one-youtube-channel-with-data");
+ await page.goto("/channels/test-youtube/videos/20240101_test1234567");
+ await expect(page.getByRole("heading", { level: 1 })).toContainText(
+ "Synthetic Test Video",
+ );
+ const fileList = page.getByLabel("files for 20240101_test1234567");
+ await expect(
+ fileList.getByLabel("file name metadata.info.json"),
+ ).toBeVisible();
+ await expect(
+ fileList.getByLabel("file name transcript.en.vtt"),
+ ).toBeVisible();
+});
+
+test("per-video page 404s for missing channel/video", async ({ page }) => {
+ await resetData("empty");
+ const res = await page.goto("/channels/does-not-exist/videos/whatever", {
+ waitUntil: "domcontentloaded",
+ });
+ expect(res?.status()).toBe(404);
+});
+
+test("WhisperPanel renders failure list as links to per-video pages", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ await writeFile(
+ resolvePath(
+ "test-transcripts/channels/test-transcribe/failed-transcriptions",
+ ),
+ "vidA\nvidB\n",
+ );
+ await page.goto("/channels/test-transcribe");
+ await expect(page.getByLabel("failed transcription vidA")).toHaveAttribute(
+ "href",
+ "/channels/test-transcribe/videos/vidA",
+ );
+ await expect(page.getByLabel("failed transcription vidB")).toBeVisible();
+});
+
+test("Retry failures prunes successes and keeps still-failing entries", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ // vidNoAudio: directory exists but has no audio.* file, so transcription
+ // genuinely fails and the entry should remain in the failure list.
+ const noAudioDir = resolvePath(
+ "test-transcripts/channels/test-transcribe/data/vidNoAudio",
+ );
+ await mkdir(noAudioDir, { recursive: true });
+ await writeFile(
+ resolvePath(
+ "test-transcripts/channels/test-transcribe/failed-transcriptions",
+ ),
+ "vidA\nvidB\nvidNoAudio\n",
+ );
+ await page.goto("/channels/test-transcribe");
+ await page.getByRole("button", { name: "Retry failures" }).click();
+ await expect(page.getByLabel("Retry failures output")).toContainText(
+ "Pruned 2 from failed-transcriptions; 1 remain.",
+ { timeout: 30_000 },
+ );
+ await page.reload();
+ await expect(
+ page.getByLabel("failed transcription vidNoAudio"),
+ ).toBeVisible();
+ await expect(page.getByLabel("failed transcription vidA")).toHaveCount(0);
+ await expect(page.getByLabel("failed transcription vidB")).toHaveCount(0);
+});
+
+test("Retry failures prunes stale entries (missing dir, already transcribed)", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ // vidA already has a transcript.json on disk → should be pruned without retry.
+ const vidADir = resolvePath(
+ "test-transcripts/channels/test-transcribe/data/vidA",
+ );
+ await writeFile(
+ `${vidADir}/transcript.json`,
+ JSON.stringify({ result: { language: "en" }, transcription: [] }),
+ );
+ // vidGhost is in the failure file but has no directory → should be pruned.
+ await writeFile(
+ resolvePath(
+ "test-transcripts/channels/test-transcribe/failed-transcriptions",
+ ),
+ "vidA\nvidGhost\n",
+ );
+ await page.goto("/channels/test-transcribe");
+ await page.getByRole("button", { name: "Retry failures" }).click();
+ await expect(page.getByLabel("Retry failures output")).toContainText(
+ "Pruned 2 from failed-transcriptions; 0 remain.",
+ { timeout: 30_000 },
+ );
+ await page.reload();
+ await expect(
+ page.getByLabel("failed transcriptions empty"),
+ ).toBeVisible();
+});
+
+test("transcribe-one writes transcript.json for a specific audio file", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ await page.goto("/channels/test-transcribe/videos/vidA");
+ await page.getByRole("button", { name: "Transcribe audio.m4a" }).click();
+ await expect(page.getByLabel("Transcribe audio.m4a output")).toContainText(
+ "wrote",
+ { timeout: 30_000 },
+ );
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-transcribe/data/vidA/transcript.json",
+ ),
+ ).toBe(true);
+});
+
+test("transcode keeps source and writes audio.<target>", async ({ page }) => {
+ await resetData("one-transcribe-channel-with-audio");
+ await page.goto("/channels/test-transcribe/videos/vidA");
+ // Source is m4a, so the first non-source target offered is mp3.
+ await page
+ .getByRole("button", { name: /^Transcode audio\.m4a → mp3$/ })
+ .click();
+ await expect(
+ page.getByLabel("Transcode audio.m4a to mp3 output"),
+ ).toContainText("wrote", { timeout: 30_000 });
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-transcribe/data/vidA/audio.mp3",
+ ),
+ ).toBe(true);
+ // Source file still on disk.
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-transcribe/data/vidA/audio.m4a",
+ ),
+ ).toBe(true);
+});
+
+test("download audio for a YouTube video with no auto-subs", async ({
+ page,
+}) => {
+ await resetData("one-youtube-channel-with-data");
+ // Stage a video dir whose name matches the URL id so fake-ytdlp's audio
+ // output (data/<id>/audio.<fmt>) lands back in the same dir we navigate to.
+ const dir = resolvePath(
+ "test-transcripts/channels/test-youtube/data/test1234567",
+ );
+ await mkdir(dir, { recursive: true });
+ await writeFile(
+ `${dir}/metadata.info.json`,
+ JSON.stringify({
+ id: "test1234567",
+ title: "No-subs video",
+ webpage_url: "https://www.youtube.com/watch?v=test1234567",
+ }),
+ );
+ await page.goto("/channels/test-youtube/videos/test1234567");
+ await expect(page.getByRole("heading", { level: 1 })).toContainText(
+ "No-subs video",
+ );
+ await page.getByRole("button", { name: /^Download audio/ }).click();
+ await expect(
+ page.getByLabel("Download audio for test1234567 output"),
+ ).toContainText("download complete", { timeout: 30_000 });
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-youtube/data/test1234567/audio.mp3",
+ ),
+ ).toBe(true);
+});
+
+test("Go to video form navigates to per-video page", async ({ page }) => {
+ await resetData("one-youtube-channel-with-data");
+ await page.goto("/channels/test-youtube");
+ const form = page.getByLabel("Go to video");
+ await form.getByLabel("Video ID").fill("20240101_test1234567");
+ await form.getByRole("button", { name: "Open" }).click();
+ await expect(page).toHaveURL(
+ "/channels/test-youtube/videos/20240101_test1234567",
+ );
+});
+
+test("Empty failure list shows muted placeholder", async ({ page }) => {
+ await resetData("one-transcribe-channel-with-audio");
+ await page.goto("/channels/test-transcribe");
+ await expect(
+ page.getByLabel("failed transcriptions empty"),
+ ).toBeVisible();
+});
+
+test("Delete file removes a single file from the directory", async ({ page }) => {
+ await resetData("one-youtube-channel-with-data");
+ await page.goto("/channels/test-youtube/videos/20240101_test1234567");
+ page.on("dialog", (d) => d.accept());
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-youtube/data/20240101_test1234567/transcript.en.vtt",
+ ),
+ ).toBe(true);
+ await page.getByLabel("delete file transcript.en.vtt").click();
+ await expect(page.getByLabel("file name transcript.en.vtt")).toHaveCount(0);
+ // metadata.info.json is untouched.
+ await expect(page.getByLabel("file name metadata.info.json")).toBeVisible();
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-youtube/data/20240101_test1234567/transcript.en.vtt",
+ ),
+ ).toBe(false);
+});
+
+test("Delete directory wrong-id confirmation shows an error", async ({
+ page,
+}) => {
+ await resetData("one-youtube-channel-with-data");
+ await page.goto("/channels/test-youtube/videos/20240101_test1234567");
+ const form = page.getByLabel(
+ "delete video directory 20240101_test1234567",
+ );
+ await form.getByLabel("confirm video id").fill("nope");
+ await form.getByRole("button", { name: "Delete directory" }).click();
+ await expect(page.getByLabel("delete directory error")).toContainText(
+ "Type the video id",
+ );
+ // Still on the per-video page, dir intact.
+ await expect(page).toHaveURL(
+ "/channels/test-youtube/videos/20240101_test1234567",
+ );
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-youtube/data/20240101_test1234567",
+ ),
+ ).toBe(true);
+});
+
+test("Delete directory removes the dir and redirects to the channel page", async ({
+ page,
+}) => {
+ await resetData("one-youtube-channel-with-data");
+ await page.goto("/channels/test-youtube/videos/20240101_test1234567");
+ const form = page.getByLabel(
+ "delete video directory 20240101_test1234567",
+ );
+ await form.getByLabel("confirm video id").fill("20240101_test1234567");
+ await form.getByRole("button", { name: "Delete directory" }).click();
+ await expect(page).toHaveURL("/channels/test-youtube");
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-youtube/data/20240101_test1234567",
+ ),
+ ).toBe(false);
+});
+
+test("Mark untranscribable writes empty transcript and prunes failure list", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ await writeFile(
+ resolvePath(
+ "test-transcripts/channels/test-transcribe/failed-transcriptions",
+ ),
+ "vidA\nvidOther\n",
+ );
+ await page.goto("/channels/test-transcribe/videos/vidA");
+ await page.getByLabel("mark video vidA untranscribable").click();
+ await expect(
+ page.getByLabel("mark video vidA untranscribable"),
+ ).toHaveCount(0);
+
+ const raw = await readFile(
+ resolvePath(
+ "test-transcripts/channels/test-transcribe/data/vidA/transcript.json",
+ ),
+ "utf8",
+ );
+ expect(JSON.parse(raw)).toEqual({ transcription: [] });
+
+ const list = await readFile(
+ resolvePath(
+ "test-transcripts/channels/test-transcribe/failed-transcriptions",
+ ),
+ "utf8",
+ );
+ expect(list.trim().split("\n")).toEqual(["vidOther"]);
+});
+
+test("Mark untranscribable button is absent on YouTube channels", async ({
+ page,
+}) => {
+ await resetData("one-youtube-channel-with-data");
+ await page.goto("/channels/test-youtube/videos/20240101_test1234567");
+ await expect(
+ page.getByLabel("mark video 20240101_test1234567 untranscribable"),
+ ).toHaveCount(0);
+});
+
+test("Redownload writes audio in the chosen format", async ({ page }) => {
+ await resetData("one-youtube-channel-with-data");
+ // Stage a video dir whose name matches the URL id so fake-ytdlp lands its
+ // output (data/<id>/audio.<fmt>) back into the dir we navigated to. Include
+ // a pre-existing audio.m4a so the panel is in "Redownload" mode.
+ const dir = resolvePath(
+ "test-transcripts/channels/test-youtube/data/test1234567",
+ );
+ await mkdir(dir, { recursive: true });
+ await writeFile(
+ `${dir}/metadata.info.json`,
+ JSON.stringify({
+ id: "test1234567",
+ title: "Redownload target",
+ webpage_url: "https://www.youtube.com/watch?v=test1234567",
+ }),
+ );
+ await writeFile(`${dir}/audio.m4a`, "stale audio");
+ await page.goto("/channels/test-youtube/videos/test1234567");
+ await page.getByLabel("redownload audio format").selectOption("mp3");
+ await page
+ .getByRole("button", { name: /^Redownload audio \(mp3\)$/ })
+ .click();
+ await expect(
+ page.getByLabel("Redownload audio for test1234567 output"),
+ ).toContainText("download complete", { timeout: 30_000 });
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-youtube/data/test1234567/audio.mp3",
+ ),
+ ).toBe(true);
+});
diff --git a/editor/e2e/whisper.spec.ts b/editor/e2e/whisper.spec.ts
@@ -1,5 +1,6 @@
+import { writeFile } from "node:fs/promises";
import { test, expect } from "@playwright/test";
-import { pathExists, resetData } from "./helpers";
+import { pathExists, resetData, resolvePath } from "./helpers";
test("transcribes every audio file with no transcript", async ({ page }) => {
await resetData("one-transcribe-channel-with-audio");
@@ -51,6 +52,76 @@ test("retry-failures runs cleanly with an empty failure list", async ({ page })
);
});
+test("Transcode failures re-encodes each listed video to the chosen format", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ await writeFile(
+ resolvePath(
+ "test-transcripts/channels/test-transcribe/failed-transcriptions",
+ ),
+ "vidA\nvidB\n",
+ );
+ await page.goto("/channels/test-transcribe");
+ // Default format on the new dropdown is mp3.
+ await page
+ .getByRole("button", { name: /^Transcode failures → mp3$/ })
+ .click();
+ await expect(page.getByLabel("Transcode failures output")).toContainText(
+ "2 succeeded",
+ { timeout: 30_000 },
+ );
+ for (const id of ["vidA", "vidB"]) {
+ expect(
+ await pathExists(
+ `test-transcripts/channels/test-transcribe/data/${id}/audio.mp3`,
+ ),
+ ).toBe(true);
+ // Source m4a is preserved.
+ expect(
+ await pathExists(
+ `test-transcripts/channels/test-transcribe/data/${id}/audio.m4a`,
+ ),
+ ).toBe(true);
+ }
+});
+
+test("Clean audio removes audio files only from transcribed videos", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ // vidA gets a transcript so its audio should be cleaned.
+ await writeFile(
+ resolvePath(
+ "test-transcripts/channels/test-transcribe/data/vidA/transcript.json",
+ ),
+ JSON.stringify({ result: { language: "en" }, transcription: [] }),
+ );
+ await page.goto("/channels/test-transcribe");
+ await page.getByRole("button", { name: "Clean audio" }).click();
+ await expect(page.getByLabel("Clean audio output")).toContainText(
+ "Cleaned 1 audio file(s) from 1 of 3 video dir(s).",
+ { timeout: 30_000 },
+ );
+ // vidA's audio is gone, transcript stays.
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-transcribe/data/vidA/audio.m4a",
+ ),
+ ).toBe(false);
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-transcribe/data/vidA/transcript.json",
+ ),
+ ).toBe(true);
+ // vidB has no transcript, its audio is left alone.
+ expect(
+ await pathExists(
+ "test-transcripts/channels/test-transcribe/data/vidB/audio.m4a",
+ ),
+ ).toBe(true);
+});
+
test("whisper panel hidden for youtube channels", async ({ page }) => {
await resetData("one-youtube-channel");
await page.goto("/channels/test-youtube");
diff --git a/editor/package.json b/editor/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "next dev --port 3001",
- "dev:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null next dev --port 3001",
+ "dev:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "eslint",
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -18,7 +18,10 @@ const geistMono = Geist_Mono({
export function generateMetadata(): Metadata {
const s = getSettings();
return {
- title: s.siteTitle,
+ title: {
+ default: s.siteTitle,
+ template: `%s — ${s.siteTitle}`,
+ },
description: s.siteDescription,
};
}
diff --git a/settings.json b/settings.json
@@ -1,5 +1,7 @@
{
"siteTitle": "Jeralyzer",
- "siteDescription": "Browse and search Quartering's video transcripts",
- "headerTitle": "Jeralyzer"
+ "siteDescription": "Browse and search Jeremy Hambly's video transcripts",
+ "headerTitle": "Jeralyzer",
+ "homeTagline": "",
+ "maxTranscriptPageBytes": 8388608
}