Archilyzer · Source

archilyzer

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

commit 52a6afa7519837f530764269c999c04c2bce3f7d
parent b5c3dbf99024054c0f2000440601466008d864d9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  3 May 2026 16:46:57 -0400

even more editor features

Diffstat:
Acommon/controller/undownloadedVideos.ts | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/ytdlp/runYtdlp.ts | 17+++++++++++------
Aeditor/app/_components/ConcurrencyControl.tsx | 32++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/_components/PipelinePanel.tsx | 2+-
Aeditor/app/channels/[slug]/_components/VideoIdList.tsx | 47+++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/_components/WhisperPanel.tsx | 120+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Meditor/app/channels/[slug]/page.tsx | 22+++++++++++++++++++++-
Meditor/app/channels/[slug]/videos/[id]/_components/VideoPanel.tsx | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 5++---
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Meditor/app/channels/[slug]/whisperActions.ts | 12++++++++++++
Meditor/app/channels/page.tsx | 110++++++++++++++++++++++++++++++++++++++++---------------------------------------
Meditor/app/jobs/page.tsx | 12+++++++-----
Meditor/app/layout.tsx | 12++++++------
Meditor/app/page.tsx | 79+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Meditor/app/settings/page.tsx | 4++--
Meditor/e2e/channels.spec.ts | 1+
Aeditor/e2e/fixtures/test-settings.default.json | 7+++++++
Aeditor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/archive | 1+
Aeditor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/config.json | 5+++++
Aeditor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/data/fake00000001/metadata.info.json | 17+++++++++++++++++
Aeditor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/data/fake00000001/transcript.en.vtt | 5+++++
Aeditor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/playlist | 5+++++
Meditor/e2e/helpers.ts | 5+++++
Meditor/e2e/pipeline.spec.ts | 44+++++++++++++++++++++++++++++++++++++++++++-
Aeditor/e2e/undownloaded.spec.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/whisper-video.spec.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
27 files changed, 772 insertions(+), 181 deletions(-)

diff --git a/common/controller/undownloadedVideos.ts b/common/controller/undownloadedVideos.ts @@ -0,0 +1,105 @@ +import path from "node:path"; +import { readdir, readFile } from "node:fs/promises"; +import type { ChannelConfig } from "../lib/channelConfig"; +import type { Paths } from "../lib/paths"; +import { + buildRumbleSlugIndex, + extractVideoId, + isRumbleUrl, +} from "../ytdlp/runYtdlp"; + +export type UndownloadedVideo = { + videoId: string; + url: string; +}; + +async function readPlaylistUrls(playlistPath: string): Promise<string[]> { + let raw: string; + try { + raw = await readFile(playlistPath, "utf8"); + } catch { + return []; + } + return raw + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +// Looser than runYtdlp's destinationExists: counts a video as downloaded if it +// has *any* of the artifacts we know about (subs, audio, whisper output). The +// download pipeline uses the strict per-handling check; this list is +// user-facing, so a YouTube video that was whisper-transcribed (no auto-subs) +// should drop off it. +async function videoHasAnyArtifact( + dataDir: string, + id: string, +): Promise<boolean> { + const entries = await readdir(path.join(dataDir, id)).catch( + () => [] as string[], + ); + return entries.some( + (e) => + e === "transcript.en.vtt" || + e === "transcript.json" || + e.startsWith("audio."), + ); +} + +export async function listUndownloadedVideos( + paths: Paths, + slug: string, + _config: ChannelConfig, +): Promise<UndownloadedVideo[]> { + const channelRoot = path.join(paths.channelsDir, slug); + const dataDir = path.join(channelRoot, "data"); + const urls = await readPlaylistUrls(path.join(channelRoot, "playlist")); + if (urls.length === 0) return []; + + const rumbleIndex = urls.some(isRumbleUrl) + ? await buildRumbleSlugIndex(dataDir) + : null; + + const out: UndownloadedVideo[] = []; + for (const url of urls) { + const slugId = extractVideoId(url); + if (!slugId) continue; + const dirId = + rumbleIndex && isRumbleUrl(url) ? rumbleIndex.get(slugId) ?? slugId : slugId; + if (await videoHasAnyArtifact(dataDir, dirId)) continue; + out.push({ videoId: dirId, url }); + } + return out; +} + +export async function findVideoSourceUrl( + paths: Paths, + slug: string, + videoId: string, +): Promise<string | null> { + const channelRoot = path.join(paths.channelsDir, slug); + const metaPath = path.join(channelRoot, "data", videoId, "metadata.info.json"); + try { + const raw = await readFile(metaPath, "utf8"); + const parsed = JSON.parse(raw); + if (typeof parsed?.webpage_url === "string" && parsed.webpage_url) { + return parsed.webpage_url; + } + } catch { + // fall through to playlist scan + } + const urls = await readPlaylistUrls(path.join(channelRoot, "playlist")); + if (urls.length === 0) return null; + const dataDir = path.join(channelRoot, "data"); + const rumbleIndex = urls.some(isRumbleUrl) + ? await buildRumbleSlugIndex(dataDir) + : null; + for (const url of urls) { + const slugId = extractVideoId(url); + if (!slugId) continue; + const dirId = + rumbleIndex && isRumbleUrl(url) ? rumbleIndex.get(slugId) ?? slugId : slugId; + if (dirId === videoId) return url; + } + return null; +} diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts @@ -249,8 +249,10 @@ async function downloadMissing(opts: RunYtdlpOpts): Promise<void> { // Prefilter by destination-file existence rather than the archive: skip // URLs whose expected output (transcript.en.vtt for youtube handling, - // audio.* for transcribe handling) is already on disk. Catches videos - // missing from a stale or absent archive file. + // audio.* or transcript.json for transcribe handling) is already on disk. + // Catches videos missing from a stale or absent archive file, and avoids + // re-downloading audio for videos whose transcript has already been + // produced (audio may have been cleaned up post-transcription). const rumbleIndex = urls.some(isRumbleUrl) ? await buildRumbleSlugIndex(dataDir) : null; @@ -341,7 +343,7 @@ async function downloadOneAudio(opts: RunYtdlpOpts): Promise<void> { await runChildAndStream(opts, root, args); } -async function destinationExists( +export async function destinationExists( dataDir: string, id: string, handling: ChannelHandling, @@ -351,7 +353,10 @@ async function destinationExists( return fileExists(path.join(dir, "transcript.en.vtt")); } const entries = await readdir(dir).catch(() => [] as string[]); - return entries.some((e) => e.startsWith("audio.")); + return ( + entries.includes("transcript.json") || + entries.some((e) => e.startsWith("audio.")) + ); } async function fileExists(p: string): Promise<boolean> { @@ -443,7 +448,7 @@ async function updateConfigField( await rename(tmp, configPath); } -function isRumbleUrl(url: string): boolean { +export function isRumbleUrl(url: string): boolean { try { return new URL(url).hostname.toLowerCase().endsWith("rumble.com"); } catch { @@ -457,7 +462,7 @@ function isRumbleUrl(url: string): boolean { // Rumble playlist URLs against the archive / data dir, we walk every existing // data/{id}/metadata.info.json once, read its `webpage_url`, and build a // slug → id map. -async function buildRumbleSlugIndex( +export async function buildRumbleSlugIndex( dataDir: string, ): Promise<Map<string, string>> { const index = new Map<string, string>(); diff --git a/editor/app/_components/ConcurrencyControl.tsx b/editor/app/_components/ConcurrencyControl.tsx @@ -0,0 +1,32 @@ +"use client"; + +type Props = { + value: string; + onChange: (next: string) => void; + defaultLimit: number; + actionLabel: string; +}; + +export function ConcurrencyControl({ + value, + onChange, + defaultLimit, + actionLabel, +}: Props) { + return ( + <label className="flex items-center gap-1 text-xs text-zinc-500"> + <span>Parallel</span> + <input + type="number" + min={1} + step={1} + inputMode="numeric" + value={value} + placeholder={String(defaultLimit)} + aria-label={`parallel transcribe limit for ${actionLabel}`} + onChange={(e) => onChange(e.target.value)} + className="w-16 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" + /> + </label> + ); +} diff --git a/editor/app/channels/[slug]/_components/PipelinePanel.tsx b/editor/app/channels/[slug]/_components/PipelinePanel.tsx @@ -101,7 +101,7 @@ export function PipelinePanel({ <div className="flex flex-col gap-2"> <Heading title="Download videos" - desc="Read the saved playlist, then re-download any video whose destination file (transcript.en.vtt or audio.*) is missing on disk. Useful when the archive is out of sync with the data directory." + 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 diff --git a/editor/app/channels/[slug]/_components/VideoIdList.tsx b/editor/app/channels/[slug]/_components/VideoIdList.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; + +type Props = { + slug: string; + ids: string[]; + ariaLabel: string; + emptyAriaLabel: string; + emptyMessage: string; + itemAriaLabel: (id: string) => string; +}; + +export function VideoIdList({ + slug, + ids, + ariaLabel, + emptyAriaLabel, + emptyMessage, + itemAriaLabel, +}: Props) { + if (ids.length === 0) { + return ( + <p className="text-sm text-zinc-500" aria-label={emptyAriaLabel}> + {emptyMessage} + </p> + ); + } + return ( + <div + aria-label={ariaLabel} + className="rounded border border-zinc-200 dark:border-zinc-800 max-h-72 overflow-auto" + > + <ul className="divide-y divide-zinc-200 dark:divide-zinc-800"> + {ids.map((id) => ( + <li key={id} className="px-3 py-1.5"> + <Link + href={`/channels/${slug}/videos/${encodeURIComponent(id)}`} + aria-label={itemAriaLabel(id)} + className="font-mono text-xs underline hover:text-zinc-900 dark:hover:text-zinc-100" + > + {id} + </Link> + </li> + ))} + </ul> + </div> + ); +} diff --git a/editor/app/channels/[slug]/_components/WhisperPanel.tsx b/editor/app/channels/[slug]/_components/WhisperPanel.tsx @@ -1,14 +1,15 @@ "use client"; import { useState } from "react"; -import Link from "next/link"; 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, @@ -22,12 +23,28 @@ type Props = { slug: string; existingQueues: string[]; failedVideoIds: string[]; + defaultConcurrency: number; }; -export function WhisperPanel({ slug, existingQueues, failedVideoIds }: Props) { +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 [retryQueue, setRetryQueue] = useState(defaultQueueKey); + const [retryConcurrency, setRetryConcurrency] = useState(""); const [transcodeFormat, setTranscodeFormat] = useState<AudioFormat>("mp3"); const [transcodeQueue, setTranscodeQueue] = useState(defaultQueueKey); const [cleanQueue, setCleanQueue] = useState(defaultQueueKey); @@ -39,18 +56,32 @@ export function WhisperPanel({ slug, existingQueues, failedVideoIds }: Props) { 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)} + trigger={() => + transcribeMissingAction( + slug, + missingQueue, + parseConcurrency(missingConcurrency), + ) + } cancelAction={cancelJobAction} buttonLabel="Transcribe missing" runningLabel="Transcribing…" extraControls={ - <QueueControl - value={missingQueue} - onChange={setMissingQueue} - defaultQueueKey={defaultQueueKey} - existingQueues={existingQueues} - actionLabel="Transcribe missing" - /> + <> + <QueueControl + value={missingQueue} + onChange={setMissingQueue} + defaultQueueKey={defaultQueueKey} + existingQueues={existingQueues} + actionLabel="Transcribe missing" + /> + <ConcurrencyControl + value={missingConcurrency} + onChange={setMissingConcurrency} + defaultLimit={defaultConcurrency} + actionLabel="Transcribe missing" + /> + </> } /> </div> @@ -60,21 +91,42 @@ export function WhisperPanel({ slug, existingQueues, failedVideoIds }: Props) { desc="Re-run whisper for any video listed below; successes will be removed from the list." /> <StreamActionLog - trigger={() => retryFailuresAction(slug, retryQueue)} + 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" - /> + <> + <QueueControl + value={retryQueue} + onChange={setRetryQueue} + defaultQueueKey={defaultQueueKey} + existingQueues={existingQueues} + actionLabel="Retry failures" + /> + <ConcurrencyControl + value={retryConcurrency} + onChange={setRetryConcurrency} + defaultLimit={defaultConcurrency} + actionLabel="Retry failures" + /> + </> } /> - <FailedList slug={slug} ids={failedVideoIds} /> + <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 @@ -215,36 +267,6 @@ function Bucket({ label, items }: { label: string; items: string[] }) { ); } -function FailedList({ slug, ids }: { slug: string; ids: string[] }) { - if (ids.length === 0) { - return ( - <p className="text-sm text-zinc-500" aria-label="failed transcriptions empty"> - No failed transcriptions. - </p> - ); - } - return ( - <div - aria-label="failed transcriptions list" - className="rounded border border-zinc-200 dark:border-zinc-800 max-h-72 overflow-auto" - > - <ul className="divide-y divide-zinc-200 dark:divide-zinc-800"> - {ids.map((id) => ( - <li key={id} className="px-3 py-1.5"> - <Link - href={`/channels/${slug}/videos/${encodeURIComponent(id)}`} - aria-label={`failed transcription ${id}`} - className="font-mono text-xs underline hover:text-zinc-900 dark:hover:text-zinc-100" - > - {id} - </Link> - </li> - ))} - </ul> - </div> - ); -} - function Heading({ title, desc }: { title: string; desc: string }) { return ( <div> diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -4,6 +4,7 @@ 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 { listUndownloadedVideos } from "yt-dlp-transcript-common/controller/undownloadedVideos"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { detectPlatform, @@ -13,6 +14,7 @@ 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 { VideoIdList } from "./_components/VideoIdList"; import { WhisperPanel } from "./_components/WhisperPanel"; import { deleteChannelAction, @@ -50,7 +52,8 @@ export default async function ChannelDetailPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; - const config = await readChannelConfig(getPaths(), slug); + const paths = getPaths(); + const config = await readChannelConfig(paths, slug); if (!config) notFound(); const update = updateChannelAction.bind(null, slug); @@ -60,6 +63,8 @@ export default async function ChannelDetailPage({ config.platform ?? detectPlatform(config.url), ); const failedVideoIds = await loadFailedVideoIds(slug); + const undownloadedVideos = await listUndownloadedVideos(paths, slug, config); + const undownloadedIds = undownloadedVideos.map((v) => v.videoId); return ( <div className="flex flex-col gap-6"> @@ -96,6 +101,20 @@ export default async function ChannelDetailPage({ /> </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"> + Undownloaded videos ({undownloadedIds.length}) + </h2> + <VideoIdList + slug={slug} + ids={undownloadedIds} + ariaLabel="undownloaded videos list" + emptyAriaLabel="undownloaded videos empty" + emptyMessage="All playlist videos are downloaded." + itemAriaLabel={(id) => `undownloaded video ${id}`} + /> + </section> + {config.handling === "transcribe" && ( <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> <h2 className="text-lg font-semibold">Transcription</h2> @@ -103,6 +122,7 @@ export default async function ChannelDetailPage({ slug={slug} existingQueues={existingQueues} failedVideoIds={failedVideoIds} + defaultConcurrency={paths.parallelTranscribeLimit} /> </section> )} diff --git a/editor/app/channels/[slug]/videos/[id]/_components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/_components/VideoPanel.tsx @@ -16,6 +16,7 @@ import { redownloadVideoAction, transcodeAudioAction, transcribeOneAction, + whisperVideoAction, type DeleteDirActionResult, } from "../videoActions"; @@ -85,6 +86,15 @@ export function VideoPanel({ noAudio={noAudio} /> + {!hasTranscript && ( + <WhisperVideoSection + slug={slug} + videoId={videoId} + existingQueues={existingQueues} + noAudio={noAudio} + /> + )} + {handling === "transcribe" && !hasTranscript && ( <MarkUntranscribableSection slug={slug} videoId={videoId} /> )} @@ -168,7 +178,7 @@ function RedownloadSection({ title={noAudio ? "Download audio" : "Redownload audio"} desc={desc} /> - <div className="flex flex-wrap items-center gap-3"> + <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 <select @@ -184,15 +194,15 @@ function RedownloadSection({ ))} </select> </label> - <label className="flex items-center gap-2 text-sm flex-1 min-w-[16rem]"> - Extra yt-dlp args + <label className="flex flex-col sm:flex-row sm:items-center gap-2 text-sm flex-1 sm:min-w-[16rem]"> + <span>Extra yt-dlp args</span> <input type="text" value={extraArgsRaw} onChange={(e) => setExtraArgsRaw(e.target.value)} aria-label="redownload extra yt-dlp args" placeholder="--cookies-from-browser firefox --quiet" - className="flex-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono" + className="flex-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono min-w-0" /> </label> </div> @@ -222,6 +232,47 @@ function RedownloadSection({ ); } +function WhisperVideoSection({ + slug, + videoId, + existingQueues, + noAudio, +}: { + slug: string; + videoId: string; + 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"> + <Heading title="Whisper transcribe" desc={desc} /> + <StreamActionLog + trigger={() => whisperVideoAction(slug, videoId, queueKey)} + cancelAction={cancelJobAction} + buttonLabel="Whisper transcribe" + runningLabel="Transcribing…" + label={actionLabel} + extraControls={ + <QueueControl + value={queueKey} + onChange={setQueueKey} + defaultQueueKey="" + existingQueues={existingQueues} + actionLabel={actionLabel} + /> + } + /> + </section> + ); +} + function AudioFileActions({ slug, videoId, diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx @@ -26,13 +26,13 @@ type VideoMeta = { async function loadVideoDir( slug: string, videoId: string, -): Promise<{ files: VideoFile[] } | null> { +): Promise<{ files: VideoFile[] }> { const dir = path.join(getPaths().channelsDir, slug, "data", videoId); let entries: Dirent[]; try { entries = await readdir(dir, { withFileTypes: true }); } catch { - return null; + return { files: [] }; } const files: VideoFile[] = []; for (const e of entries) { @@ -90,7 +90,6 @@ export default async function VideoDetailPage({ const config = await readChannelConfig(getPaths(), slug); if (!config) notFound(); const dirData = await loadVideoDir(slug, id); - if (!dirData) notFound(); const meta = await loadMeta(slug, id); const existingQueues = getRegistry().activeQueueNames(); diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts @@ -1,7 +1,7 @@ "use server"; import path from "node:path"; -import { readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import type { @@ -18,6 +18,7 @@ import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels" import { pruneFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions"; import { transcodeAudio } from "yt-dlp-transcript-common/controller/transcode"; import { transcribeOneVideo } from "yt-dlp-transcript-common/controller/transcribeOne"; +import { findVideoSourceUrl } from "yt-dlp-transcript-common/controller/undownloadedVideos"; import { runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdlp"; import { runManagedFunction, @@ -126,22 +127,12 @@ export async function redownloadVideoAction( const r = await loadConfigOrError(slug); if (!r.ok) return r; const paths = getPaths(); - const videoDir = videoDirOf(slug, videoId); - let url: string; - try { - const raw = await readFile(path.join(videoDir, "metadata.info.json"), "utf8"); - const parsed = JSON.parse(raw); - if (typeof parsed?.webpage_url !== "string" || !parsed.webpage_url) { - return { - ok: false, - error: "metadata.info.json has no webpage_url; cannot identify the source video.", - }; - } - url = parsed.webpage_url; - } catch { + const url = await findVideoSourceUrl(paths, slug, videoId); + if (!url) { return { ok: false, - error: "metadata.info.json is missing or unreadable for this video.", + error: + "Could not determine the video URL: no metadata.info.json and the playlist does not contain a matching entry.", }; } return runManagedFunction({ @@ -163,6 +154,61 @@ export async function redownloadVideoAction( extraYtdlpArgs: extraArgs, }); revalidatePath(`/channels/${slug}/videos/${videoId}`); + revalidatePath(`/channels/${slug}`); + }, + }); +} + +export async function whisperVideoAction( + slug: string, + videoId: string, + queueKey?: string, +): Promise<StreamActionResult> { + const r = await loadConfigOrError(slug); + if (!r.ok) return r; + const paths = getPaths(); + const videoDir = videoDirOf(slug, videoId); + return runManagedFunction({ + kind: "whisper-video", + queueKey: videoQueueKey(r.config, queueKey), + paths, + channelSlug: slug, + videoId, + fn: async (onLog, signal) => { + const audioFormat = r.config.audioFormat ?? "mp3"; + const entries = await readdir(videoDir).catch(() => [] as string[]); + const hasAudio = entries.some((e: string) => e.startsWith("audio.")); + if (!hasAudio) { + const url = await findVideoSourceUrl(paths, slug, videoId); + if (!url) { + throw new Error( + "Could not determine the video URL: no metadata.info.json and the playlist does not contain a matching entry.", + ); + } + onLog(`No audio on disk for ${videoId}; downloading…`); + await runYtdlp({ + channelSlug: slug, + mode: "download-one-audio", + channelConfig: r.config, + paths, + onLog, + signal, + singleVideoUrl: url, + audioFormatOverride: audioFormat, + }); + } else { + onLog(`Audio already on disk for ${videoId}; skipping download.`); + } + await transcribeOneVideo({ + paths, + videoDir, + videoId, + audioFilename: `audio.${audioFormat}`, + onLog, + signal, + }); + revalidatePath(`/channels/${slug}/videos/${videoId}`); + revalidatePath(`/channels/${slug}`); }, }); } diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts @@ -19,11 +19,19 @@ function defaultQueueKey(slug: string): string { return `channel:${slug}`; } +function sanitizeConcurrency(c: number | undefined): number | undefined { + if (c === undefined) return undefined; + if (!Number.isFinite(c) || c < 1) return undefined; + return Math.floor(c); +} + export async function transcribeMissingAction( slug: string, queueKey?: string, + concurrency?: number, ): Promise<StreamActionResult> { const paths = getPaths(); + const limit = sanitizeConcurrency(concurrency); return runManagedFunction({ kind: "whisper-all", queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(), @@ -34,6 +42,7 @@ export async function transcribeMissingAction( channelSlug: slug, paths, mode: "all", + concurrency: limit, onLog, signal, }); @@ -48,8 +57,10 @@ export async function transcribeMissingAction( export async function retryFailuresAction( slug: string, queueKey?: string, + concurrency?: number, ): Promise<StreamActionResult> { const paths = getPaths(); + const limit = sanitizeConcurrency(concurrency); return runManagedFunction({ kind: "whisper-retry", queueKey: queueKey === undefined ? defaultQueueKey(slug) : queueKey.trim(), @@ -60,6 +71,7 @@ export async function retryFailuresAction( channelSlug: slug, paths, mode: "retry-failures", + concurrency: limit, onLog, signal, }); diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -25,61 +25,63 @@ export default async function ChannelsPage() { No channels yet. </p> ) : ( - <table className="text-sm border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden"> - <thead className="bg-zinc-100 dark:bg-zinc-900"> - <tr> - <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>Last sync</Th> - </tr> - </thead> - <tbody> - {channels.map((c) => ( - <tr - key={c.slug} - className="border-t border-zinc-200 dark:border-zinc-800" - > - <Td className="font-mono"> - <Link - href={`/channels/${c.slug}`} - className="underline hover:text-zinc-900 dark:hover:text-zinc-100" - > - {c.slug} - </Link> - </Td> - <Td>{c.config.name ?? ""}</Td> - <Td>{c.config.handling}</Td> - <Td - className="text-right" - ariaLabel={`transcripts count for ${c.slug}`} - > - {c.transcriptCount} - </Td> - <Td - className="text-right" - ariaLabel={`downloads count for ${c.slug}`} - > - {c.downloadCount} - </Td> - <Td - className="text-right" - ariaLabel={`playlist count for ${c.slug}`} - > - {c.playlistCount ?? "—"} - </Td> - <Td className="text-xs text-zinc-500"> - {c.config.lastSyncedAt - ? new Date(c.config.lastSyncedAt).toLocaleString() - : ""} - </Td> + <div className="overflow-x-auto -mx-4 md:mx-0"> + <table className="text-sm border-y md:border border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full md:w-auto"> + <thead className="bg-zinc-100 dark:bg-zinc-900"> + <tr> + <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> </tr> - ))} - </tbody> - </table> + </thead> + <tbody> + {channels.map((c) => ( + <tr + key={c.slug} + className="border-t border-zinc-200 dark:border-zinc-800" + > + <Td className="font-mono"> + <Link + href={`/channels/${c.slug}`} + className="underline hover:text-zinc-900 dark:hover:text-zinc-100" + > + {c.slug} + </Link> + </Td> + <Td>{c.config.name ?? ""}</Td> + <Td>{c.config.handling}</Td> + <Td + className="text-right" + ariaLabel={`transcripts count for ${c.slug}`} + > + {c.transcriptCount} + </Td> + <Td + className="text-right" + ariaLabel={`downloads count for ${c.slug}`} + > + {c.downloadCount} + </Td> + <Td + className="text-right" + ariaLabel={`playlist 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() + : ""} + </Td> + </tr> + ))} + </tbody> + </table> + </div> )} </div> ); diff --git a/editor/app/jobs/page.tsx b/editor/app/jobs/page.tsx @@ -52,7 +52,8 @@ export default async function JobsPage() { No jobs have run yet. </p> ) : ( - <table className="text-sm border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden"> + <div className="overflow-x-auto -mx-4 md:mx-0"> + <table className="text-sm border-y md:border border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full md:w-auto"> <thead className="bg-zinc-100 dark:bg-zinc-900"> <tr> <th className="text-left font-medium px-3 py-2">ID</th> @@ -63,7 +64,7 @@ export default async function JobsPage() { <th className="text-left font-medium px-3 py-2">Status</th> <th className="text-left font-medium px-3 py-2">Started</th> <th className="text-left font-medium px-3 py-2">Duration</th> - <th className="text-right font-medium px-3 py-2">Log size</th> + <th className="text-right font-medium px-3 py-2 whitespace-nowrap">Log size</th> <th className="px-3 py-2"></th> </tr> </thead> @@ -127,13 +128,13 @@ export default async function JobsPage() { {j.status} </span> </td> - <td className="px-3 py-2 text-xs text-zinc-500"> + <td className="px-3 py-2 text-xs text-zinc-500 whitespace-nowrap"> {new Date(startedAt).toLocaleString()} </td> - <td className="px-3 py-2 text-xs text-zinc-500"> + <td className="px-3 py-2 text-xs text-zinc-500 whitespace-nowrap"> {fmtDuration(dur)} </td> - <td className="px-3 py-2 text-xs text-zinc-500 text-right"> + <td className="px-3 py-2 text-xs text-zinc-500 text-right whitespace-nowrap"> {j.logSize.toLocaleString()} B </td> <td className="px-3 py-2 text-right"> @@ -146,6 +147,7 @@ export default async function JobsPage() { })} </tbody> </table> + </div> )} </div> ); diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx @@ -32,9 +32,9 @@ export default async function RootLayout({ const headerLabel = settings.headerTitle || settings.siteTitle; return ( <html lang="en" className="h-full antialiased"> - <body className="min-h-full flex bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100"> - <aside className="w-56 shrink-0 border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex flex-col"> - <div className="px-4 py-4 border-b border-zinc-200 dark:border-zinc-800"> + <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"> + <aside className="md:w-56 md:shrink-0 border-b md:border-b-0 md:border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex flex-col"> + <div className="px-4 py-3 md:py-4 md:border-b md:border-zinc-200 dark:md:border-zinc-800"> <Link href="/" className="font-semibold tracking-tight block leading-tight" @@ -45,19 +45,19 @@ export default async function RootLayout({ editor </div> </div> - <nav className="flex flex-col p-2 gap-1 text-sm"> + <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" + className="px-3 py-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 whitespace-nowrap" > {link.label} </Link> ))} </nav> </aside> - <main className="flex-1 px-6 py-6 max-w-5xl">{children}</main> + <main className="flex-1 px-4 md:px-6 py-4 md:py-6 max-w-5xl">{children}</main> </body> </html> ); diff --git a/editor/app/page.tsx b/editor/app/page.tsx @@ -4,11 +4,18 @@ import type { Metadata } from "next"; import Link from "next/link"; import { listChannels } from "yt-dlp-transcript-common/controller/channels"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; export const dynamic = "force-dynamic"; -export const metadata: Metadata = { title: "Dashboard" }; +// Next.js title.template defined in app/layout.tsx applies to *child* route +// segments, not the root page. Build the templated form ourselves so the home +// page still gets the site name in its document title. +export async function generateMetadata(): Promise<Metadata> { + const settings = getSettings(); + return { title: `Dashboard — ${settings.siteTitle}` }; +} async function getLastBuildTime(paths: ReturnType<typeof getPaths>) { try { @@ -52,40 +59,44 @@ export default async function Dashboard() { {channels.length > 0 && ( <section className="flex flex-col gap-3"> <h2 className="text-lg font-semibold">Channels</h2> - <table className="text-sm border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden"> - <thead className="bg-zinc-100 dark:bg-zinc-900"> - <tr> - <th className="text-left font-medium px-3 py-2">Slug</th> - <th className="text-left font-medium px-3 py-2">Handling</th> - <th className="text-right font-medium px-3 py-2">Videos</th> - <th className="text-left font-medium px-3 py-2">Last sync</th> - </tr> - </thead> - <tbody> - {channels.map((c) => ( - <tr - key={c.slug} - className="border-t border-zinc-200 dark:border-zinc-800" - > - <td className="px-3 py-2 font-mono"> - <Link - href={`/channels/${c.slug}`} - className="underline hover:text-zinc-900 dark:hover:text-zinc-100" - > - {c.slug} - </Link> - </td> - <td className="px-3 py-2">{c.config.handling}</td> - <td className="px-3 py-2 text-right">{c.videoCount}</td> - <td className="px-3 py-2 text-xs text-zinc-500"> - {c.config.lastSyncedAt - ? new Date(c.config.lastSyncedAt).toLocaleString() - : "never"} - </td> + <div className="overflow-x-auto -mx-4 md:mx-0"> + <table className="text-sm border-y md:border md:border-zinc-200 dark:md:border-zinc-800 border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full md:w-auto"> + <thead className="bg-zinc-100 dark:bg-zinc-900"> + <tr> + <th className="text-left font-medium px-3 py-2">Slug</th> + <th className="text-left font-medium px-3 py-2">Handling</th> + <th className="text-right font-medium px-3 py-2">Videos</th> + <th className="text-left font-medium px-3 py-2 whitespace-nowrap"> + Last sync + </th> </tr> - ))} - </tbody> - </table> + </thead> + <tbody> + {channels.map((c) => ( + <tr + key={c.slug} + className="border-t border-zinc-200 dark:border-zinc-800" + > + <td className="px-3 py-2 font-mono"> + <Link + href={`/channels/${c.slug}`} + className="underline hover:text-zinc-900 dark:hover:text-zinc-100" + > + {c.slug} + </Link> + </td> + <td className="px-3 py-2">{c.config.handling}</td> + <td className="px-3 py-2 text-right">{c.videoCount}</td> + <td className="px-3 py-2 text-xs text-zinc-500 whitespace-nowrap"> + {c.config.lastSyncedAt + ? new Date(c.config.lastSyncedAt).toLocaleString() + : "never"} + </td> + </tr> + ))} + </tbody> + </table> + </div> </section> )} </div> diff --git a/editor/app/settings/page.tsx b/editor/app/settings/page.tsx @@ -50,14 +50,14 @@ export default function SettingsPage() { WHISPER_BIN, WHISPER_MODEL, PARALLEL_TRANSCRIBE_LIMIT) before launching to override. </p> - <table className="text-sm mt-3"> + <table className="text-sm mt-3 w-full"> <tbody> {pathRows.map(([key, value]) => ( <tr key={key} className="border-b border-zinc-200 dark:border-zinc-800" > - <th className="text-left font-medium py-2 pr-4 align-top"> + <th className="text-left font-medium py-2 pr-4 align-top whitespace-nowrap"> {key} </th> <td className="py-2 font-mono text-xs break-all">{value}</td> diff --git a/editor/e2e/channels.spec.ts b/editor/e2e/channels.spec.ts @@ -23,6 +23,7 @@ test("creates a youtube channel", async ({ page }) => { handling: "youtube", name: "New Test Channel", url: "https://www.youtube.com/@newtest/videos", + platform: "youtube", }); }); diff --git a/editor/e2e/fixtures/test-settings.default.json b/editor/e2e/fixtures/test-settings.default.json @@ -0,0 +1,7 @@ +{ + "siteTitle": "Test Browser", + "siteDescription": "Test description", + "headerTitle": "Test Browser", + "homeTagline": "", + "maxTranscriptPageBytes": 8388608 +} diff --git a/editor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/archive b/editor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/archive @@ -0,0 +1 @@ +youtube fake00000001 diff --git a/editor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/config.json b/editor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/config.json @@ -0,0 +1,5 @@ +{ + "handling": "youtube", + "name": "Test YouTube Channel", + "url": "https://www.youtube.com/@example/videos" +} diff --git a/editor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/data/fake00000001/metadata.info.json b/editor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/data/fake00000001/metadata.info.json @@ -0,0 +1,17 @@ +{ + "id": "fake00000001", + "title": "Synthetic Test Video 1", + "channel": "Test YouTube Channel", + "channel_id": "UCtestchannelid", + "channel_url": "https://www.youtube.com/channel/UCtestchannelid", + "uploader": "Test YouTube Channel", + "upload_date": "20240101", + "duration": 60, + "description": "Already-downloaded fixture entry.", + "is_live": false, + "was_live": false, + "live_status": "not_live", + "age_limit": 0, + "extractor_key": "Youtube", + "webpage_url": "https://www.youtube.com/watch?v=fake00000001" +} diff --git a/editor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/data/fake00000001/transcript.en.vtt b/editor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/data/fake00000001/transcript.en.vtt @@ -0,0 +1,5 @@ +WEBVTT + +00:00:00.000 --> 00:00:05.000 +Already-downloaded fixture entry. + diff --git a/editor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/playlist b/editor/e2e/fixtures/test-transcripts/youtube-with-playlist/channels/test-youtube/playlist @@ -0,0 +1,5 @@ +https://www.youtube.com/watch?v=fake00000001 +https://www.youtube.com/watch?v=fake00000002 +https://www.youtube.com/watch?v=fake00000003 +https://www.youtube.com/watch?v=fake00000004 +https://www.youtube.com/watch?v=fake00000005 diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts @@ -14,6 +14,10 @@ const editorRoot = resolve(here, ".."); const fixturesRoot = resolve(editorRoot, "e2e", "fixtures"); const testTranscriptsDir = resolve(editorRoot, "test-transcripts"); const testSettingsFile = resolve(editorRoot, "test-settings.json"); +const defaultTestSettingsFile = resolve( + fixturesRoot, + "test-settings.default.json", +); const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3001"; @@ -29,6 +33,7 @@ async function fileExists(p: string): Promise<boolean> { export async function resetData(fixtureName: string | null = null) { await rm(testTranscriptsDir, { recursive: true, force: true }); await rm(testSettingsFile, { force: true }); + await cp(defaultTestSettingsFile, testSettingsFile); if (fixtureName) { const src = join(fixturesRoot, "test-transcripts", fixtureName); if (!(await fileExists(src))) { diff --git a/editor/e2e/pipeline.spec.ts b/editor/e2e/pipeline.spec.ts @@ -1,5 +1,6 @@ +import { rm, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; -import { pathExists, readJson, resetData } from "./helpers"; +import { pathExists, readJson, resetData, resolvePath } from "./helpers"; test("store playlist writes a playlist file with 5 URLs", async ({ page }) => { await resetData("test-pipeline"); @@ -86,6 +87,47 @@ test("sync downloads one new entry and writes lastSyncedAt", async ({ page }) => expect(typeof config.lastSyncedAt).toBe("string"); }); +test("download videos skips a transcribed video even when its audio was cleaned up", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + const channelRoot = "test-transcripts/channels/test-transcribe"; + + // vidA: transcribed, audio cleaned up — should be skipped via transcript.json. + await writeFile( + resolvePath(`${channelRoot}/data/vidA/transcript.json`), + JSON.stringify({ result: { language: "en" }, transcription: [] }), + ); + await rm(resolvePath(`${channelRoot}/data/vidA/audio.m4a`)); + // vidB: still has audio.m4a — exercises the existing skip-via-audio path. + // vidC: genuinely missing — clear its audio so it is the only fetch target. + await rm(resolvePath(`${channelRoot}/data/vidC/audio.m4a`)); + + await writeFile( + resolvePath(`${channelRoot}/playlist`), + [ + "https://www.youtube.com/watch?v=vidA", + "https://www.youtube.com/watch?v=vidB", + "https://www.youtube.com/watch?v=vidC", + ].join("\n") + "\n", + ); + + await page.goto("/channels/test-transcribe"); + await page.getByRole("button", { name: "Download videos" }).click(); + + const log = page.getByLabel("Download videos output"); + await expect(log).toContainText( + "Prefilter: 1 missing destination files, 2 already complete (of 3 total)", + { timeout: 30_000 }, + ); + + // vidA's transcript survives and its audio is not re-fetched. + expect( + await pathExists(`${channelRoot}/data/vidA/transcript.json`), + ).toBe(true); + expect(await pathExists(`${channelRoot}/data/vidA/audio.m4a`)).toBe(false); +}); + test("blocks pipeline buttons when channel has no url", async ({ page }) => { await resetData("empty"); await page.goto("/channels/new"); diff --git a/editor/e2e/undownloaded.spec.ts b/editor/e2e/undownloaded.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from "@playwright/test"; +import { pathExists, resetData } from "./helpers"; + +test("shows empty state when there is no playlist", async ({ page }) => { + await resetData("one-youtube-channel"); + await page.goto("/channels/test-youtube"); + await expect(page.getByText(/Undownloaded videos \(0\)/)).toBeVisible(); + await expect(page.getByLabel("undownloaded videos empty")).toContainText( + "All playlist videos are downloaded.", + ); +}); + +test("lists every playlist entry that is missing on disk", async ({ page }) => { + await resetData("youtube-with-playlist"); + await page.goto("/channels/test-youtube"); + + await expect(page.getByText(/Undownloaded videos \(4\)/)).toBeVisible(); + const list = page.getByLabel("undownloaded videos list"); + for (const id of [ + "fake00000002", + "fake00000003", + "fake00000004", + "fake00000005", + ]) { + await expect(list.getByLabel(`undownloaded video ${id}`)).toHaveAttribute( + "href", + `/channels/test-youtube/videos/${id}`, + ); + } + // The already-downloaded entry must not appear. + await expect( + list.getByLabel("undownloaded video fake00000001"), + ).toHaveCount(0); +}); + +test("clicking an undownloaded entry lands on a usable video page", async ({ + page, +}) => { + await resetData("youtube-with-playlist"); + await page.goto("/channels/test-youtube"); + await page.getByLabel("undownloaded video fake00000002").click(); + + await expect(page).toHaveURL(/\/videos\/fake00000002$/); + await expect(page.getByRole("heading", { level: 1 })).toContainText( + "fake00000002", + ); + await expect( + page.getByRole("button", { name: /^Download audio/ }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Whisper transcribe" }), + ).toBeVisible(); +}); + +test("undownloaded list shrinks after a one-click whisper completes", async ({ + page, +}) => { + await resetData("youtube-with-playlist"); + await page.goto("/channels/test-youtube/videos/fake00000002"); + await page.getByRole("button", { name: "Whisper transcribe" }).click(); + await expect( + page.getByLabel("Whisper transcribe fake00000002 output"), + ).toContainText("Transcribe fake00000002 done", { timeout: 30_000 }); + + expect( + await pathExists( + "test-transcripts/channels/test-youtube/data/fake00000002/transcript.json", + ), + ).toBe(true); + + await page.goto("/channels/test-youtube"); + await expect(page.getByText(/Undownloaded videos \(3\)/)).toBeVisible(); + await expect( + page.getByLabel("undownloaded video fake00000002"), + ).toHaveCount(0); +}); diff --git a/editor/e2e/whisper-video.spec.ts b/editor/e2e/whisper-video.spec.ts @@ -0,0 +1,73 @@ +import { readFile, rm } from "node:fs/promises"; +import { test, expect } from "@playwright/test"; +import { pathExists, resetData, resolvePath } from "./helpers"; + +test("one-click whisper downloads audio then transcribes a YouTube video", async ({ + page, +}) => { + await resetData("youtube-with-playlist"); + // fake00000001 is the seeded "downloaded" entry. Drop its auto-subs so the + // page treats it as untranscribed and the Whisper button appears. + await rm( + resolvePath( + "test-transcripts/channels/test-youtube/data/fake00000001/transcript.en.vtt", + ), + ); + + await page.goto("/channels/test-youtube/videos/fake00000001"); + await page.getByRole("button", { name: "Whisper transcribe" }).click(); + + const log = page.getByLabel("Whisper transcribe fake00000001 output"); + await expect(log).toContainText("downloading", { timeout: 30_000 }); + await expect(log).toContainText("Transcribe fake00000001 done", { + timeout: 30_000, + }); + + expect( + await pathExists( + "test-transcripts/channels/test-youtube/data/fake00000001/audio.mp3", + ), + ).toBe(true); + expect( + await pathExists( + "test-transcripts/channels/test-youtube/data/fake00000001/transcript.json", + ), + ).toBe(true); +}); + +test("one-click whisper skips download when audio is already on disk", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + + await page.goto("/channels/test-transcribe/videos/vidA"); + await page.getByRole("button", { name: "Whisper transcribe" }).click(); + + const log = page.getByLabel("Whisper transcribe vidA output"); + await expect(log).toContainText("skipping download", { timeout: 30_000 }); + await expect(log).toContainText("Transcribe vidA done", { timeout: 30_000 }); + + expect( + await pathExists( + "test-transcripts/channels/test-transcribe/data/vidA/transcript.json", + ), + ).toBe(true); + + // The fake-ytdlp script appends each invocation to fake-ytdlp.invocations + // in the channel root. Confirm no download-one entry was written for vidA. + const invocationsPath = resolvePath( + "test-transcripts/channels/test-transcribe/fake-ytdlp.invocations", + ); + const invocations = await readFile(invocationsPath, "utf8").catch(() => ""); + expect(invocations).not.toContain("download-one:"); +}); + +test("Whisper button is hidden when a transcript already exists", async ({ + page, +}) => { + await resetData("one-youtube-channel-with-data"); + await page.goto("/channels/test-youtube/videos/20240101_test1234567"); + await expect( + page.getByRole("button", { name: "Whisper transcribe" }), + ).toHaveCount(0); +});