Archilyzer · Source

archilyzer

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

commit 2db84f3f1182a18da57f81d1b6ed2170d85c6d6e
parent 98d701bb98444d3c6c075bf2b5dbf82e4708e62b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 18 May 2026 20:30:49 -0400

Export live chat

Diffstat:
Acommon/controller/archiveLiveChat.ts | 374+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/archiveTranscripts.ts | 8++++----
Mcommon/controller/buildIndex.ts | 28++++++++++++++++++++++------
Acommon/controller/normalizeAllLiveChat.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/normalizeLiveChat.ts | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/normalizeTranscript.ts | 4+++-
Mcommon/lib/videoStatus.ts | 3+++
Meditor/app/build/buildAction.ts | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/build/components/BuildButtons.tsx | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 799 insertions(+), 11 deletions(-)

diff --git a/common/controller/archiveLiveChat.ts b/common/controller/archiveLiveChat.ts @@ -0,0 +1,374 @@ +// Build archives of every channel's parsed live_chat.cues.json files. +// Mirrors archiveTranscripts but produces a separate archive so the +// transcript archives stay scoped to spoken-content cues. +// +// archiveLiveChat() — one <slug>.live_chat.<ext> per channel +// archiveCombinedLiveChat() — one all-live-chat.<ext> total +// +// Internal layout of <slug>.live_chat.<ext>: +// <slug>/channel.json +// <slug>/<videoId>/live_chat.cues.json +// +// Internal layout of all-live-chat.<ext>: +// manifest.json +// <slug>/channel.json +// <slug>/<videoId>/live_chat.cues.json + +import path from "node:path"; +import { + link, + mkdir, + readdir, + rm, + writeFile, + stat, +} from "node:fs/promises"; +import pLimit from "p-limit"; +import { listChannels, type ChannelStat } from "./channels"; +import { normalizeLiveChat } from "./normalizeLiveChat"; +import { + archiveExtension, + compressArchive, + resolveBuild, + writeTransformedCues, +} from "./archiveTranscripts"; +import { LIVE_CHAT_CUES_FILENAME } from "../lib/videoStatus"; +import { detectPlatform } from "../lib/platform"; +import type { Paths } from "../lib/paths"; +import { + type ArchiveBuildOptions, +} from "../lib/archiveOptions"; + +export type ArchiveLiveChatOptions = { + paths: Paths; + onLog?: (msg: string) => void; + signal?: AbortSignal; + concurrency?: number; + channelSlugs?: string[]; + build?: Partial<ArchiveBuildOptions>; +}; + +export type ArchiveLiveChatResult = { + archives: { slug: string; archivePath: string; liveChatCount: number }[]; + totalLiveChats: number; + skipped: { slug: string; reason: string }[]; +}; + +export type ArchiveCombinedLiveChatResult = { + archivePath: string | null; + channels: { slug: string; liveChatCount: number }[]; + totalLiveChats: number; + skipped: { slug: string; reason: string }[]; +}; + +const COMBINED_BASENAME = "all-live-chat"; +const COMBINED_STAGING_DIR = "all-live-chat"; +const ARCHIVE_INFIX = "live_chat"; + +function archivesDir(paths: Paths): string { + return path.join(paths.transcriptsDir, "export", "archives"); +} + +// Keep live-chat staging isolated from transcript staging so concurrent runs +// (e.g. on separate queues) can't trample each other. +function stagingDir(paths: Paths): string { + return path.join(paths.transcriptsDir, "export", ".staging", "live-chat"); +} + +function channelManifest(ch: ChannelStat, liveChatCount: 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, + liveChatCount, + generatedAt: new Date().toISOString(), + }; +} + +// Hard-link or transform-write every video's live_chat.cues.json from +// <channelsDir>/<slug>/data/ into <destDir>/<videoId>/live_chat.cues.json. +// Normalizes on demand so the stage tree is always self-consistent. +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 live chat ${ch.slug}: scanning ${videoIds.length} videos`); + + let normalizedCount = 0; + let linkedCount = 0; + let normalizeFailed = 0; + + 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 normalizeLiveChat({ + 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, LIVE_CHAT_CUES_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} live-chat 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 }; +} + +export async function archiveLiveChat( + opts: ArchiveLiveChatOptions, +): Promise<ArchiveLiveChatResult> { + const build = resolveBuild(opts.build); + const log = opts.onLog ?? ((m: string) => console.log(m)); + const limit = pLimit(opts.concurrency ?? 8); + const archives: ArchiveLiveChatResult["archives"] = []; + const skipped: ArchiveLiveChatResult["skipped"] = []; + let totalLiveChats = 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}.${ARCHIVE_INFIX}.${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 live chat" }); + log(` ${ch.slug}: skipped (no live chat 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} live chats → ${path.basename(archivePath)}`, + ); + archives.push({ slug: ch.slug, archivePath, liveChatCount: linkedCount }); + totalLiveChats += 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 ${totalLiveChats} live chats to:`, + ); + log(` ${archivesDir(opts.paths)}`); + for (const a of archives) { + log(` - ${path.basename(a.archivePath)} (${a.liveChatCount} live chats)`); + } + if (skipped.length > 0) { + log(""); + log(`Skipped ${skipped.length} channel(s):`); + for (const s of skipped) log(` - ${s.slug}: ${s.reason}`); + } + + return { archives, totalLiveChats, skipped }; +} + +export async function archiveCombinedLiveChat( + opts: ArchiveLiveChatOptions, +): Promise<ArchiveCombinedLiveChatResult> { + const build = resolveBuild(opts.build); + const log = opts.onLog ?? ((m: string) => console.log(m)); + const limit = pLimit(opts.concurrency ?? 8); + const skipped: ArchiveCombinedLiveChatResult["skipped"] = []; + const channels: ArchiveCombinedLiveChatResult["channels"] = []; + let totalLiveChats = 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 live chat" }); + log(` ${ch.slug}: skipped (no live chat to archive)`); + continue; + } + channels.push({ slug: ch.slug, liveChatCount: linkedCount }); + totalLiveChats += 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 live chat; nothing to archive."); + return { + archivePath: null, + channels, + totalLiveChats, + skipped, + }; + } + + await writeFile( + path.join(combinedStaging, "manifest.json"), + JSON.stringify( + { + version: 1, + generatedAt: new Date().toISOString(), + channelCount: channels.length, + liveChatCount: totalLiveChats, + 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 ${totalLiveChats} live chats 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, + totalLiveChats, + skipped, + }; + } finally { + await rm(combinedStaging, { recursive: true, force: true }); + } +} diff --git a/common/controller/archiveTranscripts.ts b/common/controller/archiveTranscripts.ts @@ -86,7 +86,7 @@ function stagingDir(paths: Paths): string { return path.join(paths.transcriptsDir, "export", ".staging"); } -function resolveBuild( +export function resolveBuild( partial: Partial<ArchiveBuildOptions> | undefined, ): ArchiveBuildOptions { const merged = { ...DEFAULT_ARCHIVE_OPTIONS, ...partial }; @@ -198,7 +198,7 @@ async function stageChannel( return { linkedCount, normalizedCount, normalizeFailed }; } -async function writeTransformedCues( +export async function writeTransformedCues( sourcePath: string, destPath: string, build: ArchiveBuildOptions, @@ -218,13 +218,13 @@ async function writeTransformedCues( await writeFile(destPath, body); } -function archiveExtension(format: ArchiveFormat): string { +export 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( +export async function compressArchive( archivePath: string, cwd: string, members: string[], diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts @@ -34,6 +34,10 @@ import { isCuesJsonFresh, readNormalizedTranscript, } from "./normalizeTranscript"; +import { + isLiveChatCuesFresh, + readNormalizedLiveChat, +} from "./normalizeLiveChat"; import type { TranscriptSummary, TranscriptDetail, @@ -557,12 +561,24 @@ export async function buildIndex({ const parsedSubs: StoredSubs = []; for (const t of s.subTracks) { try { - const raw = await readFile( - path.join(path.dirname(s.metaPath), t.filename), - "utf8", - ); - const trackCues = - t.track === "live_chat" ? parseLiveChat(raw) : parseVtt(raw); + let trackCues: Cue[] | null = null; + if (t.track === "live_chat") { + const liveChatFresh = await isLiveChatCuesFresh(videoFullDir); + if (liveChatFresh.fresh) { + const normalized = await readNormalizedLiveChat( + liveChatFresh.cuesPath, + ); + if (normalized) trackCues = normalized.cues ?? []; + } + } + if (!trackCues) { + const raw = await readFile( + path.join(path.dirname(s.metaPath), t.filename), + "utf8", + ); + trackCues = + t.track === "live_chat" ? parseLiveChat(raw) : parseVtt(raw); + } if (trackCues.length > 0) { parsedSubs.push({ track: t.track, cues: trackCues }); } diff --git a/common/controller/normalizeAllLiveChat.ts b/common/controller/normalizeAllLiveChat.ts @@ -0,0 +1,80 @@ +// Walk every channel/video and ensure each transcript.live_chat.json has a +// sibling live_chat.cues.json. Mirrors normalizeAllTranscripts; cheap on +// rebuilds because normalizeLiveChat short-circuits when the sidecar is +// already at least as new as the metadata + raw payload. + +import path from "node:path"; +import { readdir } from "node:fs/promises"; +import pLimit from "p-limit"; +import { listChannels } from "./channels"; +import { normalizeLiveChat } from "./normalizeLiveChat"; +import type { Paths } from "../lib/paths"; + +export type NormalizeAllLiveChatOptions = { + paths: Paths; + onLog?: (msg: string) => void; + signal?: AbortSignal; + concurrency?: number; +}; + +export type NormalizeAllLiveChatResult = { + wrote: number; + fresh: number; + skipped: number; + failed: number; +}; + +export async function normalizeAllLiveChat( + opts: NormalizeAllLiveChatOptions, +): Promise<NormalizeAllLiveChatResult> { + const log = opts.onLog ?? ((m: string) => console.log(m)); + const limit = pLimit(opts.concurrency ?? 8); + const channels = await listChannels(opts.paths); + const result: NormalizeAllLiveChatResult = { + 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 live chat ${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 normalizeLiveChat({ + 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/normalizeLiveChat.ts b/common/controller/normalizeLiveChat.ts @@ -0,0 +1,141 @@ +// Produce a compact, self-describing parsed copy of transcript.live_chat.json +// alongside the raw file so that downstream consumers (buildIndex, archives) +// can skip re-parsing potentially huge live-chat JSON-lines payloads. Shape +// matches NormalizedTranscript with source: "live_chat". + +import path from "node:path"; +import { readFile, rename, stat, writeFile } from "node:fs/promises"; +import { parseLiveChat } from "../lib/liveChat"; +import type { Cue } from "../lib/vtt"; +import { summarize, type RawMetadata } from "../lib/transcripts-server"; +import { + type NormalizedLiveChat, +} from "./normalizeTranscript"; +import { + LIVE_CHAT_CUES_FILENAME, + LIVE_CHAT_FILENAME, + META_FILENAME, +} from "../lib/videoStatus"; + +export const LIVE_CHAT_FILE_VERSION = 1; + +export type NormalizeLiveChatOptions = { + videoDir: string; + channelSlug: string; + configName?: string; + log?: (msg: string) => void; + force?: boolean; +}; + +export type NormalizeLiveChatOutcome = + | { status: "wrote"; cuesPath: string } + | { status: "fresh"; cuesPath: string } + | { status: "skipped"; reason: "no-live-chat" | "no-metadata" }; + +async function mtimeMs(p: string): Promise<number | null> { + try { + return (await stat(p)).mtimeMs; + } catch { + return null; + } +} + +// TODO: live_chat files can be hundreds of MB. parseLiveChat already splits +// on "\n", so a streaming readline variant is straightforward if we hit a +// memory wall. For now this matches the readFile pattern used by the +// transcript normalizer. +export async function normalizeLiveChat( + opts: NormalizeLiveChatOptions, +): Promise<NormalizeLiveChatOutcome> { + const metaPath = path.join(opts.videoDir, META_FILENAME); + const rawPath = path.join(opts.videoDir, LIVE_CHAT_FILENAME); + const cuesPath = path.join(opts.videoDir, LIVE_CHAT_CUES_FILENAME); + + const [metaStatMs, rawStatMs, cuesMs] = await Promise.all([ + mtimeMs(metaPath), + mtimeMs(rawPath), + mtimeMs(cuesPath), + ]); + + if (metaStatMs === null) return { status: "skipped", reason: "no-metadata" }; + if (rawStatMs === null) return { status: "skipped", reason: "no-live-chat" }; + + if ( + !opts.force && + cuesMs !== null && + cuesMs >= metaStatMs && + cuesMs >= rawStatMs + ) { + 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 rawLiveChat = await readFile(rawPath, "utf8"); + let cues: Cue[]; + try { + cues = parseLiveChat(rawLiveChat); + } catch (err) { + throw new Error( + `Failed to parse ${LIVE_CHAT_FILENAME} for ${opts.channelSlug}/${path.basename(opts.videoDir)}: ${(err as Error).message}`, + ); + } + + const out: NormalizedLiveChat = { + version: LIVE_CHAT_FILE_VERSION, + source: "live_chat", + ...summary, + cues, + }; + + const tmp = `${cuesPath}.tmp-${process.pid}`; + await writeFile(tmp, JSON.stringify(out)); + await rename(tmp, cuesPath); + opts.log?.( + `Normalized live chat ${opts.channelSlug}/${path.basename(opts.videoDir)} (${cues.length} cues)`, + ); + return { status: "wrote", cuesPath }; +} + +export async function readNormalizedLiveChat( + cuesPath: string, +): Promise<NormalizedLiveChat | null> { + try { + const raw = await readFile(cuesPath, "utf8"); + const parsed = JSON.parse(raw) as NormalizedLiveChat; + if (typeof parsed.version !== "number") return null; + if (parsed.source !== "live_chat") return null; + return parsed; + } catch { + return null; + } +} + +// Sidecar is fresh iff it exists and its mtime is >= both metadata.info.json +// and transcript.live_chat.json. Used by buildIndex to decide whether to +// trust the cached cues without re-parsing the raw payload. +export async function isLiveChatCuesFresh( + videoDir: string, +): Promise<{ fresh: boolean; cuesPath: string }> { + const cuesPath = path.join(videoDir, LIVE_CHAT_CUES_FILENAME); + const metaPath = path.join(videoDir, META_FILENAME); + const rawPath = path.join(videoDir, LIVE_CHAT_FILENAME); + const [cuesMs, metaMs, rawMs] = await Promise.all([ + mtimeMs(cuesPath), + mtimeMs(metaPath), + mtimeMs(rawPath), + ]); + if (cuesMs === null || metaMs === null || rawMs === null) { + return { fresh: false, cuesPath }; + } + if (cuesMs < metaMs) return { fresh: false, cuesPath }; + if (cuesMs < rawMs) return { fresh: false, cuesPath }; + return { fresh: true, cuesPath }; +} diff --git a/common/controller/normalizeTranscript.ts b/common/controller/normalizeTranscript.ts @@ -23,9 +23,11 @@ export const CUES_FILE_VERSION = 1; export type NormalizedTranscript = TranscriptDetail & { version: number; - source: IndexTranscript["kind"]; + source: IndexTranscript["kind"] | "live_chat"; }; +export type NormalizedLiveChat = NormalizedTranscript & { source: "live_chat" }; + export type NormalizeOptions = { videoDir: string; channelSlug: string; diff --git a/common/lib/videoStatus.ts b/common/lib/videoStatus.ts @@ -21,6 +21,8 @@ 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 LIVE_CHAT_FILENAME = "transcript.live_chat.json"; +export const LIVE_CHAT_CUES_FILENAME = "live_chat.cues.json"; export const META_FILENAME = "metadata.info.json"; export type SubTrack = { @@ -109,6 +111,7 @@ export async function readSubTracks(videoDir: string): Promise<SubTrack[]> { for (const entry of entries) { if (entry === VTT_FILENAME || entry === WHISPER_FILENAME) continue; if (entry === CUES_JSON_FILENAME) continue; + if (entry === LIVE_CHAT_CUES_FILENAME) continue; const m = entry.match(SUB_FILE_RE); if (!m) continue; const ext = m[2].toLowerCase(); diff --git a/editor/app/build/buildAction.ts b/editor/app/build/buildAction.ts @@ -3,11 +3,16 @@ import { revalidatePath } from "next/cache"; import { buildIndex } from "yt-dlp-transcript-common/controller/buildIndex"; import { normalizeAllTranscripts } from "yt-dlp-transcript-common/controller/normalizeAll"; +import { normalizeAllLiveChat } from "yt-dlp-transcript-common/controller/normalizeAllLiveChat"; import { archiveTranscripts, archiveCombinedTranscripts, type ArchiveBuildOptions, } from "yt-dlp-transcript-common/controller/archiveTranscripts"; +import { + archiveLiveChat, + archiveCombinedLiveChat, +} from "yt-dlp-transcript-common/controller/archiveLiveChat"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { runManagedCommand, @@ -96,3 +101,47 @@ export async function archiveCombinedTranscriptsAction( }, }); } + +export async function normalizeLiveChatAction( + queueKey?: string, +): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "normalize-live-chat", + queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(), + paths, + fn: async (onLog, signal) => { + await normalizeAllLiveChat({ paths, onLog, signal }); + }, + }); +} + +export async function archiveLiveChatAction( + build: Partial<ArchiveBuildOptions>, + queueKey?: string, +): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "archive-live-chat", + queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(), + paths, + fn: async (onLog, signal) => { + await archiveLiveChat({ paths, onLog, signal, build }); + }, + }); +} + +export async function archiveCombinedLiveChatAction( + build: Partial<ArchiveBuildOptions>, + queueKey?: string, +): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "archive-combined-live-chat", + queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(), + paths, + fn: async (onLog, signal) => { + await archiveCombinedLiveChat({ paths, onLog, signal, build }); + }, + }); +} diff --git a/editor/app/build/components/BuildButtons.tsx b/editor/app/build/components/BuildButtons.tsx @@ -9,10 +9,13 @@ import { import { QueueControl } from "../../components/QueueControl"; import { cancelJobAction } from "../../jobs/actions"; import { + archiveCombinedLiveChatAction, archiveCombinedTranscriptsAction, + archiveLiveChatAction, archiveTranscriptsAction, buildExportAction, buildIndexAction, + normalizeLiveChatAction, normalizeTranscriptsAction, } from "../buildAction"; import { ArchiveOptionsControl } from "./ArchiveOptionsControl"; @@ -29,12 +32,25 @@ export function BuildButtons({ existingQueues }: Props) { const [normalizeQueue, setNormalizeQueue] = useState(DEFAULT_BUILD_QUEUE); const [archiveQueue, setArchiveQueue] = useState(DEFAULT_BUILD_QUEUE); const [combinedQueue, setCombinedQueue] = useState(DEFAULT_BUILD_QUEUE); + const [liveChatNormalizeQueue, setLiveChatNormalizeQueue] = useState( + DEFAULT_BUILD_QUEUE, + ); + const [liveChatArchiveQueue, setLiveChatArchiveQueue] = useState( + DEFAULT_BUILD_QUEUE, + ); + const [liveChatCombinedQueue, setLiveChatCombinedQueue] = useState( + DEFAULT_BUILD_QUEUE, + ); const [archiveOptions, setArchiveOptions] = useState<ArchiveBuildOptions>( DEFAULT_ARCHIVE_OPTIONS, ); const [combinedOptions, setCombinedOptions] = useState<ArchiveBuildOptions>( DEFAULT_ARCHIVE_OPTIONS, ); + const [liveChatArchiveOptions, setLiveChatArchiveOptions] = + useState<ArchiveBuildOptions>(DEFAULT_ARCHIVE_OPTIONS); + const [liveChatCombinedOptions, setLiveChatCombinedOptions] = + useState<ArchiveBuildOptions>(DEFAULT_ARCHIVE_OPTIONS); return ( <div className="flex flex-col gap-8"> @@ -190,6 +206,113 @@ 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 live chat</h2> + <p className="text-sm text-zinc-500"> + Walks every video and writes{" "} + <code>live_chat.cues.json</code> next to each raw{" "} + <code>transcript.live_chat.json</code>. Idempotent — skips + entries whose cues file is already up to date. + </p> + </div> + <StreamActionLog + trigger={() => normalizeLiveChatAction(liveChatNormalizeQueue)} + cancelAction={cancelJobAction} + buttonLabel="Normalize live chat" + runningLabel="Normalizing live chat…" + extraControls={ + <QueueControl + value={liveChatNormalizeQueue} + onChange={setLiveChatNormalizeQueue} + defaultQueueKey={DEFAULT_BUILD_QUEUE} + existingQueues={existingQueues} + actionLabel="Normalize live chat" + /> + } + /> + </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 live chat archives</h2> + <p className="text-sm text-zinc-500"> + Produces one <code>&lt;channel&gt;.live_chat.tar.gz</code> per + channel under <code>transcripts/export/archives/</code>, + containing each video&apos;s parsed{" "} + <code>live_chat.cues.json</code> plus a{" "} + <code>channel.json</code> manifest. Normalizes missing live + chats on demand. + </p> + </div> + <StreamActionLog + trigger={() => + archiveLiveChatAction(liveChatArchiveOptions, liveChatArchiveQueue) + } + cancelAction={cancelJobAction} + buttonLabel="Build live chat archives" + runningLabel="Building live chat archives…" + extraControls={ + <div className="flex flex-wrap items-center gap-3"> + <ArchiveOptionsControl + value={liveChatArchiveOptions} + onChange={setLiveChatArchiveOptions} + actionLabel="Build live chat archives" + /> + <QueueControl + value={liveChatArchiveQueue} + onChange={setLiveChatArchiveQueue} + defaultQueueKey={DEFAULT_BUILD_QUEUE} + existingQueues={existingQueues} + actionLabel="Build live chat 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 live chat archive + </h2> + <p className="text-sm text-zinc-500"> + Produces a single <code>all-live-chat.tar.gz</code> under{" "} + <code>transcripts/export/archives/</code> containing every + channel&apos;s parsed live chats plus a top-level{" "} + <code>manifest.json</code> index. Normalizes missing live chats + on demand. + </p> + </div> + <StreamActionLog + trigger={() => + archiveCombinedLiveChatAction( + liveChatCombinedOptions, + liveChatCombinedQueue, + ) + } + cancelAction={cancelJobAction} + buttonLabel="Build combined live chat archive" + runningLabel="Building combined live chat archive…" + extraControls={ + <div className="flex flex-wrap items-center gap-3"> + <ArchiveOptionsControl + value={liveChatCombinedOptions} + onChange={setLiveChatCombinedOptions} + actionLabel="Build combined live chat archive" + /> + <QueueControl + value={liveChatCombinedQueue} + onChange={setLiveChatCombinedQueue} + defaultQueueKey={DEFAULT_BUILD_QUEUE} + existingQueues={existingQueues} + actionLabel="Build combined live chat archive" + /> + </div> + } + /> + </section> </div> ); }