From ddee248f6bfa38d5f26dc19072f7515f27aae0a6 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sun, 10 May 2026 20:46:50 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20live-chat=20capture=20during=20recordin?= =?UTF-8?q?g=20=E2=80=94=20anonymous=20IRC=20->=20.chat.jsonl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to 4.6.2 (VOD chat replay): when capturing a live stream, also open an anonymous IRC connection to Twitch chat and append every message to a sibling .chat.jsonl file. Closes the symmetry — VOD downloads get .chat.json, live recordings get .chat.jsonl. Both formats are deliberate: VOD pulls finite, JSON-array friendly; live streams are open-ended, JSON Lines friendly so a kill mid-stream preserves prior data. Server: - new LiveChatSession + startLiveChatCapture / stopLiveChatCapture. Opens a TLS connection to irc.chat.twitch.tv:6697, anonymous Twitch auth (NICK justinfan{rand}, no PASS), JOINs the channel, enables CAP twitch.tv/tags + commands so we get badges, color, display-name, etc. - IRC line parser: minimal — split tags / prefix / command / params, handle PRIVMSG (chat), USERNOTICE (subs/raids), CLEARCHAT, CLEARMSG. Each parsed message is one JSON object on its own line: { t, type, u, login, color, msg, badges, bits, msgId, systemMsg }. Per-line write keeps memory flat — a 12-hour stream's chat could be hundreds of MB; we never hold more than one batch in RAM. - File handle is opened up-front (so a write failure surfaces early), always closed on the close event. - PING/PONG handling so Twitch doesn't ratelimit the connection out. - Header line written at session start so an empty-chat capture still produces a valid file with metadata. Wire-up: - downloadLiveStream starts the session BEFORE streamlink (so the first JOIN messages aren't lost) and stops it AFTER streamlink exits (so trailing reactions still get logged). Failures inside the chat session do NOT mark the recording as failed — the video is still fine. The chat file path is added to outputFiles when it exists so the existing Open file / Show in folder UI lists both. Renderer / settings: - new capture_live_chat: boolean (default off). Settings -> Download card gets the toggle with hint. - AppConfig type, autosave fingerprint, syncSettingsForm, locale strings (DE + EN). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.html | 4 + src/main.ts | 205 +++++++++++++++++++++++++++++++++++++- src/renderer-globals.d.ts | 1 + src/renderer-locale-de.ts | 2 + src/renderer-locale-en.ts | 2 + src/renderer-settings.ts | 4 + src/renderer-texts.ts | 3 + 7 files changed, 218 insertions(+), 3 deletions(-) diff --git a/src/index.html b/src/index.html index 0352cae..3105596 100644 --- a/src/index.html +++ b/src/index.html @@ -531,6 +531,10 @@ Chat-Replay parallel zum VOD speichern (.chat.json) +
diff --git a/src/main.ts b/src/main.ts index 6c78634..0f7a0b5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification } import * as path from 'path'; import * as fs from 'fs'; import { spawn, ChildProcess, execSync, spawnSync } from 'child_process'; +import { connect as tlsConnect, TLSSocket } from 'node:tls'; import axios from 'axios'; import { autoUpdater } from 'electron-updater'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils'; @@ -215,6 +216,7 @@ interface Config { auto_record_streamers: string[]; auto_record_poll_seconds: number; download_chat_replay: boolean; + capture_live_chat: boolean; } interface RuntimeMetrics { @@ -334,7 +336,8 @@ const defaultConfig: Config = { streamlink_disable_ads: true, auto_record_streamers: [], auto_record_poll_seconds: 90, - download_chat_replay: false + download_chat_replay: false, + capture_live_chat: false }; const AUTO_RECORD_POLL_MIN_SECONDS = 30; @@ -432,7 +435,8 @@ function normalizeConfigTemplates(input: Config): Config { streamlink_disable_ads: input.streamlink_disable_ads !== false, auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers), auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds), - download_chat_replay: input.download_chat_replay === true + download_chat_replay: input.download_chat_replay === true, + capture_live_chat: input.capture_live_chat === true }; } @@ -3071,6 +3075,180 @@ function chatReplayPathFor(vodFilePath: string): string { return `${base}.chat.json`; } +// ========================================== +// LIVE CHAT CAPTURE (during live recording) +// ========================================== +// Companion to fetchVodChatReplay: while a stream is being recorded live, +// open an anonymous IRC connection to Twitch chat and append every message +// to a sibling .chat.jsonl file. Format is JSON Lines (one JSON object per +// line) so a partial / killed write still parses correctly — important +// because live recordings can run for many hours and we don't want to +// keep the full chat in memory. +interface LiveChatSession { + streamer: string; + outputPath: string; + socket: TLSSocket; + fileHandle: number | null; + closing: boolean; + messageCount: number; + buffer: string; +} + +const TWITCH_IRC_HOST = 'irc.chat.twitch.tv'; +const TWITCH_IRC_PORT = 6697; + +function liveChatPathFor(videoPath: string): string { + const ext = path.extname(videoPath); + const base = ext ? videoPath.slice(0, -ext.length) : videoPath; + return `${base}.chat.jsonl`; +} + +function startLiveChatCapture(streamer: string, outputPath: string): LiveChatSession | null { + const channelName = normalizeLogin(streamer); + if (!channelName) return null; + + let fd: number; + try { + fd = fs.openSync(outputPath, 'w'); + } catch (e) { + appendDebugLog('chat-capture-open-failed', { streamer: channelName, outputPath, error: String(e) }); + return null; + } + + const session: LiveChatSession = { + streamer: channelName, + outputPath, + socket: tlsConnect({ host: TWITCH_IRC_HOST, port: TWITCH_IRC_PORT, servername: TWITCH_IRC_HOST }), + fileHandle: fd, + closing: false, + messageCount: 0, + buffer: '' + }; + + // Write a header line so the file is self-describing even if zero + // messages arrive (e.g. silent stream, immediate disconnect). + const header = { + type: 'header', + streamer: channelName, + startedAt: new Date().toISOString(), + format: 'twitch-vod-manager-chat-jsonl-v1' + }; + try { fs.writeSync(fd, JSON.stringify(header) + '\n'); } catch { /* ignore */ } + + session.socket.on('secureConnect', () => { + // Anonymous Twitch IRC: any nick prefixed with "justinfan" is + // accepted without a password. Random suffix avoids collisions. + const nick = `justinfan${Math.floor(Math.random() * 100000)}`; + try { + session.socket.write('CAP REQ :twitch.tv/tags twitch.tv/commands\r\n'); + session.socket.write(`NICK ${nick}\r\n`); + session.socket.write(`JOIN #${channelName}\r\n`); + } catch (e) { + appendDebugLog('chat-capture-handshake-failed', { streamer: channelName, error: String(e) }); + } + appendDebugLog('chat-capture-connected', { streamer: channelName, nick }); + }); + + session.socket.on('data', (chunk: Buffer) => { + session.buffer += chunk.toString('utf-8'); + const lines = session.buffer.split('\r\n'); + session.buffer = lines.pop() || ''; + for (const line of lines) { + handleIrcLine(session, line); + } + }); + + session.socket.on('error', (err: Error) => { + appendDebugLog('chat-capture-socket-error', { streamer: channelName, error: String(err) }); + }); + + session.socket.on('close', () => { + if (!session.closing) { + appendDebugLog('chat-capture-disconnected', { streamer: channelName, messages: session.messageCount }); + } + if (session.fileHandle !== null) { + try { fs.closeSync(session.fileHandle); } catch { /* ignore */ } + session.fileHandle = null; + } + }); + + return session; +} + +function handleIrcLine(session: LiveChatSession, line: string): void { + if (!line) return; + if (line.startsWith('PING')) { + try { session.socket.write('PONG' + line.slice(4) + '\r\n'); } catch { /* ignore */ } + return; + } + + let rest = line; + let tagsStr = ''; + if (rest.startsWith('@')) { + const sp = rest.indexOf(' '); + if (sp < 0) return; + tagsStr = rest.slice(1, sp); + rest = rest.slice(sp + 1); + } + let prefix = ''; + if (rest.startsWith(':')) { + const sp = rest.indexOf(' '); + if (sp < 0) return; + prefix = rest.slice(1, sp); + rest = rest.slice(sp + 1); + } + const cmdSp = rest.indexOf(' '); + const command = cmdSp < 0 ? rest : rest.slice(0, cmdSp); + const params = cmdSp < 0 ? '' : rest.slice(cmdSp + 1); + + if (command !== 'PRIVMSG' && command !== 'USERNOTICE' && command !== 'CLEARCHAT' && command !== 'CLEARMSG') return; + + const colonIdx = params.indexOf(' :'); + const text = colonIdx >= 0 ? params.slice(colonIdx + 2) : ''; + + const tags: Record = {}; + if (tagsStr) { + for (const pair of tagsStr.split(';')) { + const eq = pair.indexOf('='); + if (eq < 0) continue; + tags[pair.slice(0, eq)] = pair.slice(eq + 1); + } + } + + const login = (prefix.split('!')[0] || tags['login'] || '').toLowerCase(); + const message = { + t: new Date().toISOString(), + type: command === 'PRIVMSG' ? 'msg' : (command === 'USERNOTICE' ? 'notice' : command.toLowerCase()), + u: tags['display-name'] || login, + login, + color: tags['color'] || '', + msg: text, + badges: tags['badges'] || '', + bits: tags['bits'] || '', + msgId: tags['msg-id'] || '', + systemMsg: (tags['system-msg'] || '').replace(/\\s/g, ' ') + }; + + if (session.fileHandle === null) return; + try { + fs.writeSync(session.fileHandle, JSON.stringify(message) + '\n'); + session.messageCount++; + } catch (e) { + appendDebugLog('chat-capture-write-failed', { error: String(e) }); + } +} + +function stopLiveChatCapture(session: LiveChatSession): void { + if (session.closing) return; + session.closing = true; + appendDebugLog('chat-capture-stopping', { streamer: session.streamer, messages: session.messageCount }); + try { session.socket.write(`PART #${session.streamer}\r\nQUIT\r\n`); } catch { /* ignore */ } + try { session.socket.end(); } catch { /* ignore */ } + setTimeout(() => { + try { session.socket.destroy(); } catch { /* ignore */ } + }, 500); +} + async function downloadLiveStream( item: QueueItem, onProgress: (progress: DownloadProgress) => void @@ -3102,11 +3280,32 @@ async function downloadLiveStream( item.id ); + // Optional: anonymous IRC chat capture for the duration of the + // recording. Sibling .chat.jsonl file. We start it BEFORE streamlink + // so the very first chat lines after JOIN aren't dropped, and stop it + // AFTER streamlink exits so trailing messages (e.g. "stream offline" + // user reactions) are still captured. + let chatSession: LiveChatSession | null = null; + if (config.capture_live_chat) { + const chatPath = liveChatPathFor(filename); + chatSession = startLiveChatCapture(item.streamer, chatPath); + } + // No start/end times for live streams — streamlink records until the // stream actually ends or we kill it. downloadVODPart already handles // null start/end correctly. const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1); - return result.success ? { ...result, outputFiles: [filename] } : result; + + if (chatSession) { + stopLiveChatCapture(chatSession); + } + + if (!result.success) return result; + const outputs = [filename]; + if (chatSession && fs.existsSync(chatSession.outputPath)) { + outputs.push(chatSession.outputPath); + } + return { ...result, outputFiles: outputs }; } async function downloadVOD( diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 78174d2..2b04286 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -24,6 +24,7 @@ interface AppConfig { auto_record_streamers?: string[]; auto_record_poll_seconds?: number; download_chat_replay?: boolean; + capture_live_chat?: boolean; [key: string]: unknown; } diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 487886b..26d0182 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -75,6 +75,8 @@ const UI_TEXT_DE = { streamlinkDisableAdsHint: 'Gibt --twitch-disable-ads an streamlink weiter, damit Mid-Roll-Ads nicht ins VOD eingebettet werden. Empfohlen aktiv lassen.', downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)', downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der oeffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behaelt Chat-Replays nur solange wie das VOD selbst.', + captureLiveChatLabel: 'Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)', + captureLiveChatHint: 'Oeffnet waehrend einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch frueheren Inhalt nicht korrumpiert).', streamlinkQualityLabel: 'Stream-Qualitaet', streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.', streamlinkQualityBest: 'Best (Standard)', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index fa290fa..55d1cd4 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -75,6 +75,8 @@ const UI_TEXT_EN = { streamlinkDisableAdsHint: 'Passes --twitch-disable-ads to streamlink so mid-roll ads do not get embedded into the VOD output. Recommended on.', downloadChatReplayLabel: 'Save chat replay alongside each VOD (.chat.json)', downloadChatReplayHint: 'After a VOD download completes, fetches the public chat replay via Twitch GQL and saves it as JSON next to the video. Twitch keeps chat replay only as long as the VOD itself.', + captureLiveChatLabel: 'Capture live chat during recording (.chat.jsonl)', + captureLiveChatHint: 'Opens an anonymous IRC connection to Twitch chat during a live recording and appends every message to a sibling .chat.jsonl file (JSON Lines, one message per line) so a long capture can be killed mid-stream without corrupting earlier data.', streamlinkQualityLabel: 'Stream quality', streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".', streamlinkQualityBest: 'Best (default)', diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts index 4e60e5b..3190f2c 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -390,6 +390,7 @@ function collectDownloadSettingsPayload(): Partial { notify_on_each_completion: byId('notifyEachCompletionToggle').checked, streamlink_disable_ads: byId('streamlinkDisableAdsToggle').checked, download_chat_replay: byId('downloadChatReplayToggle').checked, + capture_live_chat: byId('captureLiveChatToggle').checked, streamlink_quality: byId('streamlinkQuality').value, metadata_cache_minutes: parseInt(byId('metadataCacheMinutes').value, 10) || 10 }; @@ -437,6 +438,7 @@ function getSettingsFingerprint(payload: Partial): string { effective.notify_on_each_completion === true, effective.streamlink_disable_ads !== false, effective.download_chat_replay === true, + effective.capture_live_chat === true, effective.streamlink_quality ?? 'best', effective.metadata_cache_minutes ?? 10, effective.filename_template_vod ?? '{title}.mp4', @@ -459,6 +461,7 @@ function syncSettingsFormFromConfig(): void { byId('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true; byId('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false; byId('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true; + byId('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true; byId('streamlinkQuality').value = (config.streamlink_quality as string) || 'best'; byId('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4'; @@ -575,6 +578,7 @@ function initSettingsAutoSave(): void { 'notifyEachCompletionToggle', 'streamlinkDisableAdsToggle', 'downloadChatReplayToggle', + 'captureLiveChatToggle', 'streamlinkQuality' ] as const; diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index 584555e..66d7325 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -126,6 +126,9 @@ function applyLanguageToStaticUI(): void { setText('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayLabel); setTitle('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayHint); setTitle('downloadChatReplayToggle', UI_TEXT.static.downloadChatReplayHint); + setText('captureLiveChatLabel', UI_TEXT.static.captureLiveChatLabel); + setTitle('captureLiveChatLabel', UI_TEXT.static.captureLiveChatHint); + setTitle('captureLiveChatToggle', UI_TEXT.static.captureLiveChatHint); setText('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityLabel); setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint); setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint);