Archilyzer · Source

archilyzer

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

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:
Aeditor/app/channels/[slug]/bulkVideoActions.ts | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/VideoListPane.tsx | 336+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/lib/videoRows.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/lib/videoRowsServer.ts | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/page.tsx | 179++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aeditor/app/components/CommandPalette.tsx | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/layout.tsx | 49++++++++++++++++++++++++++++++++++++++-----------
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> );