Archilyzer · Source

archilyzer

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

commit ac2cea66f2a93bd5d37917bcad091fe77d594c10
parent 1b24f2d620606b3079c405302d3a583cba1d99a3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 10 May 2026 21:57:59 -0400

Redo UI

Diffstat:
Mcommon/components/StreamActionLog.tsx | 8+++++++-
Acommon/controller/archiveTranscripts.ts | 481+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/buildIndex.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcommon/controller/channelSnapshot.ts | 27++++++++++++++++++++++++++-
Mcommon/controller/cleanAudioFromTranscribed.ts | 5++++-
Acommon/controller/cleanExtraAudioFormats.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/failedTranscodings.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/normalizeAll.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/normalizeTranscript.ts | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/removeFailedTranscodings.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/transcodeFailures.ts | 33+++++++++++++++++++++++++++++++--
Mcommon/controller/transcribeOne.ts | 38+++++++++++++++++++++++++++++++++++++-
Mcommon/controller/whisperBatch.ts | 23+++++++++++++++++++++--
Mcommon/lib/channelConfig.ts | 4++++
Mcommon/lib/videoStatus.ts | 17+++++++++++++++--
Mcommon/ytdlp/runYtdlp.ts | 5++++-
Aeditor/app/api/channels/[slug]/videos/[id]/files/[name]/route.ts | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/build/buildAction.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/build/components/ArchiveOptionsControl.tsx | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/build/components/BuildButtons.tsx | 123++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Deditor/app/channels/[slug]/components/PipelinePanel.tsx | 184-------------------------------------------------------------------------------
Aeditor/app/channels/[slug]/components/PipelineStageCard.tsx | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/StageBadge.tsx | 44++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/StatusHeader.tsx | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Deditor/app/channels/[slug]/components/WhisperPanel.tsx | 288-------------------------------------------------------------------------------
Aeditor/app/channels/[slug]/components/stages/CleanupStage.tsx | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/stages/DownloadStage.tsx | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/stages/PlaylistStage.tsx | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/stages/TranscodeStage.tsx | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/components/stages/TranscribeStage.tsx | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/lib/stageStatus.ts | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/page.tsx | 401+++++++++++++++++++++++++++++++------------------------------------------------
Aeditor/app/channels/[slug]/videos/[id]/components/MediaPlayer.tsx | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 451++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Meditor/app/channels/[slug]/whisperActions.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/actions.ts | 4++++
Meditor/app/channels/components/ChannelForm.tsx | 260+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Meditor/app/channels/components/parseChannelForm.ts | 2++
Meditor/app/channels/page.tsx | 22+++++++++++-----------
Meditor/app/layout.tsx | 2+-
Meditor/app/page.tsx | 8++++----
Meditor/e2e/channels.spec.ts | 4+++-
43 files changed, 4003 insertions(+), 1017 deletions(-)

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