import type { Cue } from "./vtt"; // yt-dlp writes live_chat as JSON-lines (one continuation action per line), // extracted from YouTube's youtube_live_chat_replay protocol. Each line wraps // a `replayChatItemAction` whose `actions[]` contain `addChatItemAction.item` // entries with one of several renderers (`liveChatTextMessageRenderer`, // `liveChatPaidMessageRenderer`, `liveChatMembershipItemRenderer`, etc.). // // We emit one Cue per addChatItemAction that has a renderable text body, with // `start` derived from `videoOffsetTimeMsec` and `text` formatted as // `: `. Non-text events (ticker pings without an associated // message, viewer-engagement nags) are skipped. type Run = { text?: string; emoji?: { emojiId?: string; shortcuts?: string[] } }; type MessageBody = { simpleText?: string; runs?: Run[] }; type AuthorBody = { simpleText?: string }; type ChatRenderer = { message?: MessageBody; headerSubtext?: MessageBody; text?: MessageBody; authorName?: AuthorBody; }; type ChatItem = Record; type AddAction = { item?: ChatItem }; type ChatAction = { addChatItemAction?: AddAction }; type ReplayAction = { actions?: ChatAction[]; videoOffsetTimeMsec?: string; }; type ReplayEnvelope = { replayChatItemAction?: ReplayAction; videoOffsetTimeMsec?: string; }; const TEXT_RENDERERS = [ "liveChatTextMessageRenderer", "liveChatPaidMessageRenderer", "liveChatPaidStickerRenderer", "liveChatMembershipItemRenderer", "liveChatSponsorshipsGiftPurchaseAnnouncementRenderer", "liveChatSponsorshipsGiftRedemptionAnnouncementRenderer", ]; function renderMessage(body: MessageBody | undefined): string { if (!body) return ""; if (typeof body.simpleText === "string") return body.simpleText; if (!Array.isArray(body.runs)) return ""; const parts: string[] = []; for (const run of body.runs) { if (typeof run.text === "string") { parts.push(run.text); } else if (run.emoji?.shortcuts?.length) { parts.push(run.emoji.shortcuts[0]); } } return parts.join(""); } function authorOf(renderer: ChatRenderer): string { return renderer.authorName?.simpleText ?? ""; } function textOf(renderer: ChatRenderer): string { // Sponsorship gift announcements use `headerSubtext`; sticker/paid use // `message`; membership pings use `headerSubtext` too. Fall back through // the known fields. return ( renderMessage(renderer.message) || renderMessage(renderer.headerSubtext) || renderMessage(renderer.text) ); } export function parseLiveChat(src: string): Cue[] { const lines = src.split("\n"); const cues: Cue[] = []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; let envelope: ReplayEnvelope; try { envelope = JSON.parse(trimmed) as ReplayEnvelope; } catch { continue; } const replay = envelope.replayChatItemAction; const offsetRaw = replay?.videoOffsetTimeMsec ?? envelope.videoOffsetTimeMsec; const offsetMs = offsetRaw ? Number(offsetRaw) : NaN; if (!Number.isFinite(offsetMs)) continue; const start = offsetMs / 1000; for (const action of replay?.actions ?? []) { const item = action.addChatItemAction?.item; if (!item) continue; for (const rendererKey of TEXT_RENDERERS) { const renderer = item[rendererKey]; if (!renderer) continue; const text = textOf(renderer).trim(); if (!text) continue; const author = authorOf(renderer); cues.push({ start, end: start + 5, text: author ? `${author}: ${text}` : text, }); break; } } } cues.sort((a, b) => a.start - b.start); return cues; }