commit ac2cea66f2a93bd5d37917bcad091fe77d594c10
parent 1b24f2d620606b3079c405302d3a583cba1d99a3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 10 May 2026 21:57:59 -0400
Redo UI
Diffstat:
43 files changed, 4003 insertions(+), 1017 deletions(-)
diff --git a/common/components/StreamActionLog.tsx b/common/components/StreamActionLog.tsx
@@ -19,6 +19,11 @@ type Props = {
* span). Used to attach a per-button QueueControl.
*/
extraControls?: React.ReactNode;
+ /**
+ * Disables the action button regardless of running state. Used by callers
+ * that need a typed-confirmation gate before allowing the action.
+ */
+ disabled?: boolean;
};
type StatusPoll = {
@@ -34,6 +39,7 @@ export function StreamActionLog({
label,
cancelAction,
extraControls,
+ disabled = false,
}: Props) {
const accessibleName = label ?? buttonLabel;
const [running, setRunning] = useState(false);
@@ -139,7 +145,7 @@ export function StreamActionLog({
<button
type="button"
onClick={handleClick}
- disabled={running}
+ disabled={running || disabled}
className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{running ? runningLabel : buttonLabel}
diff --git a/common/controller/archiveTranscripts.ts b/common/controller/archiveTranscripts.ts
@@ -0,0 +1,481 @@
+// Build archives of every channel's compact transcript.cues.json files.
+// Two output modes share the same staging logic:
+//
+// archiveTranscripts() — one <slug>.<ext> per channel
+// archiveCombinedTranscripts() — one all-transcripts.<ext> total
+//
+// Internal layout of <slug>.<ext>:
+// <slug>/channel.json
+// <slug>/<videoId>/transcript.cues.json
+//
+// Internal layout of all-transcripts.<ext>:
+// manifest.json (cross-channel index)
+// <slug>/channel.json (one per channel)
+// <slug>/<videoId>/transcript.cues.json
+//
+// Both modes stage hard-link trees under transcripts/export/.staging/ so
+// the compressor can stream in one pass with no extra disk usage, then
+// unlink the staging dir on success or failure. When includeMetadata or
+// prettyPrint is overridden, staging rewrites each cues file instead of
+// hard-linking it (slower, but bounded).
+
+import path from "node:path";
+import {
+ link,
+ mkdir,
+ readdir,
+ readFile,
+ rm,
+ writeFile,
+ stat,
+} from "node:fs/promises";
+import { execa } from "execa";
+import pLimit from "p-limit";
+import { listChannels, type ChannelStat } from "./channels";
+import {
+ normalizeTranscript,
+ type NormalizedTranscript,
+} from "./normalizeTranscript";
+import { CUES_JSON_FILENAME } from "../lib/videoStatus";
+import { detectPlatform } from "../lib/platform";
+import type { Paths } from "../lib/paths";
+
+export type ArchiveFormat = "tar.gz" | "tar.xz" | "zip";
+
+export const ARCHIVE_FORMATS: ReadonlyArray<ArchiveFormat> = [
+ "tar.gz",
+ "tar.xz",
+ "zip",
+];
+
+export type ArchiveBuildOptions = {
+ format: ArchiveFormat;
+ // 1 (fastest, biggest) … 9 (slowest, smallest). Common to all three formats.
+ compressionLevel: number;
+ // When false, archive entries are stripped to { version, source, cues }.
+ // Per-video addressability still comes from the file path.
+ includeMetadata: boolean;
+ // When true, cues files are written with 2-space indentation. Channel
+ // manifests are always indented.
+ prettyPrint: boolean;
+};
+
+export const DEFAULT_ARCHIVE_OPTIONS: ArchiveBuildOptions = {
+ format: "tar.gz",
+ compressionLevel: 6,
+ includeMetadata: true,
+ prettyPrint: false,
+};
+
+export type ArchiveTranscriptsOptions = {
+ paths: Paths;
+ onLog?: (msg: string) => void;
+ signal?: AbortSignal;
+ concurrency?: number;
+ channelSlugs?: string[];
+ build?: Partial<ArchiveBuildOptions>;
+};
+
+export type ArchiveTranscriptsResult = {
+ archives: { slug: string; archivePath: string; transcriptCount: number }[];
+ totalTranscripts: number;
+ skipped: { slug: string; reason: string }[];
+};
+
+export type ArchiveCombinedResult = {
+ archivePath: string | null;
+ channels: { slug: string; transcriptCount: number }[];
+ totalTranscripts: number;
+ skipped: { slug: string; reason: string }[];
+};
+
+const COMBINED_BASENAME = "all-transcripts";
+const COMBINED_STAGING_DIR = "all-transcripts";
+
+function archivesDir(paths: Paths): string {
+ return path.join(paths.transcriptsDir, "export", "archives");
+}
+
+function stagingDir(paths: Paths): string {
+ return path.join(paths.transcriptsDir, "export", ".staging");
+}
+
+function resolveBuild(
+ partial: Partial<ArchiveBuildOptions> | undefined,
+): ArchiveBuildOptions {
+ const merged = { ...DEFAULT_ARCHIVE_OPTIONS, ...partial };
+ const lvl = Math.round(merged.compressionLevel);
+ return {
+ ...merged,
+ compressionLevel: Math.min(9, Math.max(1, Number.isFinite(lvl) ? lvl : 6)),
+ };
+}
+
+function channelManifest(ch: ChannelStat, linkedCount: number) {
+ return {
+ slug: ch.slug,
+ name: ch.config.name ?? null,
+ handling: ch.config.handling,
+ platform:
+ ch.config.platform ?? detectPlatform(ch.config.url ?? null) ?? null,
+ url: ch.config.url ?? null,
+ transcriptCount: linkedCount,
+ generatedAt: new Date().toISOString(),
+ };
+}
+
+// Hard-link or transform-write every video's transcript.cues.json from
+// <channelsDir>/<slug>/data/ into <destDir>/<videoId>/transcript.cues.json.
+// Normalises on demand so the stage tree is always self-consistent. Returns
+// the count of staged entries (zero means the channel had no normalisable
+// transcripts).
+async function stageChannel(
+ paths: Paths,
+ ch: ChannelStat,
+ destDir: string,
+ build: ArchiveBuildOptions,
+ log: (msg: string) => void,
+ limit: ReturnType<typeof pLimit>,
+): Promise<{ linkedCount: number; normalizedCount: number; normalizeFailed: number }> {
+ const dataDir = path.join(paths.channelsDir, ch.slug, "data");
+ await rm(destDir, { recursive: true, force: true });
+ await mkdir(destDir, { recursive: true });
+
+ let videoIds: string[];
+ try {
+ videoIds = await readdir(dataDir);
+ } catch {
+ videoIds = [];
+ }
+
+ log(`Archive ${ch.slug}: scanning ${videoIds.length} videos`);
+
+ let normalizedCount = 0;
+ let linkedCount = 0;
+ let normalizeFailed = 0;
+
+ // Hard-linking is only safe when the on-disk file matches what the
+ // archive should contain. If the user toggled metadata off or pretty-
+ // print on, we have to re-encode each entry.
+ const requiresRewrite = !build.includeMetadata || build.prettyPrint;
+
+ await Promise.all(
+ videoIds.map((id) =>
+ limit(async () => {
+ const videoDir = path.join(dataDir, id);
+ try {
+ const s = await stat(videoDir);
+ if (!s.isDirectory()) return;
+ } catch {
+ return;
+ }
+ let outcome;
+ try {
+ outcome = await normalizeTranscript({
+ videoDir,
+ channelSlug: ch.slug,
+ configName: ch.config.name,
+ });
+ } catch (err) {
+ normalizeFailed++;
+ log(` ! normalize ${ch.slug}/${id}: ${(err as Error).message}`);
+ return;
+ }
+ if (outcome.status === "skipped") return;
+ if (outcome.status === "wrote") normalizedCount++;
+ const dest = path.join(destDir, id, CUES_JSON_FILENAME);
+ await mkdir(path.dirname(dest), { recursive: true });
+ if (requiresRewrite) {
+ await writeTransformedCues(outcome.cuesPath, dest, build);
+ } else {
+ await link(outcome.cuesPath, dest);
+ }
+ linkedCount++;
+ }),
+ ),
+ );
+
+ if (normalizedCount > 0) {
+ log(` ${ch.slug}: normalized ${normalizedCount} cues files on demand`);
+ }
+ if (normalizeFailed > 0) {
+ log(` ${ch.slug}: ${normalizeFailed} videos failed to normalize`);
+ }
+
+ if (linkedCount > 0) {
+ await writeFile(
+ path.join(destDir, "channel.json"),
+ JSON.stringify(channelManifest(ch, linkedCount), null, 2) + "\n",
+ );
+ }
+
+ return { linkedCount, normalizedCount, normalizeFailed };
+}
+
+async function writeTransformedCues(
+ sourcePath: string,
+ destPath: string,
+ build: ArchiveBuildOptions,
+): Promise<void> {
+ const raw = await readFile(sourcePath, "utf8");
+ const parsed = JSON.parse(raw) as NormalizedTranscript;
+ const payload = build.includeMetadata
+ ? parsed
+ : {
+ version: parsed.version,
+ source: parsed.source,
+ cues: parsed.cues,
+ };
+ const body = build.prettyPrint
+ ? JSON.stringify(payload, null, 2) + "\n"
+ : JSON.stringify(payload);
+ await writeFile(destPath, body);
+}
+
+function archiveExtension(format: ArchiveFormat): string {
+ return format;
+}
+
+// Compress a single staged top-level entry (a slug dir for per-channel
+// mode, or one of multiple entries for combined mode) into archivePath.
+async function compressArchive(
+ archivePath: string,
+ cwd: string,
+ members: string[],
+ build: ArchiveBuildOptions,
+): Promise<void> {
+ const level = String(build.compressionLevel);
+ if (build.format === "zip") {
+ // `zip -r -<level>` walks each member dir recursively. -X strips
+ // uid/gid/extras so output is reproducible. Overwrite via -f? No —
+ // we delete archivePath first so zip doesn't append to a stale file.
+ await rm(archivePath, { force: true });
+ await execa(
+ "zip",
+ ["-r", `-${level}`, "-X", "-q", archivePath, ...members],
+ { cwd, stdio: "ignore" },
+ );
+ return;
+ }
+ // tar.gz / tar.xz: route compression through --use-compress-program so we
+ // can pass the level flag.
+ const compressor =
+ build.format === "tar.xz"
+ ? `xz -${level} -T 0 -c`
+ : `gzip -${level} -c`;
+ await execa(
+ "tar",
+ [
+ "--use-compress-program",
+ compressor,
+ "-cf",
+ archivePath,
+ "--owner=0",
+ "--group=0",
+ "--mtime=@0",
+ "-C",
+ cwd,
+ ...members,
+ ],
+ { stdio: "ignore" },
+ );
+}
+
+export async function archiveTranscripts(
+ opts: ArchiveTranscriptsOptions,
+): Promise<ArchiveTranscriptsResult> {
+ const build = resolveBuild(opts.build);
+ const log = opts.onLog ?? ((m: string) => console.log(m));
+ const limit = pLimit(opts.concurrency ?? 8);
+ const archives: ArchiveTranscriptsResult["archives"] = [];
+ const skipped: ArchiveTranscriptsResult["skipped"] = [];
+ let totalTranscripts = 0;
+
+ log(
+ `Format: ${build.format} level=${build.compressionLevel} metadata=${build.includeMetadata} pretty=${build.prettyPrint}`,
+ );
+
+ await mkdir(archivesDir(opts.paths), { recursive: true });
+ await mkdir(stagingDir(opts.paths), { recursive: true });
+
+ const allChannels = await listChannels(opts.paths);
+ const target = opts.channelSlugs
+ ? allChannels.filter((c) => opts.channelSlugs!.includes(c.slug))
+ : allChannels;
+
+ for (const ch of target) {
+ if (opts.signal?.aborted) {
+ log("Aborted.");
+ break;
+ }
+ const staging = path.join(stagingDir(opts.paths), ch.slug);
+ const archivePath = path.join(
+ archivesDir(opts.paths),
+ `${ch.slug}.${archiveExtension(build.format)}`,
+ );
+ try {
+ const { linkedCount } = await stageChannel(
+ opts.paths,
+ ch,
+ staging,
+ build,
+ log,
+ limit,
+ );
+ if (linkedCount === 0) {
+ await rm(staging, { recursive: true, force: true });
+ skipped.push({ slug: ch.slug, reason: "no normalized transcripts" });
+ log(` ${ch.slug}: skipped (no transcripts to archive)`);
+ continue;
+ }
+ try {
+ await compressArchive(
+ archivePath,
+ stagingDir(opts.paths),
+ [ch.slug],
+ build,
+ );
+ } finally {
+ await rm(staging, { recursive: true, force: true });
+ }
+ log(
+ ` ${ch.slug}: ${linkedCount} transcripts → ${path.basename(archivePath)}`,
+ );
+ archives.push({ slug: ch.slug, archivePath, transcriptCount: linkedCount });
+ totalTranscripts += linkedCount;
+ } catch (err) {
+ await rm(staging, { recursive: true, force: true }).catch(() => {});
+ skipped.push({ slug: ch.slug, reason: (err as Error).message });
+ log(` ! ${ch.slug}: ${(err as Error).message}`);
+ }
+ }
+
+ log("");
+ log(
+ `Wrote ${archives.length} archive${archives.length === 1 ? "" : "s"} containing ${totalTranscripts} transcripts to:`,
+ );
+ log(` ${archivesDir(opts.paths)}`);
+ for (const a of archives) {
+ log(` - ${path.basename(a.archivePath)} (${a.transcriptCount} transcripts)`);
+ }
+ if (skipped.length > 0) {
+ log("");
+ log(`Skipped ${skipped.length} channel(s):`);
+ for (const s of skipped) log(` - ${s.slug}: ${s.reason}`);
+ }
+
+ return { archives, totalTranscripts, skipped };
+}
+
+export async function archiveCombinedTranscripts(
+ opts: ArchiveTranscriptsOptions,
+): Promise<ArchiveCombinedResult> {
+ const build = resolveBuild(opts.build);
+ const log = opts.onLog ?? ((m: string) => console.log(m));
+ const limit = pLimit(opts.concurrency ?? 8);
+ const skipped: ArchiveCombinedResult["skipped"] = [];
+ const channels: ArchiveCombinedResult["channels"] = [];
+ let totalTranscripts = 0;
+
+ log(
+ `Format: ${build.format} level=${build.compressionLevel} metadata=${build.includeMetadata} pretty=${build.prettyPrint}`,
+ );
+
+ await mkdir(archivesDir(opts.paths), { recursive: true });
+ await mkdir(stagingDir(opts.paths), { recursive: true });
+
+ const combinedStaging = path.join(stagingDir(opts.paths), COMBINED_STAGING_DIR);
+ const archivePath = path.join(
+ archivesDir(opts.paths),
+ `${COMBINED_BASENAME}.${archiveExtension(build.format)}`,
+ );
+
+ await rm(combinedStaging, { recursive: true, force: true });
+ await mkdir(combinedStaging, { recursive: true });
+
+ try {
+ const allChannels = await listChannels(opts.paths);
+ const target = opts.channelSlugs
+ ? allChannels.filter((c) => opts.channelSlugs!.includes(c.slug))
+ : allChannels;
+
+ for (const ch of target) {
+ if (opts.signal?.aborted) {
+ log("Aborted.");
+ break;
+ }
+ const dest = path.join(combinedStaging, ch.slug);
+ try {
+ const { linkedCount } = await stageChannel(
+ opts.paths,
+ ch,
+ dest,
+ build,
+ log,
+ limit,
+ );
+ if (linkedCount === 0) {
+ await rm(dest, { recursive: true, force: true });
+ skipped.push({ slug: ch.slug, reason: "no normalized transcripts" });
+ log(` ${ch.slug}: skipped (no transcripts to archive)`);
+ continue;
+ }
+ channels.push({ slug: ch.slug, transcriptCount: linkedCount });
+ totalTranscripts += linkedCount;
+ } catch (err) {
+ await rm(dest, { recursive: true, force: true }).catch(() => {});
+ skipped.push({ slug: ch.slug, reason: (err as Error).message });
+ log(` ! ${ch.slug}: ${(err as Error).message}`);
+ }
+ }
+
+ if (channels.length === 0) {
+ log("No channels had normalisable transcripts; nothing to archive.");
+ return {
+ archivePath: null,
+ channels,
+ totalTranscripts,
+ skipped,
+ };
+ }
+
+ await writeFile(
+ path.join(combinedStaging, "manifest.json"),
+ JSON.stringify(
+ {
+ version: 1,
+ generatedAt: new Date().toISOString(),
+ channelCount: channels.length,
+ transcriptCount: totalTranscripts,
+ channels,
+ },
+ null,
+ 2,
+ ) + "\n",
+ );
+
+ const tarMembers = ["manifest.json", ...channels.map((c) => c.slug)];
+ log(
+ `Compressing ${tarMembers.length - 1} channels into ${path.basename(archivePath)}…`,
+ );
+ await compressArchive(archivePath, combinedStaging, tarMembers, build);
+ log("");
+ log(
+ `Wrote ${path.basename(archivePath)} containing ${totalTranscripts} transcripts across ${channels.length} channels to:`,
+ );
+ log(` ${archivePath}`);
+ if (skipped.length > 0) {
+ log("");
+ log(`Skipped ${skipped.length} channel(s):`);
+ for (const s of skipped) log(` - ${s.slug}: ${s.reason}`);
+ }
+ return {
+ archivePath,
+ channels,
+ totalTranscripts,
+ skipped,
+ };
+ } finally {
+ await rm(combinedStaging, { recursive: true, force: true });
+ }
+}
diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts
@@ -29,6 +29,10 @@ import {
toDisplaySummary,
type RawMetadata,
} from "../lib/transcripts-server";
+import {
+ isCuesJsonFresh,
+ readNormalizedTranscript,
+} from "./normalizeTranscript";
import type {
TranscriptSummary,
TranscriptDetail,
@@ -409,14 +413,50 @@ export async function buildIndex({
await Promise.all(
slice.map(async (s) => {
try {
- const metaRaw = await readFile(s.metaPath, "utf8");
- const parsedMeta = JSON.parse(metaRaw) as RawMetadata;
- const summary = summarize(
- s.channelSlug,
- s.videoDir,
- parsedMeta,
- s.configName,
- );
+ const videoFullDir = path.dirname(s.metaPath);
+ const cuesFresh = await isCuesJsonFresh(videoFullDir);
+
+ let summary: TranscriptSummary | undefined;
+ let cueList: Cue[] | undefined;
+ if (cuesFresh.fresh) {
+ const normalized = await readNormalizedTranscript(cuesFresh.cuesPath);
+ if (normalized) {
+ const {
+ version,
+ source,
+ cues: cuesField,
+ ...rest
+ } = normalized;
+ void version;
+ void source;
+ summary = {
+ ...rest,
+ channel: s.configName ?? rest.channel,
+ };
+ cueList = cuesField;
+ }
+ }
+ if (!summary) {
+ const metaRaw = await readFile(s.metaPath, "utf8");
+ const parsedMeta = JSON.parse(metaRaw) as RawMetadata;
+ summary = summarize(
+ s.channelSlug,
+ s.videoDir,
+ parsedMeta,
+ s.configName,
+ );
+ if (s.transcriptMs !== null && s.transcriptKind) {
+ try {
+ const raw = await readFile(s.transcriptPath, "utf8");
+ cueList =
+ s.transcriptKind === "vtt"
+ ? parseVtt(raw)
+ : parseWhisper(raw);
+ } catch {
+ cueList = undefined;
+ }
+ }
+ }
if (!summary.uploadDate) {
log(`Skipping ${s.channelSlug}/${s.videoDir}: no upload_date`);
return;
@@ -426,16 +466,6 @@ export async function buildIndex({
s.channelSlug,
summary.id,
];
- let cueList: Cue[] | undefined;
- if (s.transcriptMs !== null && s.transcriptKind) {
- try {
- const raw = await readFile(s.transcriptPath, "utf8");
- cueList =
- s.transcriptKind === "vtt" ? parseVtt(raw) : parseWhisper(raw);
- } catch {
- cueList = undefined;
- }
- }
const pk: PathKey = [s.channelSlug, s.videoDir];
const prev = mtimes.get(pk);
diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts
@@ -11,6 +11,7 @@ import {
import type { Paths } from "../lib/paths";
import { extractVideoId, isRumbleUrl } from "../ytdlp/runYtdlp";
import { loadFailedTranscriptions } from "./failedTranscriptions";
+import { readChannelConfig } from "./channels";
export type ChannelSnapshot = {
generatedAt: string;
@@ -22,6 +23,8 @@ export type ChannelSnapshot = {
buckets: {
noTranscript: string[];
downloadedNoTranscript: string[];
+ untranscoded: string[];
+ multipleAudioFormats: string[];
untranscribable: string[];
noMetadata: string[];
failedListed: string[];
@@ -71,12 +74,16 @@ export async function generateChannelSnapshot(
const archivePath = path.join(channelDir, "archive");
const playlistPath = path.join(channelDir, "playlist");
- const [dirEntries, urls, archive, failedListed] = await Promise.all([
+ const [dirEntries, urls, archive, failedListed, config] = await Promise.all([
readdir(dataDir, { withFileTypes: true }).catch(() => [] as Dirent[]),
readPlaylistUrls(playlistPath),
readArchive(archivePath),
loadFailedTranscriptions(paths, slug),
+ readChannelConfig(paths, slug),
]);
+ const targetAudioFile = config?.audioFormat
+ ? `audio.${config.audioFormat}`
+ : null;
const videoDirNames = dirEntries
.filter((d) => d.isDirectory())
.map((d) => d.name);
@@ -117,6 +124,8 @@ export async function generateChannelSnapshot(
const noTranscript: string[] = [];
const downloadedNoTranscript: string[] = [];
+ const untranscoded: string[] = [];
+ const multipleAudioFormats: string[] = [];
const untranscribable: string[] = [];
const noMetadata: string[] = [];
let transcribed = 0;
@@ -125,6 +134,20 @@ export async function generateChannelSnapshot(
if (isVideoTranscribed(files)) transcribed++;
if (isVideoDownloaded(files)) downloaded++;
if (!files.hasMeta) noMetadata.push(id);
+ if (
+ targetAudioFile &&
+ files.audioFiles.length > 0 &&
+ !files.audioFiles.includes(targetAudioFile)
+ ) {
+ untranscoded.push(id);
+ }
+ if (
+ targetAudioFile &&
+ files.audioFiles.includes(targetAudioFile) &&
+ files.audioFiles.length > 1
+ ) {
+ multipleAudioFormats.push(id);
+ }
if (files.isUntranscribable) {
untranscribable.push(id);
continue;
@@ -163,6 +186,8 @@ export async function generateChannelSnapshot(
buckets: {
noTranscript: noTranscript.sort(),
downloadedNoTranscript: downloadedNoTranscript.sort(),
+ untranscoded: untranscoded.sort(),
+ multipleAudioFormats: multipleAudioFormats.sort(),
untranscribable: untranscribable.sort(),
noMetadata: noMetadata.sort(),
failedListed,
diff --git a/common/controller/cleanAudioFromTranscribed.ts b/common/controller/cleanAudioFromTranscribed.ts
@@ -40,7 +40,10 @@ export async function cleanAudioFromTranscribed({
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-"),
+ (e) =>
+ e.startsWith("audio.") &&
+ !e.includes(".tmp-") &&
+ !e.endsWith(".info.json"),
);
if (audioFiles.length === 0) continue;
for (const f of audioFiles) {
diff --git a/common/controller/cleanExtraAudioFormats.ts b/common/controller/cleanExtraAudioFormats.ts
@@ -0,0 +1,74 @@
+import path from "node:path";
+import fs from "fs-extra";
+import type { Paths } from "../lib/paths";
+import { readChannelConfig } from "./channels";
+
+const { pathExists, readdir, remove } = fs;
+
+export type CleanExtraAudioFormatsOptions = {
+ channelSlug: string;
+ paths: Paths;
+ onLog?: (msg: string) => void;
+ signal?: AbortSignal;
+};
+
+export type CleanExtraAudioFormatsResult = {
+ inspected: number;
+ cleanedDirs: number;
+ removedFiles: number;
+};
+
+export async function cleanExtraAudioFormats({
+ channelSlug,
+ paths,
+ onLog,
+ signal,
+}: CleanExtraAudioFormatsOptions): Promise<CleanExtraAudioFormatsResult> {
+ const log = onLog ?? ((m: string) => console.log(m));
+ const config = await readChannelConfig(paths, channelSlug);
+ if (!config?.audioFormat) {
+ log(
+ `No audioFormat configured for ${channelSlug}; nothing to clean (set the channel's audio format first).`,
+ );
+ return { inspected: 0, cleanedDirs: 0, removedFiles: 0 };
+ }
+ const targetAudioFile = `audio.${config.audioFormat}`;
+ 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) {
+ log("Cancelled");
+ break;
+ }
+ const videoDir = path.join(dataDir, id);
+ const entries = await readdir(videoDir).catch(() => [] as string[]);
+ if (!entries.includes(targetAudioFile)) continue;
+ const extras = entries.filter(
+ (e) =>
+ e.startsWith("audio.") &&
+ e !== targetAudioFile &&
+ !e.includes(".tmp-") &&
+ !e.endsWith(".info.json"),
+ );
+ if (extras.length === 0) continue;
+ for (const f of extras) {
+ await remove(path.join(videoDir, f));
+ log(`Removed ${id}/${f}`);
+ removedFiles++;
+ }
+ cleanedDirs++;
+ }
+
+ log(
+ `Cleaned ${removedFiles} extra audio file(s) from ${cleanedDirs} of ${dirs.length} video dir(s) (kept ${targetAudioFile}).`,
+ );
+ return { inspected: dirs.length, cleanedDirs, removedFiles };
+}
diff --git a/common/controller/failedTranscodings.ts b/common/controller/failedTranscodings.ts
@@ -0,0 +1,50 @@
+import path from "node:path";
+import { readFile, rename, writeFile } from "node:fs/promises";
+import type { Paths } from "../lib/paths";
+
+export function failedTranscodingsFile(paths: Paths, slug: string): string {
+ return path.join(paths.channelsDir, slug, "failed-transcodings");
+}
+
+export async function loadFailedTranscodings(
+ paths: Paths,
+ slug: string,
+): Promise<string[]> {
+ try {
+ const raw = await readFile(failedTranscodingsFile(paths, slug), "utf8");
+ return raw.split("\n").filter(Boolean);
+ } catch {
+ return [];
+ }
+}
+
+export async function pruneFailedTranscodings(
+ 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,
+ };
+}
+
+export async function clearFailedTranscodings(
+ paths: Paths,
+ slug: string,
+): Promise<void> {
+ const file = failedTranscodingsFile(paths, slug);
+ const tmp = `${file}.tmp-${process.pid}`;
+ await writeFile(tmp, "");
+ await rename(tmp, file);
+}
diff --git a/common/controller/normalizeAll.ts b/common/controller/normalizeAll.ts
@@ -0,0 +1,80 @@
+// Walk every channel/video and ensure each transcript has a sibling
+// transcript.cues.json (compact, self-describing). Cheap on rebuilds
+// because normalizeTranscript short-circuits when cues.json is already
+// at least as new as the metadata + raw transcript.
+
+import path from "node:path";
+import { readdir } from "node:fs/promises";
+import pLimit from "p-limit";
+import { listChannels } from "./channels";
+import { normalizeTranscript } from "./normalizeTranscript";
+import type { Paths } from "../lib/paths";
+
+export type NormalizeAllOptions = {
+ paths: Paths;
+ onLog?: (msg: string) => void;
+ signal?: AbortSignal;
+ concurrency?: number;
+};
+
+export type NormalizeAllResult = {
+ wrote: number;
+ fresh: number;
+ skipped: number;
+ failed: number;
+};
+
+export async function normalizeAllTranscripts(
+ opts: NormalizeAllOptions,
+): Promise<NormalizeAllResult> {
+ const log = opts.onLog ?? ((m: string) => console.log(m));
+ const limit = pLimit(opts.concurrency ?? 8);
+ const channels = await listChannels(opts.paths);
+ const result: NormalizeAllResult = {
+ wrote: 0,
+ fresh: 0,
+ skipped: 0,
+ failed: 0,
+ };
+ for (const ch of channels) {
+ if (opts.signal?.aborted) break;
+ const dataDir = path.join(opts.paths.channelsDir, ch.slug, "data");
+ const videoIds = await readdir(dataDir).catch(() => [] as string[]);
+ log(`Normalize ${ch.slug}: ${videoIds.length} videos`);
+ let wrote = 0;
+ let fresh = 0;
+ let skipped = 0;
+ let failed = 0;
+ await Promise.all(
+ videoIds.map((id) =>
+ limit(async () => {
+ if (opts.signal?.aborted) return;
+ try {
+ const outcome = await normalizeTranscript({
+ videoDir: path.join(dataDir, id),
+ channelSlug: ch.slug,
+ configName: ch.config.name,
+ });
+ if (outcome.status === "wrote") wrote++;
+ else if (outcome.status === "fresh") fresh++;
+ else skipped++;
+ } catch (err) {
+ failed++;
+ log(` ! ${ch.slug}/${id}: ${(err as Error).message}`);
+ }
+ }),
+ ),
+ );
+ log(
+ ` ${ch.slug}: wrote=${wrote} fresh=${fresh} skipped=${skipped} failed=${failed}`,
+ );
+ result.wrote += wrote;
+ result.fresh += fresh;
+ result.skipped += skipped;
+ result.failed += failed;
+ }
+ log(
+ `Done. wrote=${result.wrote} fresh=${result.fresh} skipped=${result.skipped} failed=${result.failed}`,
+ );
+ return result;
+}
diff --git a/common/controller/normalizeTranscript.ts b/common/controller/normalizeTranscript.ts
@@ -0,0 +1,150 @@
+// Produce a self-describing compact transcript file alongside the raw
+// VTT/Whisper output so that downstream consumers (buildIndex, archives)
+// can skip re-parsing and re-summarising. Mirrors the TranscriptDetail
+// shape that buildIndex would emit, plus a `source` marker.
+
+import path from "node:path";
+import { readFile, rename, stat, writeFile } from "node:fs/promises";
+import { parseVtt, type Cue } from "../lib/vtt";
+import { parseWhisper } from "../lib/whisper";
+import { summarize, type RawMetadata } from "../lib/transcripts-server";
+import type { TranscriptDetail } from "../lib/transcripts";
+import {
+ CUES_JSON_FILENAME,
+ META_FILENAME,
+ VTT_FILENAME,
+ WHISPER_FILENAME,
+ pickIndexTranscript,
+ readVideoFiles,
+ type IndexTranscript,
+} from "../lib/videoStatus";
+
+export const CUES_FILE_VERSION = 1;
+
+export type NormalizedTranscript = TranscriptDetail & {
+ version: number;
+ source: IndexTranscript["kind"];
+};
+
+export type NormalizeOptions = {
+ videoDir: string;
+ channelSlug: string;
+ configName?: string;
+ log?: (msg: string) => void;
+ force?: boolean;
+};
+
+export type NormalizeOutcome =
+ | { status: "wrote"; cuesPath: string }
+ | { status: "fresh"; cuesPath: string }
+ | { status: "skipped"; reason: "no-raw-transcript" | "no-metadata" };
+
+async function mtimeMs(p: string): Promise<number | null> {
+ try {
+ return (await stat(p)).mtimeMs;
+ } catch {
+ return null;
+ }
+}
+
+export async function normalizeTranscript(
+ opts: NormalizeOptions,
+): Promise<NormalizeOutcome> {
+ const files = await readVideoFiles(opts.videoDir);
+ if (!files.hasMeta) return { status: "skipped", reason: "no-metadata" };
+ const picked = pickIndexTranscript(files);
+ if (!picked) return { status: "skipped", reason: "no-raw-transcript" };
+
+ const cuesPath = path.join(opts.videoDir, CUES_JSON_FILENAME);
+ const metaPath = path.join(opts.videoDir, META_FILENAME);
+ const transcriptPath = path.join(opts.videoDir, picked.filename);
+
+ const [cuesMs, metaStatMs, transcriptStatMs] = await Promise.all([
+ mtimeMs(cuesPath),
+ mtimeMs(metaPath),
+ mtimeMs(transcriptPath),
+ ]);
+
+ if (
+ !opts.force &&
+ cuesMs !== null &&
+ metaStatMs !== null &&
+ transcriptStatMs !== null &&
+ cuesMs >= metaStatMs &&
+ cuesMs >= transcriptStatMs
+ ) {
+ return { status: "fresh", cuesPath };
+ }
+
+ const metaRaw = await readFile(metaPath, "utf8");
+ const parsedMeta = JSON.parse(metaRaw) as RawMetadata;
+ const summary = summarize(
+ opts.channelSlug,
+ path.basename(opts.videoDir),
+ parsedMeta,
+ opts.configName,
+ );
+
+ const rawTranscript = await readFile(transcriptPath, "utf8");
+ let cues: Cue[];
+ try {
+ cues = picked.kind === "vtt" ? parseVtt(rawTranscript) : parseWhisper(rawTranscript);
+ } catch (err) {
+ throw new Error(
+ `Failed to parse ${picked.filename} for ${opts.channelSlug}/${path.basename(opts.videoDir)}: ${(err as Error).message}`,
+ );
+ }
+
+ const out: NormalizedTranscript = {
+ version: CUES_FILE_VERSION,
+ source: picked.kind,
+ ...summary,
+ cues,
+ };
+
+ const tmp = `${cuesPath}.tmp-${process.pid}`;
+ await writeFile(tmp, JSON.stringify(out));
+ await rename(tmp, cuesPath);
+ opts.log?.(
+ `Normalized ${opts.channelSlug}/${path.basename(opts.videoDir)} (${picked.kind}, ${cues.length} cues)`,
+ );
+ return { status: "wrote", cuesPath };
+}
+
+export async function readNormalizedTranscript(
+ cuesPath: string,
+): Promise<NormalizedTranscript | null> {
+ try {
+ const raw = await readFile(cuesPath, "utf8");
+ const parsed = JSON.parse(raw) as NormalizedTranscript;
+ if (typeof parsed.version !== "number") return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+// Helper: given a video dir, decide whether transcript.cues.json (if present)
+// is at least as new as metadata.info.json and the raw transcript file. Used
+// by buildIndex to know whether it can trust cues.json without re-parsing.
+export async function isCuesJsonFresh(
+ videoDir: string,
+): Promise<{ fresh: boolean; cuesPath: string }> {
+ const cuesPath = path.join(videoDir, CUES_JSON_FILENAME);
+ const metaPath = path.join(videoDir, META_FILENAME);
+ const vttPath = path.join(videoDir, VTT_FILENAME);
+ const whisperPath = path.join(videoDir, WHISPER_FILENAME);
+ const [cuesMs, metaMs, vttMs, whisperMs] = await Promise.all([
+ mtimeMs(cuesPath),
+ mtimeMs(metaPath),
+ mtimeMs(vttPath),
+ mtimeMs(whisperPath),
+ ]);
+ if (cuesMs === null || metaMs === null) return { fresh: false, cuesPath };
+ if (cuesMs < metaMs) return { fresh: false, cuesPath };
+ // Prefer whisper if present (matches pickIndexTranscript priority).
+ const rawMs = whisperMs ?? vttMs;
+ if (rawMs === null) return { fresh: false, cuesPath };
+ if (cuesMs < rawMs) return { fresh: false, cuesPath };
+ return { fresh: true, cuesPath };
+}
diff --git a/common/controller/removeFailedTranscodings.ts b/common/controller/removeFailedTranscodings.ts
@@ -0,0 +1,60 @@
+import path from "node:path";
+import fs from "fs-extra";
+import type { Paths } from "../lib/paths";
+import {
+ clearFailedTranscodings,
+ loadFailedTranscodings,
+} from "./failedTranscodings";
+
+const { pathExists, remove } = fs;
+
+export type RemoveFailedTranscodingsOptions = {
+ channelSlug: string;
+ paths: Paths;
+ onLog?: (msg: string) => void;
+ signal?: AbortSignal;
+};
+
+export type RemoveFailedTranscodingsResult = {
+ removed: number;
+ missing: number;
+};
+
+export async function removeFailedTranscodings({
+ channelSlug,
+ paths,
+ onLog,
+ signal,
+}: RemoveFailedTranscodingsOptions): Promise<RemoveFailedTranscodingsResult> {
+ const log = onLog ?? ((m: string) => console.log(m));
+ const dataDir = path.join(paths.channelsDir, channelSlug, "data");
+ const ids = await loadFailedTranscodings(paths, channelSlug);
+ if (ids.length === 0) {
+ log("No failed-transcodings to remove.");
+ return { removed: 0, missing: 0 };
+ }
+
+ let removed = 0;
+ let missing = 0;
+ for (const id of ids) {
+ if (signal?.aborted) {
+ log("Cancelled");
+ break;
+ }
+ const videoDir = path.join(dataDir, id);
+ if (!(await pathExists(videoDir))) {
+ log(`Skipping ${id}: directory does not exist`);
+ missing++;
+ continue;
+ }
+ await remove(videoDir);
+ log(`Removed ${id}`);
+ removed++;
+ }
+
+ await clearFailedTranscodings(paths, channelSlug);
+ log(
+ `Removed ${removed} video dir(s) listed in failed-transcodings; ${missing} were already gone.`,
+ );
+ return { removed, missing };
+}
diff --git a/common/controller/transcodeFailures.ts b/common/controller/transcodeFailures.ts
@@ -4,8 +4,12 @@ import pLimit from "p-limit";
import type { Paths } from "../lib/paths";
import type { AudioFormat } from "../lib/channelConfig";
import { transcodeAudio } from "./transcode";
+import {
+ failedTranscodingsFile,
+ pruneFailedTranscodings,
+} from "./failedTranscodings";
-const { pathExists, readdir, readFile, ensureFile } = fs;
+const { pathExists, readdir, readFile, appendFile, ensureFile } = fs;
const AUDIO_PREFERENCE = ["audio.m4a", "audio.opus", "audio.mp3"];
@@ -15,7 +19,11 @@ function pickSourceAudio(
): string | null {
const target = `audio.${targetFormat}`;
const candidates = entries.filter(
- (e) => e.startsWith("audio.") && e !== target && !e.includes(".tmp-"),
+ (e) =>
+ e.startsWith("audio.") &&
+ e !== target &&
+ !e.includes(".tmp-") &&
+ !e.endsWith(".info.json"),
);
if (candidates.length === 0) return null;
for (const preferred of AUDIO_PREFERENCE) {
@@ -54,12 +62,18 @@ export async function runTranscodeFailures({
const channelDir = path.join(paths.channelsDir, channelSlug);
const dataDir = path.join(channelDir, "data");
const failureListFile = path.join(channelDir, "failed-transcriptions");
+ const transcodeFailureFile = failedTranscodingsFile(paths, channelSlug);
const limit = pLimit(concurrency ?? 1);
await ensureFile(failureListFile);
+ await ensureFile(transcodeFailureFile);
const ids = (await readFile(failureListFile, "utf-8"))
.split("\n")
.filter(Boolean);
+ const previousTranscodeFailures = new Set(
+ (await readFile(transcodeFailureFile, "utf-8")).split("\n").filter(Boolean),
+ );
+ const succeededIds = new Set<string>();
let attempted = 0;
let succeeded = 0;
@@ -103,6 +117,7 @@ export async function runTranscodeFailures({
signal: signal ?? new AbortController().signal,
});
succeeded++;
+ succeededIds.add(id);
} catch (err) {
if (signal?.aborted) {
log(`Transcode ${id} cancelled`);
@@ -111,10 +126,24 @@ export async function runTranscodeFailures({
}
log(`FAILED TO TRANSCODE ${id}: ${String(err)}`);
failed++;
+ if (!previousTranscodeFailures.has(id)) {
+ await appendFile(transcodeFailureFile, `${id}\n`);
+ previousTranscodeFailures.add(id);
+ }
}
}),
),
);
+ if (succeededIds.size > 0) {
+ const { pruned, remaining } = await pruneFailedTranscodings(
+ transcodeFailureFile,
+ succeededIds,
+ );
+ log(
+ `Pruned ${pruned} from failed-transcodings; ${remaining} remain.`,
+ );
+ }
+
return { attempted, succeeded, failed, skipped };
}
diff --git a/common/controller/transcribeOne.ts b/common/controller/transcribeOne.ts
@@ -9,6 +9,7 @@ import {
TRANSCRIBE_KNOWN_PLACEHOLDERS,
getSettings,
} from "../lib/settings";
+import { normalizeTranscript } from "./normalizeTranscript";
const { pathExists, readdir, rename } = fs;
@@ -17,8 +18,10 @@ const AUDIO_PREFERENCE = ["audio.mp3", "audio.m4a", "audio.opus"];
async function resolveAudioFile(
videoDir: string,
requested: string,
+ strict: boolean,
): Promise<string | null> {
if (await pathExists(path.join(videoDir, requested))) return requested;
+ if (strict) return null;
const entries = await readdir(videoDir);
const candidates = entries.filter((e) => e.startsWith("audio."));
if (candidates.length === 0) return null;
@@ -52,6 +55,7 @@ export type TranscribeOneOptions = {
videoDir: string;
videoId: string;
audioFilename: string;
+ strictAudio?: boolean;
onLog?: (msg: string) => void;
signal?: AbortSignal;
};
@@ -69,8 +73,17 @@ export async function transcribeOneVideo(
return "already-exists";
}
- const resolvedAudio = await resolveAudioFile(opts.videoDir, opts.audioFilename);
+ const resolvedAudio = await resolveAudioFile(
+ opts.videoDir,
+ opts.audioFilename,
+ opts.strictAudio === true,
+ );
if (!resolvedAudio) {
+ if (opts.strictAudio) {
+ throw new Error(
+ `no ${opts.audioFilename} found in ${opts.videoDir} (strict audio mode)`,
+ );
+ }
throw new Error(`no audio file found in ${opts.videoDir}`);
}
if (resolvedAudio !== opts.audioFilename) {
@@ -101,5 +114,28 @@ export async function transcribeOneVideo(
log(
`Transcribe ${opts.videoId} done in ${((Date.now() - start) / 1000).toFixed(2)}s`,
);
+
+ const channelSlug = deriveChannelSlug(opts.paths, opts.videoDir);
+ if (channelSlug) {
+ try {
+ await normalizeTranscript({
+ videoDir: opts.videoDir,
+ channelSlug,
+ log,
+ });
+ } catch (err) {
+ log(
+ `Warning: failed to write transcript.cues.json for ${opts.videoId}: ${(err as Error).message}`,
+ );
+ }
+ }
+
return "transcribed";
}
+
+function deriveChannelSlug(paths: Paths, videoDir: string): string | null {
+ const rel = path.relative(paths.channelsDir, videoDir);
+ if (!rel || rel.startsWith("..")) return null;
+ const [slug] = rel.split(path.sep);
+ return slug || null;
+}
diff --git a/common/controller/whisperBatch.ts b/common/controller/whisperBatch.ts
@@ -2,6 +2,7 @@ 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 { transcribeOneVideo } from "./transcribeOne";
import { pruneFailedTranscriptions } from "./failedTranscriptions";
@@ -15,6 +16,8 @@ export type WhisperBatchOptions = {
mode: WhisperBatchMode;
concurrency?: number;
audioFilename?: string;
+ audioFormat?: AudioFormat;
+ strictAudioFormat?: boolean;
reverse?: boolean;
onLog?: (msg: string) => void;
signal?: AbortSignal;
@@ -32,7 +35,9 @@ export async function runWhisperBatch({
paths,
mode,
concurrency,
- audioFilename = "audio.mp3",
+ audioFilename,
+ audioFormat,
+ strictAudioFormat = false,
reverse = false,
onLog,
signal,
@@ -42,6 +47,9 @@ export async function runWhisperBatch({
const dataDir = path.join(channelDir, "data");
const failureListFile = path.join(channelDir, "failed-transcriptions");
const limit = pLimit(concurrency ?? paths.parallelTranscribeLimit);
+ const resolvedAudioFilename =
+ audioFilename ?? (audioFormat ? `audio.${audioFormat}` : "audio.mp3");
+ const strict = strictAudioFormat && audioFormat !== undefined;
await ensureFile(failureListFile);
@@ -93,13 +101,24 @@ export async function runWhisperBatch({
}
return;
}
+ if (
+ strict &&
+ !(await pathExists(path.join(videoPath, resolvedAudioFilename)))
+ ) {
+ log(
+ `Skipping ${videoDir}: no ${resolvedAudioFilename} (strict mode)`,
+ );
+ skipped++;
+ return;
+ }
attempted++;
try {
await transcribeOneVideo({
paths,
videoDir: videoPath,
videoId: videoDir,
- audioFilename,
+ audioFilename: resolvedAudioFilename,
+ strictAudio: strict,
onLog: log,
signal,
});
diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts
@@ -10,6 +10,7 @@ export type ChannelConfig = {
name?: string;
url?: string;
audioFormat?: AudioFormat;
+ keepSourceVideo?: boolean;
cookiesFromBrowser?: string;
ytdlpExtraArgs?: string[];
lastSyncedAt?: string;
@@ -47,6 +48,9 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null {
) {
config.audioFormat = r.audioFormat;
}
+ if (typeof r.keepSourceVideo === "boolean") {
+ config.keepSourceVideo = r.keepSourceVideo;
+ }
if (typeof r.cookiesFromBrowser === "string") {
config.cookiesFromBrowser = r.cookiesFromBrowser;
}
diff --git a/common/lib/videoStatus.ts b/common/lib/videoStatus.ts
@@ -5,6 +5,7 @@ export type VideoFiles = {
hasMeta: boolean;
hasYtVtt: boolean;
hasWhisper: boolean;
+ hasCuesJson: boolean;
isUntranscribable: boolean;
audioFiles: string[];
};
@@ -15,6 +16,7 @@ export type IndexTranscript =
export const VTT_FILENAME = "transcript.en.vtt";
export const WHISPER_FILENAME = "transcript.json";
+export const CUES_JSON_FILENAME = "transcript.cues.json";
export const META_FILENAME = "metadata.info.json";
export async function readVideoFiles(
@@ -25,8 +27,12 @@ export async function readVideoFiles(
const hasMeta = entries.includes(META_FILENAME);
const hasYtVtt = entries.includes(VTT_FILENAME);
const hasWhisper = entries.includes(WHISPER_FILENAME);
+ const hasCuesJson = entries.includes(CUES_JSON_FILENAME);
const audioFiles = entries.filter(
- (e) => e.startsWith("audio.") && !e.includes(".tmp-"),
+ (e) =>
+ e.startsWith("audio.") &&
+ !e.includes(".tmp-") &&
+ !e.endsWith(".info.json"),
);
let isUntranscribable = false;
if (hasWhisper && opts.checkUntranscribable) {
@@ -39,7 +45,14 @@ export async function readVideoFiles(
isUntranscribable = false;
}
}
- return { hasMeta, hasYtVtt, hasWhisper, isUntranscribable, audioFiles };
+ return {
+ hasMeta,
+ hasYtVtt,
+ hasWhisper,
+ hasCuesJson,
+ isUntranscribable,
+ audioFiles,
+ };
}
export function pickIndexTranscript(files: VideoFiles): IndexTranscript | null {
diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts
@@ -94,7 +94,7 @@ function handlingArgs(config: ChannelConfig): string[] {
}
// 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 [
+ const args = [
"--write-info-json",
"-f",
"bestaudio/worst",
@@ -102,6 +102,8 @@ function handlingArgs(config: ChannelConfig): string[] {
"--audio-format",
fmt,
];
+ if (config.keepSourceVideo) args.push("-k");
+ return args;
}
const OUTPUT_ARGS: string[] = [
@@ -334,6 +336,7 @@ async function downloadOneAudio(opts: RunYtdlpOpts): Promise<void> {
"-x",
"--audio-format",
fmt,
+ ...(opts.channelConfig.keepSourceVideo ? ["-k"] : []),
...configArgs(opts.channelConfig),
...(opts.extraYtdlpArgs ?? []),
"--",
diff --git a/editor/app/api/channels/[slug]/videos/[id]/files/[name]/route.ts b/editor/app/api/channels/[slug]/videos/[id]/files/[name]/route.ts
@@ -0,0 +1,137 @@
+import path from "node:path";
+import { createReadStream } from "node:fs";
+import { stat } from "node:fs/promises";
+import { NextResponse } from "next/server";
+import { Readable } from "node:stream";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+
+export const dynamic = "force-dynamic";
+
+const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
+
+const MIME_BY_EXT: Record<string, string> = {
+ mp3: "audio/mpeg",
+ m4a: "audio/mp4",
+ aac: "audio/aac",
+ ogg: "audio/ogg",
+ opus: "audio/ogg",
+ wav: "audio/wav",
+ flac: "audio/flac",
+ mp4: "video/mp4",
+ webm: "video/webm",
+ mkv: "video/x-matroska",
+ mov: "video/quicktime",
+ vtt: "text/vtt; charset=utf-8",
+ json: "application/json; charset=utf-8",
+};
+
+function contentTypeFor(name: string): string {
+ const dot = name.lastIndexOf(".");
+ const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
+ return MIME_BY_EXT[ext] ?? "application/octet-stream";
+}
+
+function parseRange(
+ header: string | null,
+ size: number,
+): { start: number; end: number } | null {
+ if (!header) return null;
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
+ if (!m) return null;
+ const startRaw = m[1];
+ const endRaw = m[2];
+ let start: number;
+ let end: number;
+ if (startRaw === "" && endRaw !== "") {
+ // suffix range: bytes=-N → last N bytes
+ const n = Number.parseInt(endRaw, 10);
+ if (!Number.isFinite(n) || n <= 0) return null;
+ start = Math.max(0, size - n);
+ end = size - 1;
+ } else if (startRaw !== "") {
+ start = Number.parseInt(startRaw, 10);
+ end = endRaw === "" ? size - 1 : Number.parseInt(endRaw, 10);
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
+ } else {
+ return null;
+ }
+ if (start < 0 || end >= size || start > end) return null;
+ return { start, end };
+}
+
+export async function GET(
+ request: Request,
+ {
+ params,
+ }: { params: Promise<{ slug: string; id: string; name: string }> },
+) {
+ const { slug, id, name } = await params;
+ if (!SAFE_SEGMENT.test(slug) || !SAFE_SEGMENT.test(name)) {
+ return NextResponse.json({ error: "Invalid path" }, { status: 400 });
+ }
+ // Video IDs may include URL-encoded characters; decode then validate.
+ const decodedId = decodeURIComponent(id);
+ if (!SAFE_SEGMENT.test(decodedId)) {
+ return NextResponse.json({ error: "Invalid id" }, { status: 400 });
+ }
+
+ const paths = getPaths();
+ const fullPath = path.resolve(
+ paths.channelsDir,
+ slug,
+ "data",
+ decodedId,
+ name,
+ );
+ const expectedPrefix =
+ path.resolve(paths.channelsDir, slug, "data", decodedId) + path.sep;
+ if (!fullPath.startsWith(expectedPrefix)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ let stats;
+ try {
+ stats = await stat(fullPath);
+ } catch {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ if (!stats.isFile()) {
+ return NextResponse.json({ error: "Not a file" }, { status: 404 });
+ }
+
+ const size = stats.size;
+ const type = contentTypeFor(name);
+ const rangeHeader = request.headers.get("range");
+ const range = parseRange(rangeHeader, size);
+
+ if (range) {
+ const { start, end } = range;
+ const length = end - start + 1;
+ const stream = Readable.toWeb(
+ createReadStream(fullPath, { start, end }),
+ ) as ReadableStream<Uint8Array>;
+ return new Response(stream, {
+ status: 206,
+ headers: {
+ "Content-Type": type,
+ "Content-Length": String(length),
+ "Content-Range": `bytes ${start}-${end}/${size}`,
+ "Accept-Ranges": "bytes",
+ "Cache-Control": "no-store",
+ },
+ });
+ }
+
+ const stream = Readable.toWeb(
+ createReadStream(fullPath),
+ ) as ReadableStream<Uint8Array>;
+ return new Response(stream, {
+ status: 200,
+ headers: {
+ "Content-Type": type,
+ "Content-Length": String(size),
+ "Accept-Ranges": "bytes",
+ "Cache-Control": "no-store",
+ },
+ });
+}
diff --git a/editor/app/build/buildAction.ts b/editor/app/build/buildAction.ts
@@ -2,6 +2,12 @@
import { revalidatePath } from "next/cache";
import { buildIndex } from "yt-dlp-transcript-common/controller/buildIndex";
+import { normalizeAllTranscripts } from "yt-dlp-transcript-common/controller/normalizeAll";
+import {
+ archiveTranscripts,
+ archiveCombinedTranscripts,
+ type ArchiveBuildOptions,
+} from "yt-dlp-transcript-common/controller/archiveTranscripts";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import {
runManagedCommand,
@@ -46,3 +52,47 @@ export async function buildExportAction(
},
});
}
+
+export async function normalizeTranscriptsAction(
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "normalize-transcripts",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ await normalizeAllTranscripts({ paths, onLog, signal });
+ },
+ });
+}
+
+export async function archiveTranscriptsAction(
+ build: Partial<ArchiveBuildOptions>,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "archive-transcripts",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ await archiveTranscripts({ paths, onLog, signal, build });
+ },
+ });
+}
+
+export async function archiveCombinedTranscriptsAction(
+ build: Partial<ArchiveBuildOptions>,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "archive-combined-transcripts",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ await archiveCombinedTranscripts({ paths, onLog, signal, build });
+ },
+ });
+}
diff --git a/editor/app/build/components/ArchiveOptionsControl.tsx b/editor/app/build/components/ArchiveOptionsControl.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import {
+ ARCHIVE_FORMATS,
+ type ArchiveBuildOptions,
+ type ArchiveFormat,
+} from "yt-dlp-transcript-common/controller/archiveTranscripts";
+
+type Props = {
+ value: ArchiveBuildOptions;
+ onChange: (next: ArchiveBuildOptions) => void;
+ // Used to disambiguate aria-labels when multiple instances are rendered
+ // on the same page (per-channel vs combined).
+ actionLabel: string;
+};
+
+const inputClass =
+ "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";
+
+export function ArchiveOptionsControl({ value, onChange, actionLabel }: Props) {
+ return (
+ <div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
+ <label className="flex items-center gap-1">
+ <span>format</span>
+ <select
+ aria-label={`archive format for ${actionLabel}`}
+ value={value.format}
+ onChange={(e) =>
+ onChange({ ...value, format: e.target.value as ArchiveFormat })
+ }
+ className={inputClass}
+ >
+ {ARCHIVE_FORMATS.map((f) => (
+ <option key={f} value={f}>
+ {f}
+ </option>
+ ))}
+ </select>
+ </label>
+
+ <label className="flex items-center gap-1">
+ <span>level</span>
+ <select
+ aria-label={`compression level for ${actionLabel}`}
+ value={value.compressionLevel}
+ onChange={(e) =>
+ onChange({
+ ...value,
+ compressionLevel: Number(e.target.value),
+ })
+ }
+ className={inputClass}
+ >
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
+ <option key={n} value={n}>
+ {n}
+ </option>
+ ))}
+ </select>
+ </label>
+
+ <label className="flex items-center gap-1">
+ <input
+ type="checkbox"
+ aria-label={`include per-video metadata for ${actionLabel}`}
+ checked={value.includeMetadata}
+ onChange={(e) =>
+ onChange({ ...value, includeMetadata: e.target.checked })
+ }
+ />
+ <span>metadata</span>
+ </label>
+
+ <label className="flex items-center gap-1">
+ <input
+ type="checkbox"
+ aria-label={`pretty-print json for ${actionLabel}`}
+ checked={value.prettyPrint}
+ onChange={(e) =>
+ onChange({ ...value, prettyPrint: e.target.checked })
+ }
+ />
+ <span>pretty</span>
+ </label>
+ </div>
+ );
+}
diff --git a/editor/app/build/components/BuildButtons.tsx b/editor/app/build/components/BuildButtons.tsx
@@ -2,9 +2,20 @@
import { useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import {
+ DEFAULT_ARCHIVE_OPTIONS,
+ type ArchiveBuildOptions,
+} from "yt-dlp-transcript-common/controller/archiveTranscripts";
import { QueueControl } from "../../components/QueueControl";
import { cancelJobAction } from "../../jobs/actions";
-import { buildExportAction, buildIndexAction } from "../buildAction";
+import {
+ archiveCombinedTranscriptsAction,
+ archiveTranscriptsAction,
+ buildExportAction,
+ buildIndexAction,
+ normalizeTranscriptsAction,
+} from "../buildAction";
+import { ArchiveOptionsControl } from "./ArchiveOptionsControl";
type Props = {
existingQueues: string[];
@@ -15,6 +26,15 @@ const DEFAULT_BUILD_QUEUE = "build";
export function BuildButtons({ existingQueues }: Props) {
const [indexQueue, setIndexQueue] = useState(DEFAULT_BUILD_QUEUE);
const [exportQueue, setExportQueue] = useState(DEFAULT_BUILD_QUEUE);
+ const [normalizeQueue, setNormalizeQueue] = useState(DEFAULT_BUILD_QUEUE);
+ const [archiveQueue, setArchiveQueue] = useState(DEFAULT_BUILD_QUEUE);
+ const [combinedQueue, setCombinedQueue] = useState(DEFAULT_BUILD_QUEUE);
+ const [archiveOptions, setArchiveOptions] = useState<ArchiveBuildOptions>(
+ DEFAULT_ARCHIVE_OPTIONS,
+ );
+ const [combinedOptions, setCombinedOptions] = useState<ArchiveBuildOptions>(
+ DEFAULT_ARCHIVE_OPTIONS,
+ );
return (
<div className="flex flex-col gap-8">
@@ -69,6 +89,107 @@ export function BuildButtons({ existingQueues }: Props) {
}
/>
</section>
+
+ <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">Normalize transcripts</h2>
+ <p className="text-sm text-zinc-500">
+ Walks every video and writes{" "}
+ <code>transcript.cues.json</code> next to each raw{" "}
+ <code>.vtt</code>/<code>.json</code>. Idempotent — skips entries
+ whose cues file is already up to date.
+ </p>
+ </div>
+ <StreamActionLog
+ trigger={() => normalizeTranscriptsAction(normalizeQueue)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Normalize transcripts"
+ runningLabel="Normalizing transcripts…"
+ extraControls={
+ <QueueControl
+ value={normalizeQueue}
+ onChange={setNormalizeQueue}
+ defaultQueueKey={DEFAULT_BUILD_QUEUE}
+ existingQueues={existingQueues}
+ actionLabel="Normalize transcripts"
+ />
+ }
+ />
+ </section>
+
+ <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">Build transcript archives</h2>
+ <p className="text-sm text-zinc-500">
+ Produces one <code><channel>.tar.gz</code> per channel
+ under <code>transcripts/export/archives/</code>, containing each
+ video's compact <code>transcript.cues.json</code> plus a{" "}
+ <code>channel.json</code> manifest. Normalizes missing cues on
+ demand.
+ </p>
+ </div>
+ <StreamActionLog
+ trigger={() => archiveTranscriptsAction(archiveOptions, archiveQueue)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Build archives"
+ runningLabel="Building archives…"
+ extraControls={
+ <div className="flex flex-wrap items-center gap-3">
+ <ArchiveOptionsControl
+ value={archiveOptions}
+ onChange={setArchiveOptions}
+ actionLabel="Build archives"
+ />
+ <QueueControl
+ value={archiveQueue}
+ onChange={setArchiveQueue}
+ defaultQueueKey={DEFAULT_BUILD_QUEUE}
+ existingQueues={existingQueues}
+ actionLabel="Build archives"
+ />
+ </div>
+ }
+ />
+ </section>
+
+ <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">
+ Build combined transcript archive
+ </h2>
+ <p className="text-sm text-zinc-500">
+ Produces a single{" "}
+ <code>all-transcripts.tar.gz</code> under{" "}
+ <code>transcripts/export/archives/</code> containing every
+ channel plus a top-level <code>manifest.json</code> index.
+ Normalizes missing cues on demand.
+ </p>
+ </div>
+ <StreamActionLog
+ trigger={() =>
+ archiveCombinedTranscriptsAction(combinedOptions, combinedQueue)
+ }
+ cancelAction={cancelJobAction}
+ buttonLabel="Build combined archive"
+ runningLabel="Building combined archive…"
+ extraControls={
+ <div className="flex flex-wrap items-center gap-3">
+ <ArchiveOptionsControl
+ value={combinedOptions}
+ onChange={setCombinedOptions}
+ actionLabel="Build combined archive"
+ />
+ <QueueControl
+ value={combinedQueue}
+ onChange={setCombinedQueue}
+ defaultQueueKey={DEFAULT_BUILD_QUEUE}
+ existingQueues={existingQueues}
+ actionLabel="Build combined archive"
+ />
+ </div>
+ }
+ />
+ </section>
</div>
);
}
diff --git a/editor/app/channels/[slug]/components/PipelinePanel.tsx b/editor/app/channels/[slug]/components/PipelinePanel.tsx
@@ -1,184 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import { QueueControl } from "../../../components/QueueControl";
-import { cancelJobAction } from "../../../jobs/actions";
-import {
- downloadAction,
- downloadMissingAction,
- storePlaylistAction,
- syncAction,
-} from "../pipelineActions";
-
-type Props = {
- slug: string;
- hasUrl: boolean;
- defaultQueueKey: string;
- existingQueues: string[];
-};
-
-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 (
- <p className="text-sm text-zinc-500">
- Set a <code>url</code> in the configuration above to enable the
- pipeline.
- </p>
- );
- }
- return (
- <div className="flex flex-col gap-6">
- <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, 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">
- <Heading
- 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, 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 for YouTube, or audio.* / transcript.json for transcribe) 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">
- <Heading
- title="Sync"
- desc="Quick incremental fetch using --lazy-playlist + --break-on-existing. Run regularly to grab the newest videos."
- />
- <StreamActionLog
- trigger={() => syncAction(slug, syncQueue)}
- cancelAction={cancelJobAction}
- buttonLabel="Sync"
- runningLabel="Syncing…"
- extraControls={
- <QueueControl
- value={syncQueue}
- onChange={setSyncQueue}
- defaultQueueKey={defaultQueueKey}
- existingQueues={existingQueues}
- actionLabel="Sync"
- />
- }
- />
- </div>
- </div>
- );
-}
-
-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]/components/PipelineStageCard.tsx b/editor/app/channels/[slug]/components/PipelineStageCard.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import type { ReactNode } from "react";
+import type { StageTone } from "../lib/stageStatus";
+
+type Props = {
+ id: string;
+ title: string;
+ summary: string;
+ defaultOpen: boolean;
+ tone?: StageTone;
+ pending?: number;
+ failed?: number;
+ running?: boolean;
+ children: ReactNode;
+};
+
+const TONE_BORDER: Record<StageTone, string> = {
+ neutral: "border-zinc-200 dark:border-zinc-800",
+ attention: "border-amber-300 dark:border-amber-800",
+ danger: "border-red-300 dark:border-red-800",
+ running: "border-blue-300 dark:border-blue-800",
+ ok: "border-emerald-300 dark:border-emerald-800",
+};
+
+const TONE_BADGE: Record<StageTone, string> = {
+ neutral:
+ "bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-700",
+ attention:
+ "bg-amber-50 dark:bg-amber-950 text-amber-800 dark:text-amber-200 border-amber-200 dark:border-amber-800",
+ danger:
+ "bg-red-50 dark:bg-red-950 text-red-800 dark:text-red-200 border-red-200 dark:border-red-800",
+ running:
+ "bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800",
+ ok: "bg-emerald-50 dark:bg-emerald-950 text-emerald-800 dark:text-emerald-200 border-emerald-200 dark:border-emerald-800",
+};
+
+export function PipelineStageCard({
+ id,
+ title,
+ summary,
+ defaultOpen,
+ tone = "neutral",
+ pending = 0,
+ failed = 0,
+ running = false,
+ children,
+}: Props) {
+ const isDanger = id === "danger" || title === "Danger zone";
+ const titleColor = isDanger ? "text-red-700 dark:text-red-400" : "";
+ return (
+ <details
+ id={`stage-${id}`}
+ open={defaultOpen}
+ aria-label={`${title} stage`}
+ className={`group rounded-md border ${TONE_BORDER[tone]} bg-white dark:bg-zinc-900 scroll-mt-32`}
+ >
+ <summary
+ className="cursor-pointer list-none flex items-center justify-between gap-3 px-4 py-3 select-none rounded-md hover:bg-zinc-50 dark:hover:bg-zinc-800/50"
+ aria-label={`${title} stage summary`}
+ >
+ <div className="flex items-baseline gap-3 min-w-0">
+ <h2 className={`text-lg font-semibold ${titleColor}`}>{title}</h2>
+ <span className="text-sm text-zinc-500 truncate">{summary}</span>
+ </div>
+ <div className="flex items-center gap-2 flex-shrink-0">
+ {running && (
+ <span
+ className={`text-xs uppercase tracking-wide rounded-full border px-2 py-0.5 ${TONE_BADGE.running}`}
+ aria-label={`${title} running`}
+ >
+ running
+ </span>
+ )}
+ {pending > 0 && (
+ <span
+ className={`text-xs rounded-full border px-2 py-0.5 ${TONE_BADGE.attention}`}
+ aria-label={`${title} pending count`}
+ >
+ {pending}
+ </span>
+ )}
+ {failed > 0 && (
+ <span
+ className={`text-xs rounded-full border px-2 py-0.5 ${TONE_BADGE.danger}`}
+ aria-label={`${title} failed count`}
+ >
+ {failed} failed
+ </span>
+ )}
+ <Chevron />
+ </div>
+ </summary>
+ <div className="px-4 pb-4 pt-1 flex flex-col gap-4 border-t border-zinc-100 dark:border-zinc-800">
+ {children}
+ </div>
+ </details>
+ );
+}
+
+function Chevron() {
+ return (
+ <svg
+ aria-hidden="true"
+ className="w-4 h-4 text-zinc-400 transition-transform group-open:rotate-90"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ >
+ <path
+ fillRule="evenodd"
+ d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 0 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02z"
+ clipRule="evenodd"
+ />
+ </svg>
+ );
+}
diff --git a/editor/app/channels/[slug]/components/StageBadge.tsx b/editor/app/channels/[slug]/components/StageBadge.tsx
@@ -0,0 +1,44 @@
+import type { StageStatus, StageTone } from "../lib/stageStatus";
+
+const TONE_CLASS: Record<StageTone, string> = {
+ neutral:
+ "border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400",
+ attention:
+ "border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950 text-amber-800 dark:text-amber-200 hover:border-amber-500",
+ danger:
+ "border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950 text-red-800 dark:text-red-200 hover:border-red-500",
+ running:
+ "border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 hover:border-blue-500",
+ ok: "border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-950 text-emerald-800 dark:text-emerald-200 hover:border-emerald-500",
+};
+
+export function StageBadge({ status }: { status: StageStatus }) {
+ const countLabel = formatCount(status);
+ return (
+ <a
+ href={`#stage-${status.id}`}
+ aria-label={`Jump to ${status.title} stage`}
+ className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${TONE_CLASS[status.tone]}`}
+ >
+ {status.running && (
+ <span
+ aria-hidden="true"
+ className="inline-block w-2 h-2 rounded-full bg-blue-500 animate-pulse"
+ />
+ )}
+ <span>{status.title}</span>
+ {countLabel && (
+ <span className="font-mono opacity-80">{countLabel}</span>
+ )}
+ </a>
+ );
+}
+
+function formatCount(s: StageStatus): string | null {
+ if (s.running) return "running";
+ const parts: string[] = [];
+ if (s.pending > 0) parts.push(String(s.pending));
+ if (s.failed > 0) parts.push(`${s.failed} failed`);
+ if (parts.length === 0) return null;
+ return parts.join(" · ");
+}
diff --git a/editor/app/channels/[slug]/components/StatusHeader.tsx b/editor/app/channels/[slug]/components/StatusHeader.tsx
@@ -0,0 +1,89 @@
+import Link from "next/link";
+import type { StageId, StageStatus } from "../lib/stageStatus";
+import { RefreshSnapshotButton } from "./RefreshSnapshotButton";
+import { StageBadge } from "./StageBadge";
+
+const BADGE_ORDER: StageId[] = [
+ "playlist",
+ "download",
+ "transcode",
+ "transcribe",
+ "cleanup",
+ "diagnostics",
+];
+
+type Props = {
+ slug: string;
+ channelName: string;
+ generatedAt: string;
+ fresh: boolean;
+ stages: Record<StageId, StageStatus>;
+ transcodeApplies: boolean;
+};
+
+export function StatusHeader({
+ slug,
+ channelName,
+ generatedAt,
+ fresh,
+ stages,
+ transcodeApplies,
+}: Props) {
+ const badgeIds = BADGE_ORDER.filter(
+ (id) => transcodeApplies || id !== "transcode",
+ );
+ return (
+ <header
+ aria-label="channel status header"
+ className="sticky top-0 z-10 -mx-4 md:-mx-6 px-4 md:px-6 py-3 bg-zinc-50/95 dark:bg-zinc-950/95 backdrop-blur border-b border-zinc-200 dark:border-zinc-800"
+ >
+ <div className="flex flex-col gap-3">
+ <div className="flex items-start justify-between gap-3 flex-wrap">
+ <div className="flex flex-col gap-1 min-w-0">
+ <nav 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 aria-hidden="true">/</span>
+ <span className="font-mono">{slug}</span>
+ </nav>
+ <h1 className="text-2xl font-semibold truncate">{channelName}</h1>
+ <p className="text-xs text-zinc-500">
+ {fresh ? (
+ "Report generated just now."
+ ) : (
+ <>
+ Report generated{" "}
+ <time dateTime={generatedAt}>
+ {formatTimestamp(generatedAt)}
+ </time>
+ .
+ </>
+ )}
+ </p>
+ </div>
+ <RefreshSnapshotButton slug={slug} />
+ </div>
+ <div
+ aria-label="pipeline stage badges"
+ className="flex flex-wrap gap-2"
+ >
+ {badgeIds.map((id) => (
+ <StageBadge key={id} status={stages[id]} />
+ ))}
+ </div>
+ </div>
+ </header>
+ );
+}
+
+function formatTimestamp(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString();
+ } catch {
+ return iso;
+ }
+}
diff --git a/editor/app/channels/[slug]/components/WhisperPanel.tsx b/editor/app/channels/[slug]/components/WhisperPanel.tsx
@@ -1,288 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import {
- AUDIO_FORMAT_VALUES,
- type AudioFormat,
-} from "yt-dlp-transcript-common/lib/channelConfig";
-import { ConcurrencyControl } from "../../../components/ConcurrencyControl";
-import { QueueControl } from "../../../components/QueueControl";
-import { cancelJobAction } from "../../../jobs/actions";
-import { VideoIdList } from "./VideoIdList";
-import {
- cleanAudioAction,
- retryFailuresAction,
- transcodeFailuresAction,
- transcribeMissingAction,
- verifyAction,
- type VerifyResult,
-} from "../whisperActions";
-
-type Props = {
- slug: string;
- existingQueues: string[];
- failedVideoIds: string[];
- defaultConcurrency: number;
-};
-
-function parseConcurrency(s: string): number | undefined {
- const trimmed = s.trim();
- if (trimmed === "") return undefined;
- const n = Number(trimmed);
- if (!Number.isFinite(n) || n < 1) return undefined;
- return Math.floor(n);
-}
-
-export function WhisperPanel({
- slug,
- existingQueues,
- failedVideoIds,
- defaultConcurrency,
-}: Props) {
- const defaultQueueKey = `channel:${slug}`;
- const [missingQueue, setMissingQueue] = useState(defaultQueueKey);
- const [missingConcurrency, setMissingConcurrency] = useState("");
- const [missingReverse, setMissingReverse] = useState(false);
- const [retryQueue, setRetryQueue] = useState(defaultQueueKey);
- const [retryConcurrency, setRetryConcurrency] = useState("");
- const [transcodeFormat, setTranscodeFormat] = useState<AudioFormat>("mp3");
- const [transcodeQueue, setTranscodeQueue] = useState(defaultQueueKey);
- const [cleanQueue, setCleanQueue] = useState(defaultQueueKey);
- return (
- <div className="flex flex-col gap-6">
- <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,
- missingQueue,
- parseConcurrency(missingConcurrency),
- missingReverse,
- )
- }
- cancelAction={cancelJobAction}
- buttonLabel="Transcribe missing"
- runningLabel="Transcribing…"
- extraControls={
- <>
- <QueueControl
- value={missingQueue}
- onChange={setMissingQueue}
- defaultQueueKey={defaultQueueKey}
- existingQueues={existingQueues}
- actionLabel="Transcribe missing"
- />
- <ConcurrencyControl
- value={missingConcurrency}
- onChange={setMissingConcurrency}
- defaultLimit={defaultConcurrency}
- actionLabel="Transcribe missing"
- />
- <label className="flex items-center gap-1 text-xs text-zinc-500">
- <input
- type="checkbox"
- checked={missingReverse}
- onChange={(e) => setMissingReverse(e.target.checked)}
- aria-label="reverse order for Transcribe missing"
- />
- Reverse
- </label>
- </>
- }
- />
- </div>
- <div className="flex flex-col gap-2">
- <Heading
- title="Retry failures"
- desc="Re-run whisper for any video listed below; successes will be removed from the list."
- />
- <StreamActionLog
- trigger={() =>
- retryFailuresAction(
- slug,
- retryQueue,
- parseConcurrency(retryConcurrency),
- )
- }
- cancelAction={cancelJobAction}
- buttonLabel="Retry failures"
- runningLabel="Retrying…"
- extraControls={
- <>
- <QueueControl
- value={retryQueue}
- onChange={setRetryQueue}
- defaultQueueKey={defaultQueueKey}
- existingQueues={existingQueues}
- actionLabel="Retry failures"
- />
- <ConcurrencyControl
- value={retryConcurrency}
- onChange={setRetryConcurrency}
- defaultLimit={defaultConcurrency}
- actionLabel="Retry failures"
- />
- </>
- }
- />
- <VideoIdList
- slug={slug}
- ids={failedVideoIds}
- ariaLabel="failed transcriptions list"
- emptyAriaLabel="failed transcriptions empty"
- emptyMessage="No failed transcriptions."
- itemAriaLabel={(id) => `failed transcription ${id}`}
- />
- </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 (whisper output). YouTube videos with only auto-sub .vtt files have no audio and are skipped automatically."
- />
- <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">
- <Heading
- title="Verify"
- desc="Cross-check the archive against transcripts on disk."
- />
- <VerifyButton slug={slug} />
- </div>
- </div>
- );
-}
-
-function VerifyButton({ slug }: { slug: string }) {
- const [running, setRunning] = useState(false);
- const [result, setResult] = useState<VerifyResult | null>(null);
-
- async function handleClick() {
- setRunning(true);
- setResult(null);
- try {
- const r = await verifyAction(slug);
- setResult(r);
- } finally {
- setRunning(false);
- }
- }
-
- return (
- <div className="flex flex-col gap-2">
- <button
- type="button"
- onClick={handleClick}
- disabled={running}
- 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"
- >
- {running ? "Verifying…" : "Verify"}
- </button>
- {result?.ok === false && (
- <div
- role="alert"
- className="rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300"
- >
- {result.error}
- </div>
- )}
- {result?.ok === true && (
- <div
- aria-label="verify result"
- className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm"
- >
- <Bucket label="Duplicates" items={result.duplicates} />
- <Bucket label="Missing" items={result.missing} />
- </div>
- )}
- </div>
- );
-}
-
-function Bucket({ label, items }: { label: string; items: string[] }) {
- return (
- <div className="rounded border border-zinc-200 dark:border-zinc-800 p-3">
- <div className="text-xs uppercase tracking-wide text-zinc-500">
- {label} ({items.length})
- </div>
- {items.length === 0 ? (
- <p className="text-zinc-500 mt-1">None</p>
- ) : (
- <ul className="mt-1 font-mono text-xs space-y-0.5">
- {items.map((id) => (
- <li key={id}>{id}</li>
- ))}
- </ul>
- )}
- </div>
- );
-}
-
-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]/components/stages/CleanupStage.tsx b/editor/app/channels/[slug]/components/stages/CleanupStage.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { useState } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { QueueControl } from "../../../../components/QueueControl";
+import { cancelJobAction } from "../../../../jobs/actions";
+import {
+ cleanAudioAction,
+ cleanExtraAudioFormatsAction,
+} from "../../whisperActions";
+import { VideoIdList } from "../VideoIdList";
+
+type Props = {
+ slug: string;
+ existingQueues: string[];
+ multipleAudioFormatIds: string[];
+ transcodeApplies: boolean;
+};
+
+export function CleanupStage({
+ slug,
+ existingQueues,
+ multipleAudioFormatIds,
+ transcodeApplies,
+}: Props) {
+ const defaultQueueKey = `channel:${slug}`;
+ const [cleanQueue, setCleanQueue] = useState(defaultQueueKey);
+ return (
+ <div className="flex flex-col gap-6">
+ <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 (whisper output). YouTube videos with only auto-sub .vtt files have no audio and are skipped automatically."
+ />
+ <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>
+ {transcodeApplies && (
+ <div className="flex flex-col gap-2">
+ <Heading
+ title="Clean extra audio formats"
+ desc="In any video dir that already has the channel's target audio format, delete every other audio.* file. The list below tracks dirs with extras as of the last report."
+ />
+ <ExtraAudioFormatsSection
+ slug={slug}
+ ids={multipleAudioFormatIds}
+ existingQueues={existingQueues}
+ defaultQueueKey={defaultQueueKey}
+ />
+ </div>
+ )}
+ </div>
+ );
+}
+
+function ExtraAudioFormatsSection({
+ slug,
+ ids,
+ existingQueues,
+ defaultQueueKey,
+}: {
+ slug: string;
+ ids: string[];
+ existingQueues: string[];
+ defaultQueueKey: string;
+}) {
+ const [cleanQueue, setCleanQueue] = useState(defaultQueueKey);
+ const [confirmText, setConfirmText] = useState("");
+ const armed = confirmText === "clean";
+ return (
+ <div
+ aria-label="multiple audio formats section"
+ className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3"
+ >
+ <div>
+ <h4 className="text-sm font-semibold">
+ Dirs with extra audio formats ({ids.length})
+ </h4>
+ <p className="text-xs text-zinc-500">
+ Click <em>Refresh report</em> above to recompute. The cleanup walks
+ every video dir on disk regardless of this list — the count below
+ reflects the last snapshot only.
+ </p>
+ </div>
+ <VideoIdList
+ slug={slug}
+ ids={ids}
+ ariaLabel="multiple audio formats list"
+ emptyAriaLabel="multiple audio formats empty"
+ emptyMessage="No dirs with extra audio formats in the last report."
+ itemAriaLabel={(id) => `multiple audio formats ${id}`}
+ />
+ <div className="flex flex-col gap-2">
+ <label className="flex items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400">
+ Type
+ <span className="font-mono">clean</span>
+ to confirm
+ <input
+ type="text"
+ value={confirmText}
+ onChange={(e) => setConfirmText(e.target.value)}
+ aria-label="confirm clean extra audio formats"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-0.5 font-mono"
+ />
+ </label>
+ <StreamActionLog
+ trigger={() => cleanExtraAudioFormatsAction(slug, cleanQueue)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Clean extra audio formats"
+ runningLabel="Cleaning…"
+ label="Clean extra audio formats"
+ disabled={!armed}
+ extraControls={
+ <QueueControl
+ value={cleanQueue}
+ onChange={setCleanQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Clean extra audio formats"
+ />
+ }
+ />
+ </div>
+ </div>
+ );
+}
+
+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]/components/stages/DiagnosticsStage.tsx b/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import { useState } from "react";
+import { verifyAction, type VerifyResult } from "../../whisperActions";
+import { gotoVideoAction } from "../../../actions";
+import { VideoIdList } from "../VideoIdList";
+
+type DiagnosticBucket = {
+ ids: string[];
+ label: string;
+ description: string;
+ ariaLabel: string;
+};
+
+type Props = {
+ slug: string;
+ untranscribableIds: string[];
+ noMetadataIds: string[];
+ missingFromArchiveIds: string[];
+ duplicateDirIds: string[];
+ totals: { videos: number; transcribed: number; downloaded: number };
+};
+
+export function DiagnosticsStage({
+ slug,
+ untranscribableIds,
+ noMetadataIds,
+ missingFromArchiveIds,
+ duplicateDirIds,
+ totals,
+}: Props) {
+ const buckets: DiagnosticBucket[] = [
+ {
+ ids: untranscribableIds,
+ label: "Marked untranscribable",
+ description:
+ "transcript.json present but contains an empty transcription array.",
+ ariaLabel: "marked untranscribable",
+ },
+ {
+ ids: noMetadataIds,
+ label: "Missing metadata.info.json",
+ description:
+ "Video directory exists without yt-dlp metadata; index will skip it.",
+ ariaLabel: "missing metadata",
+ },
+ {
+ ids: missingFromArchiveIds,
+ label: "Archived ID with no directory",
+ description:
+ "Video ID is in the archive but its data directory is missing.",
+ ariaLabel: "archived without dir",
+ },
+ {
+ ids: duplicateDirIds,
+ label: "Duplicate directories",
+ description:
+ "Multiple directories resolve to the same video ID (e.g., legacy names).",
+ ariaLabel: "duplicate directories",
+ },
+ ];
+ const populated = buckets.filter((b) => b.ids.length > 0);
+ const total = populated.reduce((acc, b) => acc + b.ids.length, 0);
+ return (
+ <div className="flex flex-col gap-6">
+ <div className="flex flex-col gap-2">
+ <Heading
+ title="Verify"
+ desc="Cross-check the archive against transcripts on disk."
+ />
+ <VerifyButton slug={slug} />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Heading
+ title="Inspect a video"
+ desc="Jump to a specific video by ID."
+ />
+ <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>
+ </div>
+ <div className="flex flex-col gap-2" aria-label="channel health">
+ <div>
+ <h3 className="text-base font-semibold">Channel health</h3>
+ <p className="text-sm text-zinc-500">
+ {totals.videos} video dirs · {totals.transcribed} transcribed ·{" "}
+ {totals.downloaded} downloaded
+ {total > 0 ? ` · ${total} abnormal` : ""}
+ </p>
+ </div>
+ {populated.length === 0 ? (
+ <p
+ aria-label="channel health empty"
+ className="text-sm text-zinc-600 dark:text-zinc-400"
+ >
+ All clear.
+ </p>
+ ) : (
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
+ {populated.map((b) => (
+ <div
+ key={b.ariaLabel}
+ className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3"
+ >
+ <div>
+ <h4 className="text-sm font-semibold">
+ {b.label} ({b.ids.length})
+ </h4>
+ <p className="text-xs text-zinc-500">{b.description}</p>
+ </div>
+ <VideoIdList
+ slug={slug}
+ ids={b.ids}
+ ariaLabel={`${b.ariaLabel} list`}
+ emptyAriaLabel={`${b.ariaLabel} empty`}
+ emptyMessage="None"
+ itemAriaLabel={(id) => `${b.ariaLabel} ${id}`}
+ />
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function VerifyButton({ slug }: { slug: string }) {
+ const [running, setRunning] = useState(false);
+ const [result, setResult] = useState<VerifyResult | null>(null);
+
+ async function handleClick() {
+ setRunning(true);
+ setResult(null);
+ try {
+ const r = await verifyAction(slug);
+ setResult(r);
+ } finally {
+ setRunning(false);
+ }
+ }
+
+ return (
+ <div className="flex flex-col gap-2">
+ <button
+ type="button"
+ onClick={handleClick}
+ disabled={running}
+ 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"
+ >
+ {running ? "Verifying…" : "Verify"}
+ </button>
+ {result?.ok === false && (
+ <div
+ role="alert"
+ className="rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300"
+ >
+ {result.error}
+ </div>
+ )}
+ {result?.ok === true && (
+ <div
+ aria-label="verify result"
+ className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm"
+ >
+ <VerifyResultBucket label="Duplicates" items={result.duplicates} />
+ <VerifyResultBucket label="Missing" items={result.missing} />
+ </div>
+ )}
+ </div>
+ );
+}
+
+function VerifyResultBucket({
+ label,
+ items,
+}: {
+ label: string;
+ items: string[];
+}) {
+ return (
+ <div className="rounded border border-zinc-200 dark:border-zinc-800 p-3">
+ <div className="text-xs uppercase tracking-wide text-zinc-500">
+ {label} ({items.length})
+ </div>
+ {items.length === 0 ? (
+ <p className="text-zinc-500 mt-1">None</p>
+ ) : (
+ <ul className="mt-1 font-mono text-xs space-y-0.5">
+ {items.map((id) => (
+ <li key={id}>{id}</li>
+ ))}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+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]/components/stages/DownloadStage.tsx b/editor/app/channels/[slug]/components/stages/DownloadStage.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { useState } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { QueueControl } from "../../../../components/QueueControl";
+import { cancelJobAction } from "../../../../jobs/actions";
+import {
+ downloadAction,
+ downloadMissingAction,
+} from "../../pipelineActions";
+import { VideoIdList } from "../VideoIdList";
+
+type Props = {
+ slug: string;
+ hasUrl: boolean;
+ defaultQueueKey: string;
+ existingQueues: string[];
+ undownloadedIds: string[];
+ noTranscriptIds: string[];
+};
+
+export function DownloadStage({
+ slug,
+ hasUrl,
+ defaultQueueKey,
+ existingQueues,
+ undownloadedIds,
+ noTranscriptIds,
+}: Props) {
+ const [downloadQueue, setDownloadQueue] = useState(defaultQueueKey);
+ const [missingQueue, setMissingQueue] = useState(defaultQueueKey);
+ const [ignoreArchive, setIgnoreArchive] = useState(false);
+ const [downloadAbortOnError, setDownloadAbortOnError] = useState(true);
+ const [missingAbortOnError, setMissingAbortOnError] = useState(true);
+
+ if (!hasUrl) {
+ return (
+ <p className="text-sm text-zinc-500">
+ Channel has no URL — downloads disabled.
+ </p>
+ );
+ }
+ return (
+ <div className="flex flex-col gap-6">
+ <UndownloadedList slug={slug} ids={undownloadedIds} />
+ <div className="flex flex-col gap-2">
+ <Heading
+ 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, 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 for YouTube, or audio.* / transcript.json for transcribe) 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>
+ <NoTranscriptList slug={slug} ids={noTranscriptIds} />
+ </div>
+ );
+}
+
+function UndownloadedList({ slug, ids }: { slug: string; ids: string[] }) {
+ return (
+ <div className="flex flex-col gap-2">
+ <h3 className="text-base font-semibold">
+ Undownloaded videos ({ids.length})
+ </h3>
+ <VideoIdList
+ slug={slug}
+ ids={ids}
+ ariaLabel="undownloaded videos list"
+ emptyAriaLabel="undownloaded videos empty"
+ emptyMessage="All playlist videos are downloaded."
+ itemAriaLabel={(id) => `undownloaded video ${id}`}
+ />
+ </div>
+ );
+}
+
+function NoTranscriptList({ slug, ids }: { slug: string; ids: string[] }) {
+ if (ids.length === 0) return null;
+ return (
+ <div className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3">
+ <div>
+ <h4 className="text-sm font-semibold">
+ Missing transcript and no download ({ids.length})
+ </h4>
+ <p className="text-xs text-zinc-500">
+ Video directory exists but has no transcript or audio.
+ </p>
+ </div>
+ <VideoIdList
+ slug={slug}
+ ids={ids}
+ ariaLabel="no transcript or download list"
+ emptyAriaLabel="no transcript or download empty"
+ emptyMessage="None"
+ itemAriaLabel={(id) => `no transcript or download ${id}`}
+ />
+ </div>
+ );
+}
+
+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]/components/stages/PlaylistStage.tsx b/editor/app/channels/[slug]/components/stages/PlaylistStage.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { useState } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { QueueControl } from "../../../../components/QueueControl";
+import { cancelJobAction } from "../../../../jobs/actions";
+import { storePlaylistAction, syncAction } from "../../pipelineActions";
+
+type Props = {
+ slug: string;
+ hasUrl: boolean;
+ defaultQueueKey: string;
+ existingQueues: string[];
+};
+
+export function PlaylistStage({
+ slug,
+ hasUrl,
+ defaultQueueKey,
+ existingQueues,
+}: Props) {
+ const [storeQueue, setStoreQueue] = useState(defaultQueueKey);
+ const [syncQueue, setSyncQueue] = useState(defaultQueueKey);
+
+ if (!hasUrl) {
+ return (
+ <p className="text-sm text-zinc-500">
+ Set a <code>url</code> in Configure to enable the pipeline.
+ </p>
+ );
+ }
+ return (
+ <div className="flex flex-col gap-6">
+ <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, 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">
+ <Heading
+ title="Sync"
+ desc="Quick incremental fetch using --lazy-playlist + --break-on-existing. Run regularly to grab the newest videos."
+ />
+ <StreamActionLog
+ trigger={() => syncAction(slug, syncQueue)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Sync"
+ runningLabel="Syncing…"
+ extraControls={
+ <QueueControl
+ value={syncQueue}
+ onChange={setSyncQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Sync"
+ />
+ }
+ />
+ </div>
+ </div>
+ );
+}
+
+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]/components/stages/TranscodeStage.tsx b/editor/app/channels/[slug]/components/stages/TranscodeStage.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { useState } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import {
+ AUDIO_FORMAT_VALUES,
+ type AudioFormat,
+} from "yt-dlp-transcript-common/lib/channelConfig";
+import { QueueControl } from "../../../../components/QueueControl";
+import { cancelJobAction } from "../../../../jobs/actions";
+import {
+ removeFailedTranscodingsAction,
+ transcodeFailuresAction,
+} from "../../whisperActions";
+import { VideoIdList } from "../VideoIdList";
+
+type Props = {
+ slug: string;
+ existingQueues: string[];
+ failedTranscodingIds: string[];
+ untranscodedIds: string[];
+};
+
+export function TranscodeStage({
+ slug,
+ existingQueues,
+ failedTranscodingIds,
+ untranscodedIds,
+}: Props) {
+ const defaultQueueKey = `channel:${slug}`;
+ const [transcodeFormat, setTranscodeFormat] = useState<AudioFormat>("mp3");
+ const [transcodeQueue, setTranscodeQueue] = useState(defaultQueueKey);
+ return (
+ <div className="flex flex-col gap-6">
+ <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>
+ <UntranscodedList slug={slug} ids={untranscodedIds} />
+ <FailedTranscodingsSection
+ slug={slug}
+ ids={failedTranscodingIds}
+ existingQueues={existingQueues}
+ defaultQueueKey={defaultQueueKey}
+ />
+ </div>
+ );
+}
+
+function UntranscodedList({ slug, ids }: { slug: string; ids: string[] }) {
+ if (ids.length === 0) return null;
+ return (
+ <div className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3">
+ <div>
+ <h4 className="text-sm font-semibold">
+ Has audio but missing target format ({ids.length})
+ </h4>
+ <p className="text-xs text-zinc-500">
+ Audio file present in a different format than the channel's
+ configured audioFormat — re-run the transcoder to produce
+ audio.<format>.
+ </p>
+ </div>
+ <VideoIdList
+ slug={slug}
+ ids={ids}
+ ariaLabel="untranscoded list"
+ emptyAriaLabel="untranscoded empty"
+ emptyMessage="None"
+ itemAriaLabel={(id) => `untranscoded ${id}`}
+ />
+ </div>
+ );
+}
+
+function FailedTranscodingsSection({
+ slug,
+ ids,
+ existingQueues,
+ defaultQueueKey,
+}: {
+ slug: string;
+ ids: string[];
+ existingQueues: string[];
+ defaultQueueKey: string;
+}) {
+ const [removeQueue, setRemoveQueue] = useState(defaultQueueKey);
+ const [confirmText, setConfirmText] = useState("");
+ const armed = confirmText === "remove";
+ return (
+ <div
+ aria-label="failed transcodings section"
+ className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3"
+ >
+ <div>
+ <h4 className="text-sm font-semibold">
+ Failed transcodings ({ids.length})
+ </h4>
+ <p className="text-xs text-zinc-500">
+ Videos that ffmpeg refused to transcode. Source files are likely
+ corrupt — the button below deletes their video directories on disk
+ and clears this list.
+ </p>
+ </div>
+ <VideoIdList
+ slug={slug}
+ ids={ids}
+ ariaLabel="failed transcodings list"
+ emptyAriaLabel="failed transcodings empty"
+ emptyMessage="No failed transcodings."
+ itemAriaLabel={(id) => `failed transcoding ${id}`}
+ />
+ {ids.length > 0 ? (
+ <div className="flex flex-col gap-2">
+ <label className="flex items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400">
+ Type
+ <span className="font-mono">remove</span>
+ to confirm
+ <input
+ type="text"
+ value={confirmText}
+ onChange={(e) => setConfirmText(e.target.value)}
+ aria-label="confirm remove failed transcodings"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-0.5 font-mono"
+ />
+ </label>
+ <StreamActionLog
+ trigger={() => removeFailedTranscodingsAction(slug, removeQueue)}
+ cancelAction={cancelJobAction}
+ buttonLabel={`Remove ${ids.length} failed-transcoding video dir(s)`}
+ runningLabel="Removing…"
+ label="Remove failed transcodings"
+ disabled={!armed}
+ extraControls={
+ <QueueControl
+ value={removeQueue}
+ onChange={setRemoveQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Remove failed transcodings"
+ />
+ }
+ />
+ </div>
+ ) : null}
+ </div>
+ );
+}
+
+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]/components/stages/TranscribeStage.tsx b/editor/app/channels/[slug]/components/stages/TranscribeStage.tsx
@@ -0,0 +1,261 @@
+"use client";
+
+import { useState } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import {
+ AUDIO_FORMAT_VALUES,
+ type AudioFormat,
+} from "yt-dlp-transcript-common/lib/channelConfig";
+import { ConcurrencyControl } from "../../../../components/ConcurrencyControl";
+import { QueueControl } from "../../../../components/QueueControl";
+import { cancelJobAction } from "../../../../jobs/actions";
+import {
+ retryFailuresAction,
+ transcribeMissingAction,
+} from "../../whisperActions";
+import { VideoIdList } from "../VideoIdList";
+
+type AudioFormatChoice = AudioFormat | "any";
+
+function parseConcurrency(s: string): number | undefined {
+ const trimmed = s.trim();
+ if (trimmed === "") return undefined;
+ const n = Number(trimmed);
+ if (!Number.isFinite(n) || n < 1) return undefined;
+ return Math.floor(n);
+}
+
+type Props = {
+ slug: string;
+ existingQueues: string[];
+ failedVideoIds: string[];
+ downloadedNoTranscriptIds: string[];
+ defaultConcurrency: number;
+};
+
+export function TranscribeStage({
+ slug,
+ existingQueues,
+ failedVideoIds,
+ downloadedNoTranscriptIds,
+ defaultConcurrency,
+}: Props) {
+ const defaultQueueKey = `channel:${slug}`;
+ const [missingQueue, setMissingQueue] = useState(defaultQueueKey);
+ const [missingConcurrency, setMissingConcurrency] = useState("");
+ const [missingReverse, setMissingReverse] = useState(false);
+ const [missingAudioFormat, setMissingAudioFormat] =
+ useState<AudioFormatChoice>("any");
+ const [missingStrictFormat, setMissingStrictFormat] = useState(false);
+ const [retryQueue, setRetryQueue] = useState(defaultQueueKey);
+ const [retryConcurrency, setRetryConcurrency] = useState("");
+ const [retryAudioFormat, setRetryAudioFormat] =
+ useState<AudioFormatChoice>("any");
+ const [retryStrictFormat, setRetryStrictFormat] = useState(false);
+
+ return (
+ <div className="flex flex-col gap-6">
+ <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,
+ missingQueue,
+ parseConcurrency(missingConcurrency),
+ missingReverse,
+ missingAudioFormat === "any" ? undefined : missingAudioFormat,
+ missingAudioFormat !== "any" && missingStrictFormat,
+ )
+ }
+ cancelAction={cancelJobAction}
+ buttonLabel="Transcribe missing"
+ runningLabel="Transcribing…"
+ extraControls={
+ <>
+ <QueueControl
+ value={missingQueue}
+ onChange={setMissingQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Transcribe missing"
+ />
+ <ConcurrencyControl
+ value={missingConcurrency}
+ onChange={setMissingConcurrency}
+ defaultLimit={defaultConcurrency}
+ actionLabel="Transcribe missing"
+ />
+ <AudioFormatControl
+ actionLabel="Transcribe missing"
+ value={missingAudioFormat}
+ onChange={setMissingAudioFormat}
+ strict={missingStrictFormat}
+ onStrictChange={setMissingStrictFormat}
+ />
+ <label className="flex items-center gap-1 text-xs text-zinc-500">
+ <input
+ type="checkbox"
+ checked={missingReverse}
+ onChange={(e) => setMissingReverse(e.target.checked)}
+ aria-label="reverse order for Transcribe missing"
+ />
+ Reverse
+ </label>
+ </>
+ }
+ />
+ <DownloadedNoTranscriptList
+ slug={slug}
+ ids={downloadedNoTranscriptIds}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Heading
+ title="Retry failures"
+ desc="Re-run whisper for any video listed below; successes will be removed from the list."
+ />
+ <StreamActionLog
+ trigger={() =>
+ retryFailuresAction(
+ slug,
+ retryQueue,
+ parseConcurrency(retryConcurrency),
+ retryAudioFormat === "any" ? undefined : retryAudioFormat,
+ retryAudioFormat !== "any" && retryStrictFormat,
+ )
+ }
+ cancelAction={cancelJobAction}
+ buttonLabel="Retry failures"
+ runningLabel="Retrying…"
+ extraControls={
+ <>
+ <QueueControl
+ value={retryQueue}
+ onChange={setRetryQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Retry failures"
+ />
+ <ConcurrencyControl
+ value={retryConcurrency}
+ onChange={setRetryConcurrency}
+ defaultLimit={defaultConcurrency}
+ actionLabel="Retry failures"
+ />
+ <AudioFormatControl
+ actionLabel="Retry failures"
+ value={retryAudioFormat}
+ onChange={setRetryAudioFormat}
+ strict={retryStrictFormat}
+ onStrictChange={setRetryStrictFormat}
+ />
+ </>
+ }
+ />
+ <VideoIdList
+ slug={slug}
+ ids={failedVideoIds}
+ ariaLabel="failed transcriptions list"
+ emptyAriaLabel="failed transcriptions empty"
+ emptyMessage="No failed transcriptions."
+ itemAriaLabel={(id) => `failed transcription ${id}`}
+ />
+ </div>
+ </div>
+ );
+}
+
+function DownloadedNoTranscriptList({
+ slug,
+ ids,
+}: {
+ slug: string;
+ ids: string[];
+}) {
+ if (ids.length === 0) return null;
+ return (
+ <div className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3">
+ <div>
+ <h4 className="text-sm font-semibold">
+ Downloaded but not transcribed ({ids.length})
+ </h4>
+ <p className="text-xs text-zinc-500">
+ Audio is on disk but no .vtt or transcript.json yet.
+ </p>
+ </div>
+ <VideoIdList
+ slug={slug}
+ ids={ids}
+ ariaLabel="downloaded without transcript list"
+ emptyAriaLabel="downloaded without transcript empty"
+ emptyMessage="None"
+ itemAriaLabel={(id) => `downloaded without transcript ${id}`}
+ />
+ </div>
+ );
+}
+
+function AudioFormatControl({
+ actionLabel,
+ value,
+ onChange,
+ strict,
+ onStrictChange,
+}: {
+ actionLabel: string;
+ value: AudioFormatChoice;
+ onChange: (v: AudioFormatChoice) => void;
+ strict: boolean;
+ onStrictChange: (v: boolean) => void;
+}) {
+ const strictDisabled = value === "any";
+ return (
+ <span className="flex items-center gap-2 text-xs text-zinc-500">
+ <label className="flex items-center gap-1">
+ Audio format
+ <select
+ value={value}
+ onChange={(e) => onChange(e.target.value as AudioFormatChoice)}
+ aria-label={`audio format for ${actionLabel}`}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-1 py-0.5"
+ >
+ <option value="any">any</option>
+ {AUDIO_FORMAT_VALUES.map((f) => (
+ <option key={f} value={f}>
+ {f}
+ </option>
+ ))}
+ </select>
+ </label>
+ <label
+ className={
+ strictDisabled
+ ? "flex items-center gap-1 opacity-50"
+ : "flex items-center gap-1"
+ }
+ >
+ <input
+ type="checkbox"
+ checked={!strictDisabled && strict}
+ disabled={strictDisabled}
+ onChange={(e) => onStrictChange(e.target.checked)}
+ aria-label={`strict audio format for ${actionLabel}`}
+ />
+ Strict (skip videos lacking this format)
+ </label>
+ </span>
+ );
+}
+
+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]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts
@@ -0,0 +1,321 @@
+import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
+import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
+import type { JobRecord } from "yt-dlp-transcript-common/jobs/registry";
+
+export type SnapshotBuckets = ChannelSnapshot["buckets"];
+
+// Older snapshots on disk may pre-date some bucket fields. Normalize to
+// always-present arrays so callers can read .length without guards.
+export function normalizeBuckets(
+ raw: Partial<SnapshotBuckets> | undefined,
+): SnapshotBuckets {
+ return {
+ noTranscript: raw?.noTranscript ?? [],
+ downloadedNoTranscript: raw?.downloadedNoTranscript ?? [],
+ untranscoded: raw?.untranscoded ?? [],
+ multipleAudioFormats: raw?.multipleAudioFormats ?? [],
+ untranscribable: raw?.untranscribable ?? [],
+ noMetadata: raw?.noMetadata ?? [],
+ failedListed: raw?.failedListed ?? [],
+ missingFromArchive: raw?.missingFromArchive ?? [],
+ duplicateDirs: raw?.duplicateDirs ?? [],
+ };
+}
+
+export type StageId =
+ | "configure"
+ | "playlist"
+ | "download"
+ | "transcode"
+ | "transcribe"
+ | "cleanup"
+ | "diagnostics"
+ | "danger";
+
+export type StageTone = "neutral" | "attention" | "danger" | "running" | "ok";
+
+export type StageStatus = {
+ id: StageId;
+ title: string;
+ pending: number;
+ failed: number;
+ running: boolean;
+ defaultOpen: boolean;
+ summary: string;
+ tone: StageTone;
+};
+
+const JOB_KIND_TO_STAGE: Record<string, StageId> = {
+ "store-playlist": "playlist",
+ sync: "playlist",
+ "download-from-playlist": "download",
+ "download-missing": "download",
+ "transcode-failures": "transcode",
+ "remove-failed-transcodings": "transcode",
+ "transcode-audio": "transcode",
+ "whisper-all": "transcribe",
+ "whisper-retry": "transcribe",
+ "transcribe-one": "transcribe",
+ "whisper-video": "transcribe",
+ "clean-audio-transcribed": "cleanup",
+ "clean-extra-audio-formats": "cleanup",
+};
+
+function pluralize(n: number, singular: string, plural?: string): string {
+ return `${n} ${n === 1 ? singular : plural ?? `${singular}s`}`;
+}
+
+function pickTone(args: {
+ running: boolean;
+ pending: number;
+ failed: number;
+ fallback?: StageTone;
+}): StageTone {
+ if (args.running) return "running";
+ if (args.failed > 0) return "danger";
+ if (args.pending > 0) return "attention";
+ return args.fallback ?? "neutral";
+}
+
+export type ComputeStageStatusesInput = {
+ snapshot: ChannelSnapshot;
+ failedVideoIds: string[];
+ failedTranscodingIds: string[];
+ config: ChannelConfig;
+ runningJobs: JobRecord[];
+};
+
+export function computeStageStatuses(
+ input: ComputeStageStatusesInput,
+): Record<StageId, StageStatus> {
+ const { snapshot, failedVideoIds, failedTranscodingIds, config, runningJobs } =
+ input;
+
+ const buckets = normalizeBuckets(snapshot.buckets);
+ const undownloadedIds = snapshot.undownloadedIds ?? [];
+
+ const runningByStage = new Set<StageId>();
+ for (const job of runningJobs) {
+ if (job.status !== "running" && job.status !== "queued") continue;
+ const stage = JOB_KIND_TO_STAGE[job.kind];
+ if (stage) runningByStage.add(stage);
+ }
+
+ const transcodeApplies =
+ config.handling === "transcribe" && !!config.audioFormat;
+
+ const downloadPending = undownloadedIds.length + buckets.noTranscript.length;
+ const transcodePending = transcodeApplies ? buckets.untranscoded.length : 0;
+ const transcodeFailed = transcodeApplies ? failedTranscodingIds.length : 0;
+ const transcribePending = buckets.downloadedNoTranscript.length;
+ const transcribeFailed = failedVideoIds.length;
+ const cleanupPending = buckets.multipleAudioFormats.length;
+ const diagnosticsPending =
+ buckets.untranscribable.length +
+ buckets.noMetadata.length +
+ buckets.missingFromArchive.length +
+ buckets.duplicateDirs.length;
+
+ // Cards that carry primary actions stay open by default so the user can
+ // always reach the buttons; the summary line communicates idle/busy state
+ // instead of collapsing the controls out of sight. Configure collapses once
+ // the channel has a URL; Danger zone stays collapsed unless opened.
+ const configure: StageStatus = {
+ id: "configure",
+ title: "Configure",
+ pending: 0,
+ failed: 0,
+ running: false,
+ defaultOpen: true,
+ summary: config.url
+ ? `${config.handling}${config.platform ? ` · ${config.platform}` : ""}`
+ : "Channel has no URL — open to configure.",
+ tone: config.url ? "neutral" : "attention",
+ };
+
+ const playlistRunning = runningByStage.has("playlist");
+ const playlist: StageStatus = {
+ id: "playlist",
+ title: "Playlist",
+ pending: 0,
+ failed: 0,
+ running: playlistRunning,
+ defaultOpen: true,
+ summary: playlistRunning
+ ? "Running…"
+ : config.lastSyncedAt
+ ? `Last sync ${new Date(config.lastSyncedAt).toLocaleString()}`
+ : "Never synced.",
+ tone: pickTone({ running: playlistRunning, pending: 0, failed: 0 }),
+ };
+
+ const downloadRunning = runningByStage.has("download");
+ const downloadParts: string[] = [];
+ if (undownloadedIds.length > 0) {
+ downloadParts.push(pluralize(undownloadedIds.length, "undownloaded"));
+ }
+ if (buckets.noTranscript.length > 0) {
+ downloadParts.push(
+ pluralize(
+ buckets.noTranscript.length,
+ "dir missing transcript & audio",
+ "dirs missing transcript & audio",
+ ),
+ );
+ }
+ const download: StageStatus = {
+ id: "download",
+ title: "Download",
+ pending: downloadPending,
+ failed: 0,
+ running: downloadRunning,
+ defaultOpen: true,
+ summary: downloadRunning
+ ? "Running…"
+ : downloadParts.length > 0
+ ? downloadParts.join(" · ")
+ : "Nothing to download.",
+ tone: pickTone({
+ running: downloadRunning,
+ pending: downloadPending,
+ failed: 0,
+ }),
+ };
+
+ const transcodeRunning = runningByStage.has("transcode");
+ const transcodeParts: string[] = [];
+ if (transcodeApplies && buckets.untranscoded.length > 0) {
+ transcodeParts.push(
+ pluralize(
+ buckets.untranscoded.length,
+ "video missing target format",
+ "videos missing target format",
+ ),
+ );
+ }
+ if (transcodeApplies && failedTranscodingIds.length > 0) {
+ transcodeParts.push(
+ pluralize(failedTranscodingIds.length, "failed transcoding"),
+ );
+ }
+ const transcode: StageStatus = {
+ id: "transcode",
+ title: "Transcode",
+ pending: transcodePending,
+ failed: transcodeFailed,
+ running: transcodeRunning,
+ defaultOpen: transcodeApplies,
+ summary: !transcodeApplies
+ ? "Not applicable for this channel."
+ : transcodeRunning
+ ? "Running…"
+ : transcodeParts.length > 0
+ ? transcodeParts.join(" · ")
+ : "Audio formats are aligned.",
+ tone: !transcodeApplies
+ ? "neutral"
+ : pickTone({
+ running: transcodeRunning,
+ pending: transcodePending,
+ failed: transcodeFailed,
+ }),
+ };
+
+ const transcribeRunning = runningByStage.has("transcribe");
+ const transcribeParts: string[] = [];
+ if (buckets.downloadedNoTranscript.length > 0) {
+ transcribeParts.push(
+ pluralize(
+ buckets.downloadedNoTranscript.length,
+ "video awaiting whisper",
+ "videos awaiting whisper",
+ ),
+ );
+ }
+ if (failedVideoIds.length > 0) {
+ transcribeParts.push(pluralize(failedVideoIds.length, "failed"));
+ }
+ const transcribe: StageStatus = {
+ id: "transcribe",
+ title: "Transcribe",
+ pending: transcribePending,
+ failed: transcribeFailed,
+ running: transcribeRunning,
+ defaultOpen: true,
+ summary: transcribeRunning
+ ? "Running…"
+ : transcribeParts.length > 0
+ ? transcribeParts.join(" · ")
+ : "All transcribed.",
+ tone: pickTone({
+ running: transcribeRunning,
+ pending: transcribePending,
+ failed: transcribeFailed,
+ }),
+ };
+
+ const cleanupRunning = runningByStage.has("cleanup");
+ const cleanup: StageStatus = {
+ id: "cleanup",
+ title: "Cleanup",
+ pending: cleanupPending,
+ failed: 0,
+ running: cleanupRunning,
+ defaultOpen: true,
+ summary: cleanupRunning
+ ? "Running…"
+ : cleanupPending > 0
+ ? pluralize(
+ cleanupPending,
+ "dir has extra audio formats",
+ "dirs have extra audio formats",
+ )
+ : "Nothing to clean.",
+ tone: pickTone({
+ running: cleanupRunning,
+ pending: cleanupPending,
+ failed: 0,
+ }),
+ };
+
+ const diagnostics: StageStatus = {
+ id: "diagnostics",
+ title: "Diagnostics",
+ pending: diagnosticsPending,
+ failed: 0,
+ running: false,
+ defaultOpen: true,
+ summary:
+ diagnosticsPending > 0
+ ? pluralize(diagnosticsPending, "anomaly", "anomalies")
+ : "All clear.",
+ tone: pickTone({
+ running: false,
+ pending: diagnosticsPending,
+ failed: 0,
+ fallback: "ok",
+ }),
+ };
+
+ const danger: StageStatus = {
+ id: "danger",
+ title: "Danger zone",
+ pending: 0,
+ failed: 0,
+ running: false,
+ defaultOpen: true,
+ summary: "Delete this channel.",
+ tone: "neutral",
+ };
+
+ return {
+ configure,
+ playlist,
+ download,
+ transcode,
+ transcribe,
+ cleanup,
+ diagnostics,
+ danger,
+ };
+}
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -1,13 +1,12 @@
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 {
generateChannelSnapshot,
readChannelSnapshot,
- type ChannelSnapshot,
} from "yt-dlp-transcript-common/controller/channelSnapshot";
import { loadFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions";
+import { loadFailedTranscodings } from "yt-dlp-transcript-common/controller/failedTranscodings";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import {
detectPlatform,
@@ -16,13 +15,17 @@ import {
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { ChannelFormClient } from "../components/ChannelFormClient";
import { DeleteChannelForm } from "../components/DeleteChannelForm";
-import { PipelinePanel } from "./components/PipelinePanel";
-import { RefreshSnapshotButton } from "./components/RefreshSnapshotButton";
-import { VideoIdList } from "./components/VideoIdList";
-import { WhisperPanel } from "./components/WhisperPanel";
+import { PipelineStageCard } from "./components/PipelineStageCard";
+import { StatusHeader } from "./components/StatusHeader";
+import { CleanupStage } from "./components/stages/CleanupStage";
+import { DiagnosticsStage } from "./components/stages/DiagnosticsStage";
+import { DownloadStage } from "./components/stages/DownloadStage";
+import { PlaylistStage } from "./components/stages/PlaylistStage";
+import { TranscodeStage } from "./components/stages/TranscodeStage";
+import { TranscribeStage } from "./components/stages/TranscribeStage";
+import { computeStageStatuses, normalizeBuckets } from "./lib/stageStatus";
import {
deleteChannelAction,
- gotoVideoAction,
updateChannelAction,
type ActionResult,
} from "../actions";
@@ -52,8 +55,16 @@ export default async function ChannelDetailPage({
const update = updateChannelAction.bind(null, slug);
const del = deleteChannelAction.bind(null, slug);
- const existingQueues = getRegistry().activeQueueNames();
- const defaultQueueKey = platformQueueKey(
+ const registry = getRegistry();
+ const existingQueues = registry.activeQueueNames();
+ const runningJobs = registry
+ .list()
+ .filter(
+ (j) =>
+ j.channelSlug === slug &&
+ (j.status === "running" || j.status === "queued"),
+ );
+ const platformDefaultQueueKey = platformQueueKey(
config.platform ?? detectPlatform(config.url),
);
const existing = await readChannelSnapshot(paths, slug);
@@ -62,20 +73,38 @@ export default async function ChannelDetailPage({
// read it fresh on every render — the snapshot bucket only reflects state
// at refresh time.
const failedVideoIds = await loadFailedTranscriptions(paths, slug);
+ const failedTranscodingIds = await loadFailedTranscodings(paths, slug);
+
+ const buckets = normalizeBuckets(snapshot.buckets);
+ const undownloadedIds = snapshot.undownloadedIds ?? [];
+ const stages = computeStageStatuses({
+ snapshot,
+ failedVideoIds,
+ failedTranscodingIds,
+ config,
+ runningJobs,
+ });
+ const transcodeApplies =
+ config.handling === "transcribe" && !!config.audioFormat;
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>
- <span className="font-mono">{slug}</span>
- </div>
- <h1 className="text-2xl font-semibold">{config.name ?? slug}</h1>
+ <StatusHeader
+ slug={slug}
+ channelName={config.name ?? slug}
+ generatedAt={snapshot.generatedAt}
+ fresh={!existing}
+ stages={stages}
+ transcodeApplies={transcodeApplies}
+ />
- <section className="flex flex-col gap-3">
- <h2 className="text-lg font-semibold">Configuration</h2>
+ <PipelineStageCard
+ id="configure"
+ title="Configure"
+ summary={stages.configure.summary}
+ defaultOpen={stages.configure.defaultOpen}
+ tone={stages.configure.tone}
+ >
<ChannelFormClient
action={
update as (
@@ -86,61 +115,124 @@ export default async function ChannelDetailPage({
initial={{ slug, config }}
submitLabel="Save changes"
/>
- </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">Pipeline</h2>
- <PipelinePanel
+ </PipelineStageCard>
+
+ <PipelineStageCard
+ id="playlist"
+ title="Playlist"
+ summary={stages.playlist.summary}
+ defaultOpen={stages.playlist.defaultOpen}
+ tone={stages.playlist.tone}
+ running={stages.playlist.running}
+ >
+ <PlaylistStage
slug={slug}
hasUrl={!!config.url}
- defaultQueueKey={defaultQueueKey}
+ defaultQueueKey={platformDefaultQueueKey}
existingQueues={existingQueues}
/>
- </section>
-
- <ChannelReportSection slug={slug} snapshot={snapshot} fresh={!existing} />
-
- <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
+ </PipelineStageCard>
+
+ <PipelineStageCard
+ id="download"
+ title="Download"
+ summary={stages.download.summary}
+ defaultOpen={stages.download.defaultOpen}
+ tone={stages.download.tone}
+ pending={stages.download.pending}
+ running={stages.download.running}
+ >
+ <DownloadStage
slug={slug}
+ hasUrl={!!config.url}
+ defaultQueueKey={platformDefaultQueueKey}
existingQueues={existingQueues}
- failedVideoIds={failedVideoIds}
- defaultConcurrency={paths.parallelTranscribeLimit}
+ undownloadedIds={undownloadedIds}
+ noTranscriptIds={buckets.noTranscript}
/>
- </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"
+ </PipelineStageCard>
+
+ {transcodeApplies && (
+ <PipelineStageCard
+ id="transcode"
+ title="Transcode"
+ summary={stages.transcode.summary}
+ defaultOpen={stages.transcode.defaultOpen}
+ tone={stages.transcode.tone}
+ pending={stages.transcode.pending}
+ failed={stages.transcode.failed}
+ running={stages.transcode.running}
>
- <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"
+ <TranscodeStage
+ slug={slug}
+ existingQueues={existingQueues}
+ failedTranscodingIds={failedTranscodingIds}
+ untranscodedIds={buckets.untranscoded}
/>
- <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>
+ </PipelineStageCard>
+ )}
- <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>
+ <PipelineStageCard
+ id="transcribe"
+ title="Transcribe"
+ summary={stages.transcribe.summary}
+ defaultOpen={stages.transcribe.defaultOpen}
+ tone={stages.transcribe.tone}
+ pending={stages.transcribe.pending}
+ failed={stages.transcribe.failed}
+ running={stages.transcribe.running}
+ >
+ <TranscribeStage
+ slug={slug}
+ existingQueues={existingQueues}
+ failedVideoIds={failedVideoIds}
+ downloadedNoTranscriptIds={buckets.downloadedNoTranscript}
+ defaultConcurrency={paths.parallelTranscribeLimit}
+ />
+ </PipelineStageCard>
+
+ <PipelineStageCard
+ id="cleanup"
+ title="Cleanup"
+ summary={stages.cleanup.summary}
+ defaultOpen={stages.cleanup.defaultOpen}
+ tone={stages.cleanup.tone}
+ pending={stages.cleanup.pending}
+ running={stages.cleanup.running}
+ >
+ <CleanupStage
+ slug={slug}
+ existingQueues={existingQueues}
+ multipleAudioFormatIds={buckets.multipleAudioFormats}
+ transcodeApplies={transcodeApplies}
+ />
+ </PipelineStageCard>
+
+ <PipelineStageCard
+ id="diagnostics"
+ title="Diagnostics"
+ summary={stages.diagnostics.summary}
+ defaultOpen={stages.diagnostics.defaultOpen}
+ tone={stages.diagnostics.tone}
+ pending={stages.diagnostics.pending}
+ >
+ <DiagnosticsStage
+ slug={slug}
+ untranscribableIds={buckets.untranscribable}
+ noMetadataIds={buckets.noMetadata}
+ missingFromArchiveIds={buckets.missingFromArchive}
+ duplicateDirIds={buckets.duplicateDirs}
+ totals={snapshot.totals}
+ />
+ </PipelineStageCard>
+
+ <PipelineStageCard
+ id="danger"
+ title="Danger zone"
+ summary={stages.danger.summary}
+ defaultOpen={stages.danger.defaultOpen}
+ tone={stages.danger.tone}
+ >
<DeleteChannelForm
slug={slug}
action={
@@ -150,186 +242,7 @@ export default async function ChannelDetailPage({
) => Promise<ActionResult>
}
/>
- </section>
- </div>
- );
-}
-
-type Bucket = {
- key: keyof ChannelSnapshot["buckets"];
- label: string;
- description: string;
- ariaLabel: string;
-};
-
-const BUCKETS: ReadonlyArray<Bucket> = [
- {
- key: "downloadedNoTranscript",
- label: "Downloaded but not transcribed",
- description: "Audio is on disk but no .vtt or transcript.json yet.",
- ariaLabel: "downloaded without transcript",
- },
- {
- key: "noTranscript",
- label: "Missing transcript and no download",
- description: "Video directory exists but has no transcript or audio.",
- ariaLabel: "no transcript or download",
- },
- {
- key: "untranscribable",
- label: "Marked untranscribable",
- description: "transcript.json present but contains an empty transcription array.",
- ariaLabel: "marked untranscribable",
- },
- {
- key: "noMetadata",
- label: "Missing metadata.info.json",
- description: "Video directory exists without yt-dlp metadata; index will skip it.",
- ariaLabel: "missing metadata",
- },
- {
- key: "failedListed",
- label: "In failed-transcriptions",
- description: "Listed in the failure file; can be retried from the Transcription panel.",
- ariaLabel: "failed list",
- },
- {
- key: "missingFromArchive",
- label: "Archived ID with no directory",
- description: "Video ID is in the archive but its data directory is missing.",
- ariaLabel: "archived without dir",
- },
- {
- key: "duplicateDirs",
- label: "Duplicate directories",
- description: "Multiple directories resolve to the same video ID (e.g., legacy names).",
- ariaLabel: "duplicate directories",
- },
-];
-
-function ChannelReportSection({
- slug,
- snapshot,
- fresh,
-}: {
- slug: string;
- snapshot: ChannelSnapshot;
- fresh: boolean;
-}) {
- return (
- <section
- aria-label="channel report"
- className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"
- >
- <div className="flex items-start justify-between gap-3 flex-wrap">
- <div>
- <h2 className="text-lg font-semibold">Report</h2>
- <p className="text-sm text-zinc-500">
- {fresh ? "Generated just now" : "Generated "}
- {fresh ? null : (
- <time dateTime={snapshot.generatedAt}>
- {formatTimestamp(snapshot.generatedAt)}
- </time>
- )}
- . Updates only when refreshed.
- </p>
- </div>
- <RefreshSnapshotButton slug={slug} />
- </div>
- <UndownloadedSubsection slug={slug} ids={snapshot.undownloadedIds} />
- <ChannelHealthSubsection slug={slug} snapshot={snapshot} />
- </section>
- );
-}
-
-function UndownloadedSubsection({
- slug,
- ids,
-}: {
- slug: string;
- ids: string[];
-}) {
- return (
- <div className="flex flex-col gap-2">
- <h3 className="text-base font-semibold">
- Undownloaded videos ({ids.length})
- </h3>
- <VideoIdList
- slug={slug}
- ids={ids}
- ariaLabel="undownloaded videos list"
- emptyAriaLabel="undownloaded videos empty"
- emptyMessage="All playlist videos are downloaded."
- itemAriaLabel={(id) => `undownloaded video ${id}`}
- />
+ </PipelineStageCard>
</div>
);
}
-
-function ChannelHealthSubsection({
- slug,
- snapshot,
-}: {
- slug: string;
- snapshot: ChannelSnapshot;
-}) {
- const populated = BUCKETS.filter(
- (b) => snapshot.buckets[b.key].length > 0,
- );
- const total = populated.reduce(
- (acc, b) => acc + snapshot.buckets[b.key].length,
- 0,
- );
- return (
- <div aria-label="channel health" className="flex flex-col gap-3">
- <div>
- <h3 className="text-base font-semibold">Channel health</h3>
- <p className="text-sm text-zinc-500">
- {snapshot.totals.videos} video dirs · {snapshot.totals.transcribed}{" "}
- transcribed · {snapshot.totals.downloaded} downloaded
- {total > 0 ? ` · ${total} abnormal` : ""}
- </p>
- </div>
- {populated.length === 0 ? (
- <p
- aria-label="channel health empty"
- className="text-sm text-zinc-600 dark:text-zinc-400"
- >
- All clear.
- </p>
- ) : (
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
- {populated.map((b) => (
- <div
- key={b.key}
- className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3"
- >
- <div>
- <h4 className="text-sm font-semibold">
- {b.label} ({snapshot.buckets[b.key].length})
- </h4>
- <p className="text-xs text-zinc-500">{b.description}</p>
- </div>
- <VideoIdList
- slug={slug}
- ids={snapshot.buckets[b.key]}
- ariaLabel={`${b.ariaLabel} list`}
- emptyAriaLabel={`${b.ariaLabel} empty`}
- emptyMessage="None"
- itemAriaLabel={(id) => `${b.ariaLabel} ${id}`}
- />
- </div>
- ))}
- </div>
- )}
- </div>
- );
-}
-
-function formatTimestamp(iso: string): string {
- try {
- return new Date(iso).toLocaleString();
- } catch {
- return iso;
- }
-}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/MediaPlayer.tsx b/editor/app/channels/[slug]/videos/[id]/components/MediaPlayer.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+type Kind = "audio" | "video";
+
+type LoadState =
+ | { phase: "loading" }
+ | { phase: "ready"; duration: number | null }
+ | { phase: "error"; message: string };
+
+type Props = {
+ src: string;
+ kind: Kind;
+ filename: string;
+};
+
+const MEDIA_ERROR_REASONS: Record<number, string> = {
+ 1: "Playback aborted.",
+ 2: "Network error while loading the file.",
+ 3: "Decoder failed — file may be malformed or use an unsupported codec.",
+ 4: "Source not supported — browser cannot decode this format.",
+};
+
+export function MediaPlayer({ src, kind, filename }: Props) {
+ const ref = useRef<HTMLMediaElement | null>(null);
+ const [state, setState] = useState<LoadState>({ phase: "loading" });
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ setState({ phase: "loading" });
+ const onLoadedMeta = () => {
+ const d = el.duration;
+ setState({
+ phase: "ready",
+ duration: Number.isFinite(d) ? d : null,
+ });
+ };
+ const onError = () => {
+ const code = el.error?.code;
+ const detail =
+ code !== undefined
+ ? (MEDIA_ERROR_REASONS[code] ?? "Unknown decode error.")
+ : "Unknown decode error.";
+ setState({ phase: "error", message: detail });
+ };
+ el.addEventListener("loadedmetadata", onLoadedMeta);
+ el.addEventListener("error", onError);
+ return () => {
+ el.removeEventListener("loadedmetadata", onLoadedMeta);
+ el.removeEventListener("error", onError);
+ };
+ }, [src]);
+
+ const refCallback = (node: HTMLMediaElement | null) => {
+ ref.current = node;
+ };
+
+ return (
+ <div className="flex flex-col gap-2">
+ {kind === "audio" ? (
+ <audio
+ ref={refCallback}
+ controls
+ preload="metadata"
+ src={src}
+ aria-label={`media player for ${filename}`}
+ className="w-full max-w-md"
+ />
+ ) : (
+ <video
+ ref={refCallback}
+ controls
+ preload="metadata"
+ src={src}
+ aria-label={`media player for ${filename}`}
+ className="w-full max-w-md max-h-64 bg-black rounded"
+ />
+ )}
+ <StatusLine state={state} filename={filename} />
+ </div>
+ );
+}
+
+function StatusLine({
+ state,
+ filename,
+}: {
+ state: LoadState;
+ filename: string;
+}) {
+ if (state.phase === "loading") {
+ return (
+ <p
+ className="text-xs text-zinc-500"
+ aria-label={`media status for ${filename}`}
+ >
+ Loading metadata…
+ </p>
+ );
+ }
+ if (state.phase === "error") {
+ return (
+ <p
+ role="alert"
+ aria-label={`media error for ${filename}`}
+ className="text-xs text-red-600 dark:text-red-400"
+ >
+ Malformed: {state.message}
+ </p>
+ );
+ }
+ return (
+ <p
+ className="text-xs text-emerald-700 dark:text-emerald-400"
+ aria-label={`media status for ${filename}`}
+ >
+ Decodes OK
+ {state.duration !== null && ` · ${formatDuration(state.duration)}`}
+ </p>
+ );
+}
+
+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]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -9,6 +9,8 @@ import {
} from "yt-dlp-transcript-common/lib/channelConfig";
import { QueueControl } from "../../../../../components/QueueControl";
import { cancelJobAction } from "../../../../../jobs/actions";
+import { PipelineStageCard } from "../../../components/PipelineStageCard";
+import { MediaPlayer } from "./MediaPlayer";
import {
deleteVideoDirAction,
deleteVideoFileAction,
@@ -45,6 +47,31 @@ function audioExt(name: string): string {
return dot >= 0 ? name.slice(dot + 1) : "";
}
+const VIDEO_EXTS = new Set([
+ "mp4",
+ "webm",
+ "mkv",
+ "mov",
+ "m4v",
+ "ogv",
+ "avi",
+]);
+
+function fileExt(name: string): string {
+ const dot = name.lastIndexOf(".");
+ return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
+}
+
+function mediaKind(name: string): "audio" | "video" | null {
+ if (isAudioFile(name)) return "audio";
+ if (VIDEO_EXTS.has(fileExt(name))) return "video";
+ return null;
+}
+
+function mediaUrl(slug: string, videoId: string, filename: string): string {
+ return `/api/channels/${encodeURIComponent(slug)}/videos/${encodeURIComponent(videoId)}/files/${encodeURIComponent(filename)}`;
+}
+
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -74,74 +101,123 @@ export function VideoPanel({
const hasTranscript = hasTranscriptJson || hasYtVtt;
const noAudio = audioFiles.length === 0;
+ const downloadSummary = noAudio
+ ? handling === "youtube"
+ ? "No audio on disk — download to enable whisper."
+ : "Audio not yet downloaded."
+ : `${audioFiles.length} audio file${audioFiles.length === 1 ? "" : "s"} on disk.`;
+ const transcribeSummary = hasTranscript
+ ? "Transcript present."
+ : noAudio
+ ? "No audio yet — download first or use Whisper transcribe (combo)."
+ : "Audio ready, no transcript.";
+ const transcodeSummary =
+ audioFiles.length > 0
+ ? `Convert ${audioFiles.length} audio file${audioFiles.length === 1 ? "" : "s"} to another format.`
+ : "Nothing to transcode.";
+
return (
<div className="flex flex-col gap-6">
- <RedownloadSection
- slug={slug}
- videoId={videoId}
- handling={handling}
- defaultFormat={audioFormat}
- defaultQueueKey={defaultQueueKey}
- existingQueues={existingQueues}
- noAudio={noAudio}
- />
-
- {!hasTranscript && (
- <WhisperVideoSection
+ <PipelineStageCard
+ id="download"
+ title={noAudio ? "Download audio" : "Redownload audio"}
+ summary={downloadSummary}
+ defaultOpen={true}
+ tone={noAudio ? "attention" : "neutral"}
+ >
+ <RedownloadSection
slug={slug}
videoId={videoId}
+ handling={handling}
+ defaultFormat={audioFormat}
+ defaultQueueKey={defaultQueueKey}
existingQueues={existingQueues}
noAudio={noAudio}
/>
+ </PipelineStageCard>
+
+ {audioFiles.length > 0 && (
+ <PipelineStageCard
+ id="transcode"
+ title="Transcode"
+ summary={transcodeSummary}
+ defaultOpen={true}
+ tone="neutral"
+ >
+ <div className="flex flex-col gap-4">
+ {audioFiles.map((f) => (
+ <PerFileTranscodeRow
+ key={f.name}
+ slug={slug}
+ videoId={videoId}
+ file={f}
+ existingQueues={existingQueues}
+ />
+ ))}
+ </div>
+ </PipelineStageCard>
)}
+ <PipelineStageCard
+ id="transcribe"
+ title="Transcribe"
+ summary={transcribeSummary}
+ defaultOpen={true}
+ tone={hasTranscript ? "ok" : noAudio ? "neutral" : "attention"}
+ >
+ <div className="flex flex-col gap-4">
+ {!hasTranscript && (
+ <WhisperVideoSection
+ slug={slug}
+ videoId={videoId}
+ existingQueues={existingQueues}
+ noAudio={noAudio}
+ />
+ )}
+ {audioFiles.map((f) => (
+ <PerFileTranscribeRow
+ key={f.name}
+ slug={slug}
+ videoId={videoId}
+ file={f}
+ existingQueues={existingQueues}
+ hasTranscript={hasTranscript}
+ />
+ ))}
+ </div>
+ </PipelineStageCard>
+
{!hasTranscript && (
- <MarkUntranscribableSection slug={slug} videoId={videoId} />
+ <PipelineStageCard
+ id="mark-untranscribable"
+ title="Mark untranscribable"
+ summary="Stop the pipeline from retrying this video."
+ defaultOpen={true}
+ tone="neutral"
+ >
+ <MarkUntranscribableSection slug={slug} videoId={videoId} />
+ </PipelineStageCard>
)}
- <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>
+ <PipelineStageCard
+ id="files"
+ title="Files"
+ summary={`${files.length} file${files.length === 1 ? "" : "s"} in this directory.`}
+ defaultOpen={true}
+ tone="neutral"
+ >
+ <FilesList slug={slug} videoId={videoId} files={files} />
+ </PipelineStageCard>
- <DeleteVideoDirSection slug={slug} videoId={videoId} />
+ <PipelineStageCard
+ id="danger"
+ title="Danger zone"
+ summary={`Delete data/${videoId}/`}
+ defaultOpen={true}
+ tone="neutral"
+ >
+ <DeleteVideoDirSection slug={slug} videoId={videoId} />
+ </PipelineStageCard>
</div>
);
}
@@ -173,11 +249,8 @@ function RedownloadSection({
: "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-col gap-3">
+ <p className="text-sm text-zinc-500">{desc}</p>
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-3">
<label className="flex items-center gap-2 text-sm">
Format
@@ -228,7 +301,7 @@ function RedownloadSection({
/>
}
/>
- </section>
+ </div>
);
}
@@ -243,15 +316,13 @@ function WhisperVideoSection({
existingQueues: string[];
noAudio: boolean;
}) {
- // Run immediately in parallel by default — matches the per-audio-file
- // Transcribe button's convention.
const [queueKey, setQueueKey] = useState("");
const desc = noAudio
? "Download the audio and run whisper-cli, in one job. Useful when a YouTube video has no auto-subs."
: "Run whisper-cli on this video's audio. Skips the download phase since audio is already on disk.";
const actionLabel = `Whisper transcribe ${videoId}`;
return (
- <section className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded-md p-4">
+ <div className="flex flex-col gap-2">
<Heading title="Whisper transcribe" desc={desc} />
<StreamActionLog
trigger={() => whisperVideoAction(slug, videoId, queueKey)}
@@ -269,11 +340,11 @@ function WhisperVideoSection({
/>
}
/>
- </section>
+ </div>
);
}
-function AudioFileActions({
+function PerFileTranscribeRow({
slug,
videoId,
file,
@@ -286,101 +357,156 @@ function AudioFileActions({
existingQueues: string[];
hasTranscript: boolean;
}) {
+ const [transcribeQueue, setTranscribeQueue] = useState("");
+
+ return (
+ <div className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3">
+ <Heading
+ title={`Transcribe ${file.name}`}
+ 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>
+ );
+}
+
+function PerFileTranscodeRow({
+ slug,
+ videoId,
+ file,
+ existingQueues,
+}: {
+ slug: string;
+ videoId: string;
+ file: VideoFile;
+ existingQueues: string[];
+}) {
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("");
+ if (transcodeTargets.length === 0) return null;
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 className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3">
+ <Heading
+ title={`Transcode ${file.name}`}
+ 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 FilesList({
+ slug,
+ videoId,
+ files,
+}: {
+ slug: string;
+ videoId: string;
+ files: VideoFile[];
+}) {
+ if (files.length === 0) {
+ return <p className="text-sm text-zinc-500">Directory is empty.</p>;
+ }
+ return (
+ <ul
+ aria-label={`files for ${videoId}`}
+ className="flex flex-col gap-3"
+ >
+ {files.map((f) => {
+ const kind = mediaKind(f.name);
+ return (
+ <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>
+ {kind && (
+ <MediaPlayer
+ src={mediaUrl(slug, videoId, f.name)}
+ kind={kind}
+ filename={f.name}
+ />
+ )}
+ <DeleteFileButton slug={slug} videoId={videoId} filename={f.name} />
+ </li>
+ );
+ })}
+ </ul>
+ );
+}
+
function MarkUntranscribableSection({
slug,
videoId,
@@ -391,11 +517,12 @@ function MarkUntranscribableSection({
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."
- />
+ <div className="flex flex-col gap-2">
+ <p className="text-sm text-zinc-500">
+ Writes an empty transcript.json so the pipeline stops retrying. Removes
+ this video from the failed list. Reversible — just delete
+ transcript.json.
+ </p>
<button
type="button"
disabled={pending}
@@ -419,7 +546,7 @@ function MarkUntranscribableSection({
{error}
</span>
)}
- </section>
+ </div>
);
}
@@ -477,11 +604,11 @@ function DeleteVideoDirSection({
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.`}
- />
+ <div className="flex flex-col gap-3">
+ <p className="text-sm text-zinc-500">
+ Removes data/{videoId}/ and all of its contents. Type the video id to
+ confirm.
+ </p>
<form
action={formAction}
aria-label={`delete video directory ${videoId}`}
@@ -509,7 +636,7 @@ function DeleteVideoDirSection({
</p>
)}
</form>
- </section>
+ </div>
);
}
diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts
@@ -8,7 +8,9 @@ import {
} 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 { removeFailedTranscodings } from "yt-dlp-transcript-common/controller/removeFailedTranscodings";
import { cleanAudioFromTranscribed } from "yt-dlp-transcript-common/controller/cleanAudioFromTranscribed";
+import { cleanExtraAudioFormats } from "yt-dlp-transcript-common/controller/cleanExtraAudioFormats";
import { verifyTranscripts } from "yt-dlp-transcript-common/controller/verifyTranscripts";
import {
runManagedFunction,
@@ -25,14 +27,22 @@ function sanitizeConcurrency(c: number | undefined): number | undefined {
return Math.floor(c);
}
+function sanitizeAudioFormat(f: AudioFormat | undefined): AudioFormat | undefined {
+ if (f === undefined) return undefined;
+ return AUDIO_FORMAT_VALUES.includes(f) ? f : undefined;
+}
+
export async function transcribeMissingAction(
slug: string,
queueKey?: string,
concurrency?: number,
reverse?: boolean,
+ audioFormat?: AudioFormat,
+ strictAudioFormat?: boolean,
): Promise<StreamActionResult> {
const paths = getPaths();
const limit = sanitizeConcurrency(concurrency);
+ const fmt = sanitizeAudioFormat(audioFormat);
return runManagedFunction({
kind: "whisper-all",
queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(),
@@ -45,6 +55,8 @@ export async function transcribeMissingAction(
mode: "all",
concurrency: limit,
reverse: reverse === true,
+ audioFormat: fmt,
+ strictAudioFormat: fmt !== undefined && strictAudioFormat === true,
onLog,
signal,
});
@@ -60,9 +72,12 @@ export async function retryFailuresAction(
slug: string,
queueKey?: string,
concurrency?: number,
+ audioFormat?: AudioFormat,
+ strictAudioFormat?: boolean,
): Promise<StreamActionResult> {
const paths = getPaths();
const limit = sanitizeConcurrency(concurrency);
+ const fmt = sanitizeAudioFormat(audioFormat);
return runManagedFunction({
kind: "whisper-retry",
queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(),
@@ -74,6 +89,8 @@ export async function retryFailuresAction(
paths,
mode: "retry-failures",
concurrency: limit,
+ audioFormat: fmt,
+ strictAudioFormat: fmt !== undefined && strictAudioFormat === true,
onLog,
signal,
});
@@ -115,6 +132,56 @@ export async function transcodeFailuresAction(
});
}
+export async function removeFailedTranscodingsAction(
+ slug: string,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "remove-failed-transcodings",
+ queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(),
+ paths,
+ channelSlug: slug,
+ fn: async (onLog, signal) => {
+ const result = await removeFailedTranscodings({
+ channelSlug: slug,
+ paths,
+ onLog,
+ signal,
+ });
+ onLog(
+ `Remove failed-transcodings: ${result.removed} removed, ${result.missing} already gone.`,
+ );
+ revalidatePath(`/channels/${slug}`);
+ },
+ });
+}
+
+export async function cleanExtraAudioFormatsAction(
+ slug: string,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "clean-extra-audio-formats",
+ queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(),
+ paths,
+ channelSlug: slug,
+ fn: async (onLog, signal) => {
+ const result = await cleanExtraAudioFormats({
+ channelSlug: slug,
+ paths,
+ onLog,
+ signal,
+ });
+ onLog(
+ `Clean extra audio formats: ${result.removedFiles} file(s) removed across ${result.cleanedDirs} dir(s); inspected ${result.inspected}.`,
+ );
+ revalidatePath(`/channels/${slug}`);
+ },
+ });
+}
+
export async function cleanAudioAction(
slug: string,
queueKey?: string,
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -60,6 +60,10 @@ export async function updateChannelAction(
...existing,
...parsed.config,
};
+ // The form parser only emits boolean toggles when set, so an unchecked
+ // box leaves them undefined in `parsed.config`. The merge keeps the
+ // previous value, which is wrong — explicitly clear them here.
+ if (parsed.config.keepSourceVideo !== true) delete merged.keepSourceVideo;
if (existing.lastSyncedAt) merged.lastSyncedAt = existing.lastSyncedAt;
if (existing.lastFullDownloadAt) {
merged.lastFullDownloadAt = existing.lastFullDownloadAt;
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -22,107 +22,137 @@ export function ChannelForm({
{errorMessage}
</div>
)}
- <Field
- label="Name"
- name="name"
- required
- defaultValue={c?.name ?? ""}
- placeholder="Display name (e.g., TheQuartering)"
- />
- {isEdit ? (
+ <Section title="Identity">
<Field
- label="Slug"
- name="slug"
- readOnly
- defaultValue={initial!.slug}
- hint="Slug is fixed once a channel is created."
+ label="Name"
+ name="name"
+ required
+ defaultValue={c?.name ?? ""}
+ placeholder="Display name (e.g., TheQuartering)"
/>
- ) : (
+ {isEdit ? (
+ <Field
+ label="Slug"
+ name="slug"
+ readOnly
+ defaultValue={initial!.slug}
+ hint="Slug is fixed once a channel is created."
+ />
+ ) : (
+ <Field
+ label="Slug"
+ name="slug"
+ placeholder="kebab-case (auto-derived from name if blank)"
+ />
+ )}
+ </Section>
+ <Section title="Source">
+ <fieldset className="flex flex-col gap-2">
+ <legend className="text-sm font-medium">Handling</legend>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="radio"
+ name="handling"
+ value="youtube"
+ defaultChecked={c?.handling !== "transcribe"}
+ />
+ YouTube (auto-subtitles via yt-dlp)
+ </label>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="radio"
+ name="handling"
+ value="transcribe"
+ defaultChecked={c?.handling === "transcribe"}
+ />
+ Transcribe (download audio, run whisper-cpp)
+ </label>
+ </fieldset>
<Field
- label="Slug"
- name="slug"
- placeholder="kebab-case (auto-derived from name if blank)"
+ label="URL"
+ name="url"
+ defaultValue={c?.url ?? ""}
+ placeholder="https://www.youtube.com/@channel/videos"
+ hint="Channel or playlist URL passed to yt-dlp."
/>
- )}
- <fieldset className="flex flex-col gap-2">
- <legend className="text-sm font-medium">Handling</legend>
- <label className="flex items-center gap-2 text-sm">
- <input
- type="radio"
- name="handling"
- value="youtube"
- defaultChecked={c?.handling !== "transcribe"}
- />
- YouTube (auto-subtitles via 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 items-center gap-2 text-sm">
+ </Section>
+ <Section title="Transcription">
+ <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>
+ <select
+ name="audioFormat"
+ 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 (mp3)</option>
+ <option value="m4a">m4a</option>
+ <option value="mp3">mp3</option>
+ <option value="opus">opus</option>
+ </select>
+ </label>
+ <label className="flex items-start gap-2 text-sm">
<input
- type="radio"
- name="handling"
- value="transcribe"
- defaultChecked={c?.handling === "transcribe"}
+ type="checkbox"
+ name="keepSourceVideo"
+ defaultChecked={c?.keepSourceVideo === true}
+ className="mt-1"
/>
- Transcribe (download audio, run whisper-cpp)
+ <span className="flex flex-col gap-0.5">
+ <span className="font-medium">
+ Keep source video after extraction{" "}
+ <span className="text-zinc-500 font-normal">
+ (transcribe only)
+ </span>
+ </span>
+ <span className="text-xs text-zinc-500">
+ Adds <code>-k</code> to yt-dlp so the original container is kept
+ alongside <code>audio.<format></code>. Useful for debugging
+ corrupt-audio extraction issues.
+ </span>
+ </span>
</label>
- </fieldset>
- <Field
- label="URL"
- name="url"
- defaultValue={c?.url ?? ""}
- placeholder="https://www.youtube.com/@channel/videos"
- 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>
- <select
- name="audioFormat"
- 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 (mp3)</option>
- <option value="m4a">m4a</option>
- <option value="mp3">mp3</option>
- <option value="opus">opus</option>
- </select>
- </label>
- <Field
- label="Cookies from browser"
- name="cookiesFromBrowser"
- defaultValue={c?.cookiesFromBrowser ?? ""}
- placeholder="firefox / chrome / brave (optional)"
- hint="Forwarded to yt-dlp's --cookies-from-browser."
- />
- <label className="flex flex-col gap-1 text-sm">
- <span className="font-medium">
- Extra yt-dlp args
- <span className="text-zinc-500 font-normal"> (one per line)</span>
- </span>
- <textarea
- name="ytdlpExtraArgs"
- defaultValue={(c?.ytdlpExtraArgs ?? []).join("\n")}
- rows={4}
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono"
+ </Section>
+ <CollapsibleSection title="Advanced">
+ <Field
+ label="Cookies from browser"
+ name="cookiesFromBrowser"
+ defaultValue={c?.cookiesFromBrowser ?? ""}
+ placeholder="firefox / chrome / brave (optional)"
+ hint="Forwarded to yt-dlp's --cookies-from-browser."
/>
- </label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">
+ Extra yt-dlp args
+ <span className="text-zinc-500 font-normal"> (one per line)</span>
+ </span>
+ <textarea
+ name="ytdlpExtraArgs"
+ defaultValue={(c?.ytdlpExtraArgs ?? []).join("\n")}
+ rows={4}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono"
+ />
+ </label>
+ </CollapsibleSection>
<div className="flex gap-2">
<button
type="submit"
@@ -135,6 +165,52 @@ export function ChannelForm({
);
}
+function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+ <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded-md px-4 py-3">
+ <legend className="px-1 text-sm font-semibold">{title}</legend>
+ {children}
+ </fieldset>
+ );
+}
+
+function CollapsibleSection({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+ <details className="group border border-zinc-200 dark:border-zinc-800 rounded-md">
+ <summary className="cursor-pointer list-none flex items-center justify-between gap-2 px-4 py-2 text-sm font-semibold select-none rounded-md hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
+ <span>{title}</span>
+ <svg
+ aria-hidden="true"
+ className="w-4 h-4 text-zinc-400 transition-transform group-open:rotate-90"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ >
+ <path
+ fillRule="evenodd"
+ d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 0 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02z"
+ clipRule="evenodd"
+ />
+ </svg>
+ </summary>
+ <div className="px-4 pt-2 pb-4 flex flex-col gap-3 border-t border-zinc-100 dark:border-zinc-800">
+ {children}
+ </div>
+ </details>
+ );
+}
+
function Field({
label,
name,
diff --git a/editor/app/channels/components/parseChannelForm.ts b/editor/app/channels/components/parseChannelForm.ts
@@ -38,6 +38,7 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
audioFormatRaw === "opus"
? audioFormatRaw
: undefined;
+ const keepSourceVideo = formData.get("keepSourceVideo") != null;
const cookiesFromBrowser = stringOrUndef(formData, "cookiesFromBrowser");
const argsBlob = String(formData.get("ytdlpExtraArgs") ?? "").trim();
const ytdlpExtraArgs = argsBlob
@@ -54,6 +55,7 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
if (platform) config.platform = platform;
if (url) config.url = url;
if (audioFormat) config.audioFormat = audioFormat;
+ if (keepSourceVideo) config.keepSourceVideo = true;
if (cookiesFromBrowser) config.cookiesFromBrowser = cookiesFromBrowser;
if (ytdlpExtraArgs?.length) config.ytdlpExtraArgs = ytdlpExtraArgs;
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -33,10 +33,10 @@ export default async function ChannelsPage() {
<Th>Slug</Th>
<Th>Name</Th>
<Th>Handling</Th>
- <Th className="text-right">Transcripts</Th>
- <Th className="text-right">Downloads</Th>
<Th className="text-right">Playlist</Th>
<Th className="whitespace-nowrap">Last sync</Th>
+ <Th className="text-right">Downloads</Th>
+ <Th className="text-right">Transcripts</Th>
<Th className="whitespace-nowrap">Actions</Th>
</tr>
</thead>
@@ -58,9 +58,14 @@ export default async function ChannelsPage() {
<Td>{c.config.handling}</Td>
<Td
className="text-right"
- ariaLabel={`transcripts count for ${c.slug}`}
+ ariaLabel={`playlist count for ${c.slug}`}
>
- {c.transcriptCount}
+ {c.playlistCount ?? "—"}
+ </Td>
+ <Td className="text-xs text-zinc-500 whitespace-nowrap">
+ {c.config.lastSyncedAt
+ ? new Date(c.config.lastSyncedAt).toLocaleString()
+ : ""}
</Td>
<Td
className="text-right"
@@ -70,14 +75,9 @@ export default async function ChannelsPage() {
</Td>
<Td
className="text-right"
- ariaLabel={`playlist count for ${c.slug}`}
+ ariaLabel={`transcripts count for ${c.slug}`}
>
- {c.playlistCount ?? "—"}
- </Td>
- <Td className="text-xs text-zinc-500 whitespace-nowrap">
- {c.config.lastSyncedAt
- ? new Date(c.config.lastSyncedAt).toLocaleString()
- : ""}
+ {c.transcriptCount}
</Td>
<Td className="whitespace-nowrap">
<ChannelSyncButton
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -18,8 +18,8 @@ export async function generateMetadata(): Promise<Metadata> {
const NAV_LINKS = [
{ href: "/", label: "Dashboard" },
{ href: "/channels", label: "Channels" },
- { href: "/build", label: "Build" },
{ href: "/jobs", label: "Jobs" },
+ { href: "/build", label: "Build" },
{ href: "/settings", label: "Settings" },
];
diff --git a/editor/app/page.tsx b/editor/app/page.tsx
@@ -45,15 +45,15 @@ export default async function Dashboard() {
<dl className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<Stat label="channels" value={String(channels.length)} />
<Stat label="videos" value={totalVideos.toLocaleString()} />
- <Stat
- label="last build"
- value={lastBuild ? new Date(lastBuild).toLocaleString() : "never"}
- />
<StatLink
label="running jobs"
value={String(runningJobs.length)}
href="/jobs"
/>
+ <Stat
+ label="last build"
+ value={lastBuild ? new Date(lastBuild).toLocaleString() : "never"}
+ />
</dl>
{channels.length > 0 && (
diff --git a/editor/e2e/channels.spec.ts b/editor/e2e/channels.spec.ts
@@ -76,5 +76,7 @@ test("hides transcribe form fields for youtube channels (cosmetic)", async ({
}) => {
await resetData("one-transcribe-channel");
await page.goto("/channels/test-transcribe");
- await expect(page.getByLabel(/audio format/i)).toHaveValue("m4a");
+ await expect(
+ page.getByLabel(/audio format \(transcribe only\)/i),
+ ).toHaveValue("m4a");
});