Archilyzer · Source

archilyzer

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

commit b5c3dbf99024054c0f2000440601466008d864d9
parent 9f9564c00ca208cae61ad274bf42af6cbc122804
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat,  2 May 2026 09:35:30 -0400

more editor features

Diffstat:
Mcommon/components/StreamActionLog.tsx | 7+++++++
Mcommon/controller/channels.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++----------------
Acommon/controller/cleanAudioFromTranscribed.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/failedTranscriptions.ts | 22++++++++++++++++++++++
Acommon/controller/transcode.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/transcodeFailures.ts | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/transcribeOne.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/whisperBatch.ts | 85+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcommon/jobs/listJobs.ts | 3+++
Mcommon/jobs/registry.ts | 16++++++++++++++--
Mcommon/jobs/streamCommand.ts | 5+++++
Mcommon/lib/channelConfig.ts | 15+++++++++++++--
Mcommon/lib/paths.ts | 2++
Acommon/lib/platform.ts | 28++++++++++++++++++++++++++++
Mcommon/lib/transcripts.ts | 3++-
Mcommon/ytdlp/runYtdlp.ts | 253++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Aeditor/app/_components/QueueControl.tsx | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Deditor/app/_components/QueuePicker.tsx | 93-------------------------------------------------------------------------------
Meditor/app/build/_components/BuildButtons.tsx | 36+++++++++++++++++++++++++-----------
Meditor/app/build/buildAction.ts | 4++--
Meditor/app/build/page.tsx | 3+++
Meditor/app/channels/[slug]/_components/PipelinePanel.tsx | 124++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Meditor/app/channels/[slug]/_components/WhisperPanel.tsx | 142++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Meditor/app/channels/[slug]/page.tsx | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/pipelineActions.ts | 34++++++++++++++++++++++++++++++----
Aeditor/app/channels/[slug]/videos/[id]/_components/VideoPanel.tsx | 472+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/page.tsx | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/[id]/videoActions.ts | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/whisperActions.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Meditor/app/channels/_components/ChannelForm.tsx | 19++++++++++++++++++-
Meditor/app/channels/_components/parseChannelForm.ts | 12++++++++++++
Meditor/app/channels/actions.ts | 9+++++++++
Meditor/app/channels/new/page.tsx | 3+++
Meditor/app/channels/page.tsx | 22+++++++++++++++-------
Meditor/app/jobs/[id]/page.tsx | 32++++++++++++++++++++++++++++++++
Meditor/app/jobs/page.tsx | 22+++++++++++++++++++++-
Meditor/app/layout.tsx | 5++++-
Meditor/app/page.tsx | 3+++
Meditor/app/settings/page.tsx | 3+++
Meditor/e2e/channels-counts.spec.ts | 35+++++++++++++++++++++++++++--------
Aeditor/e2e/fixtures/bin/fake-ffmpeg.mjs | 19+++++++++++++++++++
Meditor/e2e/fixtures/bin/fake-ytdlp.mjs | 48+++++++++++++++++++++++++++++++++++++++++-------
Meditor/e2e/queues.spec.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Aeditor/e2e/video-page.spec.ts | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/whisper.spec.ts | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/package.json | 2+-
Mexport/app/layout.tsx | 5++++-
Msettings.json | 6++++--
48 files changed, 2885 insertions(+), 262 deletions(-)

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