commit ca410b8d73d84d0ae275117fa864efe11333b4ac
parent 273ac43ad6dbffda870eb02a83453067cbd3da9c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 19 May 2026 01:43:01 -0400
editor redesign
Diffstat:
8 files changed, 1276 insertions(+), 14 deletions(-)
diff --git a/editor/app/channels/[slug]/bulkVideoActions.ts b/editor/app/channels/[slug]/bulkVideoActions.ts
@@ -0,0 +1,170 @@
+"use server";
+
+import path from "node:path";
+import { readdir } from "node:fs/promises";
+import { revalidatePath } from "next/cache";
+import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ detectPlatform,
+ platformQueueKey,
+} from "yt-dlp-transcript-common/lib/platform";
+import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
+import {
+ downloadVideoPipelineAction,
+ markVideoUntranscribableAction,
+ transcribeOneAction,
+} from "./videos/[id]/videoActions";
+
+export type BulkActionSummary = {
+ ok: boolean;
+ attempted: number;
+ succeeded: number;
+ failures: Array<{ videoId: string; error: string }>;
+};
+
+function dataDirOf(slug: string, videoId: string): string {
+ return path.join(getPaths().channelsDir, slug, "data", videoId);
+}
+
+async function loadConfig(slug: string): Promise<ChannelConfig | null> {
+ return readChannelConfig(getPaths(), slug);
+}
+
+function resolveQueueKey(
+ config: ChannelConfig,
+ override?: string,
+): string | undefined {
+ if (override === undefined) return undefined;
+ const trimmed = override.trim();
+ if (!trimmed) {
+ return platformQueueKey(config.platform ?? detectPlatform(config.url));
+ }
+ return trimmed;
+}
+
+async function firstAudioFile(slug: string, videoId: string): Promise<string | null> {
+ const dir = dataDirOf(slug, videoId);
+ let entries: string[];
+ try {
+ entries = await readdir(dir);
+ } catch {
+ return null;
+ }
+ const audio = entries
+ .filter(
+ (e) =>
+ e.startsWith("audio.") &&
+ !e.includes(".tmp-") &&
+ !e.endsWith(".part") &&
+ !e.endsWith(".part.good") &&
+ !e.endsWith(".part.testing") &&
+ !e.endsWith(".info.json"),
+ )
+ .sort();
+ return audio[0] ?? null;
+}
+
+async function hasTranscript(slug: string, videoId: string): Promise<boolean> {
+ const dir = dataDirOf(slug, videoId);
+ try {
+ const entries = await readdir(dir);
+ return (
+ entries.includes("transcript.json") ||
+ entries.includes("transcript.en.vtt")
+ );
+ } catch {
+ return false;
+ }
+}
+
+export async function bulkTranscribeAction(
+ slug: string,
+ videoIds: string[],
+ queueKey?: string,
+): Promise<BulkActionSummary> {
+ const config = await loadConfig(slug);
+ if (!config) {
+ return {
+ ok: false,
+ attempted: 0,
+ succeeded: 0,
+ failures: [{ videoId: "", error: `Channel "${slug}" not found` }],
+ };
+ }
+ const resolved = resolveQueueKey(config, queueKey);
+ const failures: BulkActionSummary["failures"] = [];
+ let succeeded = 0;
+ for (const id of videoIds) {
+ if (await hasTranscript(slug, id)) {
+ failures.push({ videoId: id, error: "transcript already present" });
+ continue;
+ }
+ const audio = await firstAudioFile(slug, id);
+ if (!audio) {
+ failures.push({ videoId: id, error: "no audio file on disk" });
+ continue;
+ }
+ const r = await transcribeOneAction(slug, id, audio, resolved);
+ if (r.ok) succeeded++;
+ else failures.push({ videoId: id, error: r.error });
+ }
+ revalidatePath(`/channels/${slug}`);
+ return {
+ ok: failures.length === 0,
+ attempted: videoIds.length,
+ succeeded,
+ failures,
+ };
+}
+
+export async function bulkRetryDownloadAction(
+ slug: string,
+ videoIds: string[],
+ queueKey?: string,
+): Promise<BulkActionSummary> {
+ const config = await loadConfig(slug);
+ if (!config) {
+ return {
+ ok: false,
+ attempted: 0,
+ succeeded: 0,
+ failures: [{ videoId: "", error: `Channel "${slug}" not found` }],
+ };
+ }
+ const resolved = resolveQueueKey(config, queueKey);
+ const failures: BulkActionSummary["failures"] = [];
+ let succeeded = 0;
+ for (const id of videoIds) {
+ const r = await downloadVideoPipelineAction(slug, id, resolved);
+ if (r.ok) succeeded++;
+ else failures.push({ videoId: id, error: r.error });
+ }
+ revalidatePath(`/channels/${slug}`);
+ return {
+ ok: failures.length === 0,
+ attempted: videoIds.length,
+ succeeded,
+ failures,
+ };
+}
+
+export async function bulkMarkUntranscribableAction(
+ slug: string,
+ videoIds: string[],
+): Promise<BulkActionSummary> {
+ const failures: BulkActionSummary["failures"] = [];
+ let succeeded = 0;
+ for (const id of videoIds) {
+ const r = await markVideoUntranscribableAction(slug, id);
+ if (r.ok) succeeded++;
+ else failures.push({ videoId: id, error: r.error });
+ }
+ revalidatePath(`/channels/${slug}`);
+ return {
+ ok: failures.length === 0,
+ attempted: videoIds.length,
+ succeeded,
+ failures,
+ };
+}
diff --git a/editor/app/channels/[slug]/components/VideoListPane.tsx b/editor/app/channels/[slug]/components/VideoListPane.tsx
@@ -0,0 +1,336 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useMemo, useState, useTransition } from "react";
+import type { VideoRow, VideoFilter } from "../lib/videoRows";
+import { filterRows } from "../lib/videoRows";
+import {
+ bulkMarkUntranscribableAction,
+ bulkRetryDownloadAction,
+ bulkTranscribeAction,
+ type BulkActionSummary,
+} from "../bulkVideoActions";
+
+type Props = {
+ slug: string;
+ rows: VideoRow[];
+ selectedVideoId: string | null;
+ initialFilter: VideoFilter;
+ initialQuery: string;
+};
+
+const FILTER_OPTIONS: { value: VideoFilter; label: string }[] = [
+ { value: "all", label: "All" },
+ { value: "needs_action", label: "Needs action" },
+ { value: "failed", label: "Failed" },
+ { value: "no_audio", label: "No audio" },
+ { value: "downloaded_no_transcript", label: "No transcript" },
+ { value: "untranscribable", label: "Untranscribable" },
+ { value: "running", label: "Running" },
+ { value: "transcribed", label: "Transcribed" },
+];
+
+function buildHref(
+ slug: string,
+ base: URLSearchParams,
+ updates: Record<string, string | null>,
+): string {
+ const params = new URLSearchParams(base);
+ for (const [k, v] of Object.entries(updates)) {
+ if (v === null) params.delete(k);
+ else params.set(k, v);
+ }
+ const q = params.toString();
+ return q ? `/channels/${slug}?${q}` : `/channels/${slug}`;
+}
+
+export function VideoListPane({
+ slug,
+ rows,
+ selectedVideoId,
+ initialFilter,
+ initialQuery,
+}: Props) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [filter, setFilter] = useState<VideoFilter>(initialFilter);
+ const [query, setQuery] = useState(initialQuery);
+ const [selected, setSelected] = useState<Set<string>>(new Set());
+ const [pending, startTransition] = useTransition();
+ const [lastResult, setLastResult] = useState<BulkActionSummary | null>(null);
+
+ const visibleRows = useMemo(() => {
+ const filtered = filterRows(rows, filter);
+ const q = query.trim().toLowerCase();
+ if (!q) return filtered;
+ return filtered.filter((r) => r.id.toLowerCase().includes(q));
+ }, [rows, filter, query]);
+
+ const allSelected =
+ visibleRows.length > 0 && visibleRows.every((r) => selected.has(r.id));
+ const someSelected = selected.size > 0;
+
+ function toggleOne(id: string) {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }
+
+ function toggleAll() {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ const everyVisible = visibleRows.every((r) => next.has(r.id));
+ if (everyVisible) {
+ for (const r of visibleRows) next.delete(r.id);
+ } else {
+ for (const r of visibleRows) next.add(r.id);
+ }
+ return next;
+ });
+ }
+
+ function changeFilter(value: VideoFilter) {
+ setFilter(value);
+ const params = new URLSearchParams(searchParams.toString());
+ if (value === "all") params.delete("filter");
+ else params.set("filter", value);
+ const q = params.toString();
+ router.replace(q ? `/channels/${slug}?${q}` : `/channels/${slug}`, {
+ scroll: false,
+ });
+ }
+
+ function changeQuery(value: string) {
+ setQuery(value);
+ const params = new URLSearchParams(searchParams.toString());
+ if (value.trim()) params.set("q", value);
+ else params.delete("q");
+ const q = params.toString();
+ router.replace(q ? `/channels/${slug}?${q}` : `/channels/${slug}`, {
+ scroll: false,
+ });
+ }
+
+ const baseParams = new URLSearchParams(searchParams.toString());
+
+ function doBulk(
+ action: (slug: string, ids: string[]) => Promise<BulkActionSummary>,
+ ) {
+ const ids = Array.from(selected);
+ if (ids.length === 0) return;
+ setLastResult(null);
+ startTransition(async () => {
+ const result = await action(slug, ids);
+ setLastResult(result);
+ if (result.ok) setSelected(new Set());
+ router.refresh();
+ });
+ }
+
+ return (
+ <div className="flex flex-col gap-2 min-h-0">
+ <div className="flex flex-col gap-2">
+ <input
+ type="search"
+ placeholder="Search video id…"
+ value={query}
+ onChange={(e) => changeQuery(e.target.value)}
+ aria-label="search videos"
+ className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1.5 text-sm"
+ />
+ <div className="flex flex-wrap gap-1.5">
+ {FILTER_OPTIONS.map((opt) => {
+ const active = filter === opt.value;
+ return (
+ <button
+ key={opt.value}
+ type="button"
+ aria-pressed={active}
+ onClick={() => changeFilter(opt.value)}
+ className={`text-xs rounded-full border px-2.5 py-1 ${
+ active
+ ? "border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-950 text-blue-900 dark:text-blue-100"
+ : "border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ }`}
+ >
+ {opt.label}
+ </button>
+ );
+ })}
+ </div>
+ <div className="flex items-center justify-between text-xs text-zinc-500">
+ <span>
+ {visibleRows.length} of {rows.length} videos
+ </span>
+ {visibleRows.length > 0 && (
+ <label className="inline-flex items-center gap-1.5 cursor-pointer">
+ <input
+ type="checkbox"
+ checked={allSelected}
+ onChange={toggleAll}
+ aria-label="select all visible"
+ />
+ <span>select all visible</span>
+ </label>
+ )}
+ </div>
+ </div>
+
+ <ul
+ aria-label="videos"
+ className="flex-1 min-h-0 overflow-y-auto flex flex-col divide-y divide-zinc-100 dark:divide-zinc-800 border border-zinc-200 dark:border-zinc-800 rounded"
+ >
+ {visibleRows.length === 0 ? (
+ <li className="px-3 py-4 text-sm text-zinc-500">
+ No videos match this filter.
+ </li>
+ ) : (
+ visibleRows.map((row) => {
+ const isActive = row.id === selectedVideoId;
+ const isSelected = selected.has(row.id);
+ const href = buildHref(slug, baseParams, { video: row.id });
+ return (
+ <li
+ key={row.id}
+ className={`flex items-center gap-2 px-2 py-1.5 text-sm ${
+ isActive
+ ? "bg-blue-50 dark:bg-blue-950/40"
+ : "hover:bg-zinc-50 dark:hover:bg-zinc-800/40"
+ }`}
+ >
+ <input
+ type="checkbox"
+ checked={isSelected}
+ onChange={() => toggleOne(row.id)}
+ aria-label={`select ${row.id}`}
+ className="flex-shrink-0"
+ />
+ <Link
+ href={href}
+ scroll={false}
+ className="flex-1 min-w-0 flex items-center gap-2"
+ aria-label={`open ${row.id}`}
+ aria-current={isActive ? "page" : undefined}
+ >
+ <StatusGlyphs row={row} />
+ <span className="font-mono text-xs truncate">{row.id}</span>
+ {row.running && (
+ <span
+ aria-label="job running"
+ className="text-[10px] uppercase tracking-wide rounded-full border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 px-1.5 py-0.5 leading-none"
+ >
+ running
+ </span>
+ )}
+ </Link>
+ </li>
+ );
+ })
+ )}
+ </ul>
+
+ {someSelected && (
+ <div
+ aria-label="bulk action bar"
+ className="sticky bottom-0 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md p-2 flex flex-wrap items-center gap-2 text-sm shadow-sm"
+ >
+ <span className="font-medium">{selected.size} selected</span>
+ <div className="flex flex-wrap gap-1.5 ml-auto">
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => doBulk(bulkTranscribeAction)}
+ className="px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50"
+ >
+ Transcribe
+ </button>
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => doBulk(bulkRetryDownloadAction)}
+ className="px-2 py-1 rounded border border-zinc-300 dark:border-zinc-700 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50"
+ >
+ Retry download
+ </button>
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => {
+ if (
+ !confirm(
+ `Mark ${selected.size} video${selected.size === 1 ? "" : "s"} untranscribable?`,
+ )
+ )
+ return;
+ doBulk(bulkMarkUntranscribableAction);
+ }}
+ className="px-2 py-1 rounded border border-amber-300 dark:border-amber-800 text-amber-700 dark:text-amber-300 text-xs hover:bg-amber-50 dark:hover:bg-amber-950 disabled:opacity-50"
+ >
+ Mark untranscribable
+ </button>
+ <button
+ type="button"
+ disabled={pending}
+ onClick={() => setSelected(new Set())}
+ className="px-2 py-1 rounded border border-transparent text-xs text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50"
+ >
+ Clear
+ </button>
+ </div>
+ {lastResult && (
+ <p
+ role="status"
+ aria-label="bulk action result"
+ className={`basis-full text-xs ${lastResult.ok ? "text-emerald-700 dark:text-emerald-300" : "text-amber-700 dark:text-amber-300"}`}
+ >
+ {lastResult.succeeded}/{lastResult.attempted} succeeded
+ {lastResult.failures.length > 0 &&
+ ` — ${lastResult.failures.length} failed`}
+ </p>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+function StatusGlyphs({ row }: { row: VideoRow }) {
+ const dlColor = row.partial
+ ? "bg-amber-500"
+ : row.downloaded
+ ? "bg-emerald-500"
+ : "bg-zinc-300 dark:bg-zinc-700";
+ const tcColor =
+ row.transcoded === null
+ ? "bg-transparent border border-zinc-300 dark:border-zinc-700"
+ : row.transcoded
+ ? "bg-emerald-500"
+ : "bg-zinc-300 dark:bg-zinc-700";
+ const trColor = row.untranscribable
+ ? "bg-zinc-400 dark:bg-zinc-600"
+ : row.transcribed
+ ? "bg-emerald-500"
+ : "bg-zinc-300 dark:bg-zinc-700";
+ const failureColor =
+ row.failedTranscription || row.failedTranscoding ? "bg-red-500" : null;
+ return (
+ <span
+ aria-label={`status ${row.status}`}
+ className="inline-flex items-center gap-1 flex-shrink-0"
+ >
+ <span title="downloaded" className={`w-2 h-2 rounded-full ${dlColor}`} />
+ <span title="transcoded" className={`w-2 h-2 rounded-full ${tcColor}`} />
+ <span title="transcribed" className={`w-2 h-2 rounded-full ${trColor}`} />
+ {failureColor && (
+ <span
+ title="failure recorded"
+ className={`w-2 h-2 rounded-full ${failureColor} ml-0.5`}
+ />
+ )}
+ </span>
+ );
+}
diff --git a/editor/app/channels/[slug]/lib/videoRows.ts b/editor/app/channels/[slug]/lib/videoRows.ts
@@ -0,0 +1,79 @@
+export type VideoRowStatus =
+ | "transcribed"
+ | "downloaded_no_transcript"
+ | "no_audio"
+ | "untranscribable"
+ | "partial_download"
+ | "not_downloaded"
+ | "failed";
+
+export type VideoRow = {
+ id: string;
+ downloaded: boolean;
+ transcribed: boolean;
+ // null when the channel doesn't apply transcoding.
+ transcoded: boolean | null;
+ untranscribable: boolean;
+ partial: boolean;
+ failedTranscription: boolean;
+ failedTranscoding: boolean;
+ // Members-only / deleted / private — listed in the channel but unactionable.
+ excluded: boolean;
+ running: boolean;
+ status: VideoRowStatus;
+};
+
+export type VideoFilter =
+ | "all"
+ | "needs_action"
+ | "failed"
+ | "no_audio"
+ | "downloaded_no_transcript"
+ | "transcribed"
+ | "untranscribable"
+ | "running";
+
+export function filterRows(rows: VideoRow[], filter: VideoFilter): VideoRow[] {
+ switch (filter) {
+ case "all":
+ return rows;
+ case "needs_action":
+ return rows.filter(
+ (r) =>
+ (r.status !== "transcribed" &&
+ r.status !== "untranscribable" &&
+ !r.excluded) ||
+ r.failedTranscription ||
+ r.failedTranscoding,
+ );
+ case "failed":
+ return rows.filter((r) => r.failedTranscription || r.failedTranscoding);
+ case "no_audio":
+ return rows.filter(
+ (r) => r.status === "no_audio" || r.status === "not_downloaded",
+ );
+ case "downloaded_no_transcript":
+ return rows.filter((r) => r.status === "downloaded_no_transcript");
+ case "transcribed":
+ return rows.filter((r) => r.status === "transcribed");
+ case "untranscribable":
+ return rows.filter((r) => r.untranscribable);
+ case "running":
+ return rows.filter((r) => r.running);
+ }
+}
+
+export function parseFilter(raw: string | undefined): VideoFilter {
+ switch (raw) {
+ case "needs_action":
+ case "failed":
+ case "no_audio":
+ case "downloaded_no_transcript":
+ case "transcribed":
+ case "untranscribable":
+ case "running":
+ return raw;
+ default:
+ return "all";
+ }
+}
diff --git a/editor/app/channels/[slug]/lib/videoRowsServer.ts b/editor/app/channels/[slug]/lib/videoRowsServer.ts
@@ -0,0 +1,95 @@
+import "server-only";
+import type { Dirent } from "node:fs";
+import { readdir } from "node:fs/promises";
+import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
+import type { JobRecord } from "yt-dlp-transcript-common/jobs/registry";
+import { normalizeBuckets } from "./stageStatus";
+import type { VideoRow, VideoRowStatus } from "./videoRows";
+
+export async function readDataDirVideoIds(
+ channelDataDir: string,
+): Promise<string[]> {
+ let entries: Dirent[];
+ try {
+ entries = await readdir(channelDataDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+ return entries
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
+ .map((e) => e.name);
+}
+
+export type ComputeRowsInput = {
+ channelDataDirIds: string[];
+ snapshot: ChannelSnapshot;
+ failedTranscriptionIds: string[];
+ failedTranscodingIds: string[];
+ transcodeApplies: boolean;
+ runningJobs: JobRecord[];
+ excludedIds: Set<string>;
+};
+
+export function computeVideoRows(input: ComputeRowsInput): VideoRow[] {
+ const buckets = normalizeBuckets(input.snapshot.buckets);
+ const undownloaded = new Set(input.snapshot.undownloadedIds ?? []);
+ const onDisk = new Set(input.channelDataDirIds);
+ const noTranscript = new Set(buckets.noTranscript);
+ const downloadedNoTranscript = new Set(buckets.downloadedNoTranscript);
+ const untranscoded = new Set(buckets.untranscoded);
+ const untranscribable = new Set(buckets.untranscribable);
+ const partial = new Set(buckets.partialDownloads);
+ const failedTranscription = new Set(input.failedTranscriptionIds);
+ const failedTranscoding = new Set(input.failedTranscodingIds);
+
+ const runningIds = new Set<string>();
+ for (const j of input.runningJobs) {
+ if (j.videoId && (j.status === "queued" || j.status === "running")) {
+ runningIds.add(j.videoId);
+ }
+ }
+
+ const allIds = new Set<string>([...onDisk, ...undownloaded]);
+ const rows: VideoRow[] = [];
+ for (const id of allIds) {
+ const isOnDisk = onDisk.has(id);
+ const isUndownloaded = undownloaded.has(id) && !isOnDisk;
+ const isUntranscribable = untranscribable.has(id);
+ const isPartial = partial.has(id);
+ const isFailedT = failedTranscription.has(id);
+ const isFailedX = failedTranscoding.has(id);
+ const inNoTranscript = noTranscript.has(id);
+ const inDownloadedNoTranscript = downloadedNoTranscript.has(id);
+ const inUntranscoded = untranscoded.has(id);
+
+ const downloaded = isOnDisk && !inNoTranscript;
+ const transcribed = isOnDisk && !inNoTranscript && !inDownloadedNoTranscript;
+ const transcoded = input.transcodeApplies ? !inUntranscoded : null;
+ const excluded = input.excludedIds.has(id);
+
+ let status: VideoRowStatus;
+ if (isFailedT || isFailedX) status = "failed";
+ else if (isPartial) status = "partial_download";
+ else if (isUndownloaded) status = "not_downloaded";
+ else if (isUntranscribable) status = "untranscribable";
+ else if (inNoTranscript) status = "no_audio";
+ else if (inDownloadedNoTranscript) status = "downloaded_no_transcript";
+ else status = "transcribed";
+
+ rows.push({
+ id,
+ downloaded,
+ transcribed,
+ transcoded,
+ untranscribable: isUntranscribable,
+ partial: isPartial,
+ failedTranscription: isFailedT,
+ failedTranscoding: isFailedX,
+ excluded,
+ running: runningIds.has(id),
+ status,
+ });
+ }
+ rows.sort((a, b) => a.id.localeCompare(b.id));
+ return rows;
+}
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -1,3 +1,6 @@
+import path from "node:path";
+import { readdir, readFile, stat } from "node:fs/promises";
+import type { Dirent } from "node:fs";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
@@ -15,6 +18,7 @@ import {
type ShardConfig,
type ShardOp,
} from "yt-dlp-transcript-common/controller/shard";
+import { loadDownloadOutcome } from "yt-dlp-transcript-common/lib/downloadOutcome-server";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import {
@@ -34,6 +38,13 @@ 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 { VideoListPane } from "./components/VideoListPane";
+import { VideoPanel, type VideoFile } from "./videos/[id]/components/VideoPanel";
+import { parseFilter, filterRows } from "./lib/videoRows";
+import {
+ computeVideoRows,
+ readDataDirVideoIds,
+} from "./lib/videoRowsServer";
import {
computeStageStatuses,
normalizeBuckets,
@@ -47,6 +58,42 @@ import {
export const dynamic = "force-dynamic";
+async function loadVideoDir(
+ channelDataDir: string,
+ videoId: string,
+): Promise<{ files: VideoFile[] }> {
+ const dir = path.join(channelDataDir, videoId);
+ let entries: Dirent[];
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch {
+ return { files: [] };
+ }
+ const files: VideoFile[] = [];
+ for (const e of entries) {
+ if (!e.isFile()) continue;
+ if (e.name.includes(".tmp-")) continue;
+ const s = await stat(path.join(dir, e.name));
+ files.push({ name: e.name, size: s.size, mtime: s.mtimeMs });
+ }
+ files.sort((a, b) => a.name.localeCompare(b.name));
+ return { files };
+}
+
+async function loadVideoTitle(
+ channelDataDir: string,
+ videoId: string,
+): Promise<string | null> {
+ const file = path.join(channelDataDir, videoId, "metadata.info.json");
+ try {
+ const raw = await readFile(file, "utf8");
+ const j = JSON.parse(raw);
+ return typeof j?.title === "string" ? j.title : null;
+ } catch {
+ return null;
+ }
+}
+
export async function generateMetadata({
params,
}: {
@@ -60,10 +107,19 @@ export async function generateMetadata({
export default async function ChannelDetailPage({
params,
+ searchParams,
}: {
params: Promise<{ slug: string }>;
+ searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
+ const sp = (await searchParams) ?? {};
+ const rawVideo = sp.video;
+ const selectedVideoId =
+ typeof rawVideo === "string" && rawVideo.length > 0 ? rawVideo : null;
+ const rawFilter = typeof sp.filter === "string" ? sp.filter : undefined;
+ const filter = parseFilter(rawFilter);
+ const queryRaw = typeof sp.q === "string" ? sp.q : "";
const paths = getPaths();
const config = await readChannelConfig(paths, slug);
if (!config) notFound();
@@ -239,6 +295,70 @@ export default async function ChannelDetailPage({
);
}
+ const channelDataDir = path.join(paths.channelsDir, slug, "data");
+ const channelDataDirIds = await readDataDirVideoIds(channelDataDir);
+ const rows = computeVideoRows({
+ channelDataDirIds,
+ snapshot,
+ failedTranscriptionIds: failedVideoIds,
+ failedTranscodingIds,
+ transcodeApplies,
+ runningJobs,
+ excludedIds: excludedDownloadIds,
+ });
+ const filteredRows = filterRows(rows, filter);
+ const q = queryRaw.trim().toLowerCase();
+ const orderedRows = q
+ ? filteredRows.filter((r) => r.id.toLowerCase().includes(q))
+ : filteredRows;
+ const selectedIndex = selectedVideoId
+ ? orderedRows.findIndex((r) => r.id === selectedVideoId)
+ : -1;
+
+ const buildVideoHref = (videoId: string): string => {
+ const params = new URLSearchParams();
+ if (filter !== "all") params.set("filter", filter);
+ if (queryRaw) params.set("q", queryRaw);
+ params.set("video", videoId);
+ return `/channels/${slug}?${params.toString()}`;
+ };
+
+ let selectedPanel: ReactNode = null;
+ let selectedTitle: string | null = null;
+ if (selectedVideoId) {
+ const [dirData, title, outcome] = await Promise.all([
+ loadVideoDir(channelDataDir, selectedVideoId),
+ loadVideoTitle(channelDataDir, selectedVideoId),
+ loadDownloadOutcome(path.join(channelDataDir, selectedVideoId)),
+ ]);
+ selectedTitle = title;
+ const prevRow =
+ selectedIndex > 0 ? orderedRows[selectedIndex - 1] : undefined;
+ const nextRow =
+ selectedIndex >= 0 && selectedIndex < orderedRows.length - 1
+ ? orderedRows[selectedIndex + 1]
+ : undefined;
+ selectedPanel = (
+ <VideoPanel
+ slug={slug}
+ videoId={selectedVideoId}
+ files={dirData.files}
+ handling={config.handling}
+ defaultQueueKey={platformDefaultQueueKey}
+ existingQueues={existingQueues}
+ downloadOutcome={outcome}
+ channelAudioFormat={config.audioFormat}
+ prevHref={prevRow ? buildVideoHref(prevRow.id) : undefined}
+ nextHref={nextRow ? buildVideoHref(nextRow.id) : undefined}
+ position={
+ selectedIndex >= 0
+ ? { index: selectedIndex, total: orderedRows.length }
+ : undefined
+ }
+ />
+ );
+ }
+
return (
<div className="flex flex-col gap-6">
<StatusHeader
@@ -262,7 +382,64 @@ export default async function ChannelDetailPage({
hideChannelSlug
/>
- <StagesView stages={stages} stageOrder={stageOrder} panels={panels} />
+ <section
+ aria-label="videos"
+ className="grid lg:grid-cols-[24rem_minmax(0,1fr)] gap-4 lg:gap-6 min-h-[calc(100vh-22rem)]"
+ >
+ <div className="flex flex-col min-h-0 lg:sticky lg:top-4 lg:self-start lg:max-h-[calc(100vh-2rem)]">
+ <VideoListPane
+ slug={slug}
+ rows={rows}
+ selectedVideoId={selectedVideoId}
+ initialFilter={filter}
+ initialQuery={queryRaw}
+ />
+ </div>
+ <div className="min-w-0">
+ {selectedPanel ?? (
+ <EmptyVideoPanel
+ total={rows.length}
+ filtered={orderedRows.length}
+ />
+ )}
+ {selectedVideoId && selectedTitle && (
+ <p className="text-sm text-zinc-500 mt-2 truncate" aria-label="video title">
+ {selectedTitle}
+ </p>
+ )}
+ </div>
+ </section>
+
+ <details
+ open
+ className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"
+ >
+ <summary className="cursor-pointer px-4 py-3 text-sm font-medium select-none">
+ Channel pipeline & settings
+ </summary>
+ <div className="px-4 pb-4">
+ <StagesView stages={stages} stageOrder={stageOrder} panels={panels} />
+ </div>
+ </details>
+ </div>
+ );
+}
+
+function EmptyVideoPanel({
+ total,
+ filtered,
+}: {
+ total: number;
+ filtered: number;
+}) {
+ return (
+ <div className="flex items-center justify-center text-sm text-zinc-500 border border-dashed border-zinc-200 dark:border-zinc-800 rounded-md p-12 text-center">
+ <div className="flex flex-col gap-1">
+ <p>Select a video from the list to view actions.</p>
+ <p className="text-xs">
+ {filtered} matching · {total} total
+ </p>
+ </div>
</div>
);
}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -1,5 +1,6 @@
"use client";
+import Link from "next/link";
import { useActionState, useState, useTransition } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
import {
@@ -39,6 +40,9 @@ type Props = {
existingQueues: string[];
downloadOutcome: DownloadOutcomeRecord | null;
channelAudioFormat?: AudioFormat;
+ prevHref?: string;
+ nextHref?: string;
+ position?: { index: number; total: number };
};
const AUDIO_EXTS = new Set([
@@ -116,6 +120,9 @@ export function VideoPanel({
existingQueues,
downloadOutcome,
channelAudioFormat,
+ prevHref,
+ nextHref,
+ position,
}: Props) {
const audioFiles = files.filter((f) => isAudioFile(f.name));
const transcodeSources = files.filter((f) => isTranscodeSource(f.name));
@@ -123,6 +130,7 @@ export function VideoPanel({
const hasYtVtt = files.some((f) => f.name === "transcript.en.vtt");
const hasTranscript = hasTranscriptJson || hasYtVtt;
const noAudio = audioFiles.length === 0;
+ const downloadFailed = downloadOutcome?.status === "failed";
const downloadSummary = noAudio
? handling === "youtube"
@@ -139,15 +147,31 @@ export function VideoPanel({
? `Convert ${transcodeSources.length} source file${transcodeSources.length === 1 ? "" : "s"} to another format.`
: "Nothing to transcode.";
+ const showNav = !!prevHref || !!nextHref || !!position;
+
return (
- <div className="flex flex-col gap-6">
+ <div className="flex flex-col gap-4">
+ {showNav && (
+ <VideoNavStrip
+ prevHref={prevHref}
+ nextHref={nextHref}
+ position={position}
+ videoId={videoId}
+ />
+ )}
+ <PipelineStatusStrip
+ downloaded={!noAudio}
+ transcoded={transcodeSources.length === 0}
+ transcribed={hasTranscript}
+ downloadFailed={downloadFailed}
+ />
{downloadOutcome && <DownloadOutcomeBadge outcome={downloadOutcome} />}
<PipelineStageCard
id="download"
title={noAudio ? "Download audio" : "Redownload audio"}
summary={downloadSummary}
defaultOpen={true}
- tone={noAudio ? "attention" : "neutral"}
+ tone={noAudio ? "attention" : downloadFailed ? "danger" : "neutral"}
>
<RedownloadSection
slug={slug}
@@ -238,6 +262,113 @@ export function VideoPanel({
);
}
+function VideoNavStrip({
+ prevHref,
+ nextHref,
+ position,
+ videoId,
+}: {
+ prevHref?: string;
+ nextHref?: string;
+ position?: { index: number; total: number };
+ videoId: string;
+}) {
+ return (
+ <div className="flex items-center justify-between gap-3 text-sm">
+ {prevHref ? (
+ <Link
+ href={prevHref}
+ aria-label="previous video"
+ className="px-2 py-1 rounded border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+ ◀ prev
+ </Link>
+ ) : (
+ <span
+ aria-hidden="true"
+ className="px-2 py-1 rounded border border-zinc-100 dark:border-zinc-900 text-zinc-400 dark:text-zinc-600"
+ >
+ ◀ prev
+ </span>
+ )}
+ <span className="font-mono text-xs text-zinc-500 truncate">
+ {position
+ ? `${position.index + 1} / ${position.total} · ${videoId}`
+ : videoId}
+ </span>
+ {nextHref ? (
+ <Link
+ href={nextHref}
+ aria-label="next video"
+ className="px-2 py-1 rounded border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+ next ▶
+ </Link>
+ ) : (
+ <span
+ aria-hidden="true"
+ className="px-2 py-1 rounded border border-zinc-100 dark:border-zinc-900 text-zinc-400 dark:text-zinc-600"
+ >
+ next ▶
+ </span>
+ )}
+ </div>
+ );
+}
+
+function PipelineStatusStrip({
+ downloaded,
+ transcoded,
+ transcribed,
+ downloadFailed,
+}: {
+ downloaded: boolean;
+ transcoded: boolean;
+ transcribed: boolean;
+ downloadFailed: boolean;
+}) {
+ return (
+ <div
+ role="status"
+ aria-label="pipeline status"
+ className="flex flex-wrap items-center gap-3 text-xs text-zinc-600 dark:text-zinc-300"
+ >
+ <StatusDot
+ on={downloaded}
+ label="Downloaded"
+ tone={downloadFailed && !downloaded ? "danger" : undefined}
+ />
+ <StatusDot on={transcoded} label="Transcoded" />
+ <StatusDot on={transcribed} label="Transcribed" />
+ </div>
+ );
+}
+
+function StatusDot({
+ on,
+ label,
+ tone,
+}: {
+ on: boolean;
+ label: string;
+ tone?: "danger";
+}) {
+ const color = on
+ ? "bg-emerald-500"
+ : tone === "danger"
+ ? "bg-red-500"
+ : "bg-zinc-300 dark:bg-zinc-700";
+ return (
+ <span className="inline-flex items-center gap-1.5">
+ <span
+ aria-hidden="true"
+ className={`inline-block w-2 h-2 rounded-full ${color}`}
+ />
+ <span>{label}</span>
+ </span>
+ );
+}
+
type DownloadMode = "pipeline" | "whisper";
function RedownloadSection({
diff --git a/editor/app/components/CommandPalette.tsx b/editor/app/components/CommandPalette.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+type Command = {
+ id: string;
+ label: string;
+ hint?: string;
+ run: () => void;
+};
+
+function channelSlugFrom(pathname: string): string | null {
+ const m = /^\/channels\/([^/?#]+)/.exec(pathname);
+ return m ? decodeURIComponent(m[1]) : null;
+}
+
+function videoIdFrom(
+ pathname: string,
+ search: ReturnType<typeof useSearchParams>,
+): string | null {
+ const m = /^\/channels\/[^/]+\/videos\/([^/?#]+)/.exec(pathname);
+ if (m) return decodeURIComponent(m[1]);
+ const fromQuery = search.get("video");
+ return fromQuery && fromQuery.length > 0 ? fromQuery : null;
+}
+
+export function CommandPalette() {
+ const router = useRouter();
+ const pathname = usePathname() ?? "/";
+ const search = useSearchParams();
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [highlight, setHighlight] = useState(0);
+ const inputRef = useRef<HTMLInputElement | null>(null);
+
+ const slug = channelSlugFrom(pathname);
+ const videoId = videoIdFrom(pathname, search);
+
+ const close = useCallback(() => {
+ setOpen(false);
+ setQuery("");
+ setHighlight(0);
+ }, []);
+
+ const commands = useMemo<Command[]>(() => {
+ const out: Command[] = [
+ {
+ id: "nav-dashboard",
+ label: "Dashboard",
+ hint: "navigation",
+ run: () => router.push("/"),
+ },
+ {
+ id: "nav-channels",
+ label: "Channels",
+ hint: "navigation",
+ run: () => router.push("/channels"),
+ },
+ {
+ id: "nav-jobs",
+ label: "Jobs",
+ hint: "navigation",
+ run: () => router.push("/jobs"),
+ },
+ {
+ id: "nav-build",
+ label: "Build",
+ hint: "navigation",
+ run: () => router.push("/build"),
+ },
+ {
+ id: "nav-settings",
+ label: "Settings",
+ hint: "navigation",
+ run: () => router.push("/settings"),
+ },
+ ];
+
+ if (slug) {
+ const setFilter = (filter: string, label: string) => ({
+ id: `filter-${filter}`,
+ label,
+ hint: `channel: ${slug}`,
+ run: () => {
+ const params = new URLSearchParams(search.toString());
+ if (filter === "all") params.delete("filter");
+ else params.set("filter", filter);
+ params.delete("video");
+ const q = params.toString();
+ router.push(q ? `/channels/${slug}?${q}` : `/channels/${slug}`);
+ },
+ });
+ out.push(
+ setFilter("needs_action", "Filter: Needs action"),
+ setFilter("failed", "Filter: Failed"),
+ setFilter("no_audio", "Filter: No audio"),
+ setFilter("downloaded_no_transcript", "Filter: No transcript"),
+ setFilter("untranscribable", "Filter: Untranscribable"),
+ setFilter("running", "Filter: Running"),
+ setFilter("all", "Filter: All (clear)"),
+ {
+ id: "channel-pipeline",
+ label: "Open channel pipeline panel",
+ hint: `channel: ${slug}`,
+ run: () => router.push(`/channels/${slug}`),
+ },
+ );
+ }
+
+ if (slug && videoId) {
+ out.push({
+ id: "deselect-video",
+ label: "Close video panel",
+ hint: `video: ${videoId}`,
+ run: () => {
+ const params = new URLSearchParams(search.toString());
+ params.delete("video");
+ const q = params.toString();
+ router.push(q ? `/channels/${slug}?${q}` : `/channels/${slug}`);
+ },
+ });
+ }
+
+ return out;
+ }, [router, search, slug, videoId]);
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return commands;
+ return commands.filter(
+ (c) =>
+ c.label.toLowerCase().includes(q) ||
+ (c.hint?.toLowerCase().includes(q) ?? false),
+ );
+ }, [commands, query]);
+
+ useEffect(() => {
+ function onKey(e: KeyboardEvent) {
+ const isOpen = e.key === "k" && (e.metaKey || e.ctrlKey);
+ if (isOpen) {
+ e.preventDefault();
+ setOpen((prev) => !prev);
+ return;
+ }
+ if (e.key === "Escape" && open) {
+ e.preventDefault();
+ close();
+ }
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [open, close]);
+
+ useEffect(() => {
+ if (open) {
+ setHighlight(0);
+ const t = setTimeout(() => inputRef.current?.focus(), 0);
+ return () => clearTimeout(t);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (highlight >= filtered.length) setHighlight(Math.max(0, filtered.length - 1));
+ }, [filtered.length, highlight]);
+
+ if (!open) return null;
+
+ return (
+ <div
+ role="dialog"
+ aria-label="command palette"
+ aria-modal="true"
+ className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-[10vh] bg-zinc-900/40 dark:bg-zinc-950/60"
+ onMouseDown={(e) => {
+ if (e.target === e.currentTarget) close();
+ }}
+ >
+ <div className="w-full max-w-lg rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 shadow-lg overflow-hidden">
+ <input
+ ref={inputRef}
+ type="text"
+ value={query}
+ onChange={(e) => setQuery(e.target.value)}
+ placeholder="Type a command…"
+ aria-label="command input"
+ className="w-full px-3 py-2.5 text-sm bg-transparent border-b border-zinc-200 dark:border-zinc-800 outline-none"
+ onKeyDown={(e) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setHighlight((h) => Math.min(h + 1, filtered.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setHighlight((h) => Math.max(h - 1, 0));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const cmd = filtered[highlight];
+ if (cmd) {
+ close();
+ cmd.run();
+ }
+ }
+ }}
+ />
+ <ul role="listbox" aria-label="commands" className="max-h-80 overflow-y-auto">
+ {filtered.length === 0 ? (
+ <li className="px-3 py-4 text-sm text-zinc-500">No matches.</li>
+ ) : (
+ filtered.map((c, i) => {
+ const active = i === highlight;
+ return (
+ <li key={c.id}>
+ <button
+ type="button"
+ onMouseEnter={() => setHighlight(i)}
+ onClick={() => {
+ close();
+ c.run();
+ }}
+ aria-selected={active}
+ role="option"
+ className={`w-full text-left px-3 py-2 text-sm flex items-baseline gap-2 ${
+ active
+ ? "bg-blue-50 dark:bg-blue-950/40"
+ : "hover:bg-zinc-50 dark:hover:bg-zinc-800/40"
+ }`}
+ >
+ <span className="flex-1 truncate">{c.label}</span>
+ {c.hint && (
+ <span className="text-xs text-zinc-500 truncate flex-shrink-0">
+ {c.hint}
+ </span>
+ )}
+ </button>
+ </li>
+ );
+ })
+ )}
+ </ul>
+ <div className="border-t border-zinc-200 dark:border-zinc-800 px-3 py-1.5 text-[11px] text-zinc-500 flex items-center justify-between">
+ <span>↵ to run · ↑↓ to move · Esc to close</span>
+ <span>⌘K</span>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -1,6 +1,9 @@
import type { Metadata } from "next";
+import { Suspense } from "react";
import Link from "next/link";
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { CommandPalette } from "./components/CommandPalette";
import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
@@ -15,10 +18,16 @@ export async function generateMetadata(): Promise<Metadata> {
};
}
-const NAV_LINKS = [
+type NavLink = {
+ href: string;
+ label: string;
+ badgeKey?: "jobs";
+};
+
+const NAV_LINKS: NavLink[] = [
{ href: "/", label: "Dashboard" },
{ href: "/channels", label: "Channels" },
- { href: "/jobs", label: "Jobs" },
+ { href: "/jobs", label: "Jobs", badgeKey: "jobs" },
{ href: "/build", label: "Build" },
{ href: "/settings", label: "Settings" },
];
@@ -30,6 +39,9 @@ export default async function RootLayout({
}>) {
const settings = getSettings();
const headerLabel = settings.headerTitle || settings.siteTitle;
+ const activeJobs = getRegistry()
+ .list()
+ .filter((j) => j.status === "running" || j.status === "queued").length;
return (
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col md:flex-row bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
@@ -46,18 +58,33 @@ export default async function RootLayout({
</div>
</div>
<nav className="flex flex-row md:flex-col p-2 gap-1 text-sm overflow-x-auto md:overflow-visible">
- {NAV_LINKS.map((link) => (
- <Link
- key={link.href}
- href={link.href}
- className="px-3 py-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 whitespace-nowrap"
- >
- {link.label}
- </Link>
- ))}
+ {NAV_LINKS.map((link) => {
+ const showBadge =
+ link.badgeKey === "jobs" && activeJobs > 0;
+ return (
+ <Link
+ key={link.href}
+ href={link.href}
+ className="px-3 py-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 whitespace-nowrap flex items-center gap-2"
+ >
+ <span>{link.label}</span>
+ {showBadge && (
+ <span
+ aria-label={`${activeJobs} active job${activeJobs === 1 ? "" : "s"}`}
+ className="ml-auto text-xs rounded-full border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 px-2 py-0.5 leading-none"
+ >
+ {activeJobs}
+ </span>
+ )}
+ </Link>
+ );
+ })}
</nav>
</aside>
<main className="flex-1 min-w-0 px-4 md:px-6 py-4 md:py-6">{children}</main>
+ <Suspense fallback={null}>
+ <CommandPalette />
+ </Suspense>
</body>
</html>
);