feat: live-chat capture during recording — anonymous IRC -> .chat.jsonl

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) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 20:46:50 +02:00
parent 81c775a92e
commit ddee248f6b
7 changed files with 218 additions and 3 deletions

View File

@ -531,6 +531,10 @@
<input type="checkbox" id="downloadChatReplayToggle"> <input type="checkbox" id="downloadChatReplayToggle">
<span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span> <span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span>
</label> </label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="captureLiveChatToggle">
<span id="captureLiveChatLabel">Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)</span>
</label>
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label> <label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>

View File

@ -2,6 +2,7 @@ import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification }
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import { spawn, ChildProcess, execSync, spawnSync } from 'child_process'; import { spawn, ChildProcess, execSync, spawnSync } from 'child_process';
import { connect as tlsConnect, TLSSocket } from 'node:tls';
import axios from 'axios'; import axios from 'axios';
import { autoUpdater } from 'electron-updater'; import { autoUpdater } from 'electron-updater';
import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils'; import { compareUpdateVersions, isNewerUpdateVersion, normalizeUpdateVersion } from './update-version-utils';
@ -215,6 +216,7 @@ interface Config {
auto_record_streamers: string[]; auto_record_streamers: string[];
auto_record_poll_seconds: number; auto_record_poll_seconds: number;
download_chat_replay: boolean; download_chat_replay: boolean;
capture_live_chat: boolean;
} }
interface RuntimeMetrics { interface RuntimeMetrics {
@ -334,7 +336,8 @@ const defaultConfig: Config = {
streamlink_disable_ads: true, streamlink_disable_ads: true,
auto_record_streamers: [], auto_record_streamers: [],
auto_record_poll_seconds: 90, auto_record_poll_seconds: 90,
download_chat_replay: false download_chat_replay: false,
capture_live_chat: false
}; };
const AUTO_RECORD_POLL_MIN_SECONDS = 30; const AUTO_RECORD_POLL_MIN_SECONDS = 30;
@ -432,7 +435,8 @@ function normalizeConfigTemplates(input: Config): Config {
streamlink_disable_ads: input.streamlink_disable_ads !== false, streamlink_disable_ads: input.streamlink_disable_ads !== false,
auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers), auto_record_streamers: normalizeAutoRecordList(input.auto_record_streamers),
auto_record_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds), 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`; 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<string, string> = {};
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( async function downloadLiveStream(
item: QueueItem, item: QueueItem,
onProgress: (progress: DownloadProgress) => void onProgress: (progress: DownloadProgress) => void
@ -3102,11 +3280,32 @@ async function downloadLiveStream(
item.id 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 // No start/end times for live streams — streamlink records until the
// stream actually ends or we kill it. downloadVODPart already handles // stream actually ends or we kill it. downloadVODPart already handles
// null start/end correctly. // null start/end correctly.
const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1); 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( async function downloadVOD(

View File

@ -24,6 +24,7 @@ interface AppConfig {
auto_record_streamers?: string[]; auto_record_streamers?: string[];
auto_record_poll_seconds?: number; auto_record_poll_seconds?: number;
download_chat_replay?: boolean; download_chat_replay?: boolean;
capture_live_chat?: boolean;
[key: string]: unknown; [key: string]: unknown;
} }

View File

@ -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.', 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)', 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.', 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', streamlinkQualityLabel: 'Stream-Qualitaet',
streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.', streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.',
streamlinkQualityBest: 'Best (Standard)', streamlinkQualityBest: 'Best (Standard)',

View File

@ -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.', 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)', 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.', 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', streamlinkQualityLabel: 'Stream quality',
streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".', streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".',
streamlinkQualityBest: 'Best (default)', streamlinkQualityBest: 'Best (default)',

View File

@ -390,6 +390,7 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked, notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked,
streamlink_disable_ads: byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked, streamlink_disable_ads: byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked,
download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked, download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked,
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value, streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10 metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
}; };
@ -437,6 +438,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.notify_on_each_completion === true, effective.notify_on_each_completion === true,
effective.streamlink_disable_ads !== false, effective.streamlink_disable_ads !== false,
effective.download_chat_replay === true, effective.download_chat_replay === true,
effective.capture_live_chat === true,
effective.streamlink_quality ?? 'best', effective.streamlink_quality ?? 'best',
effective.metadata_cache_minutes ?? 10, effective.metadata_cache_minutes ?? 10,
effective.filename_template_vod ?? '{title}.mp4', effective.filename_template_vod ?? '{title}.mp4',
@ -459,6 +461,7 @@ function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true; byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false; byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true; byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best'; byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4'; byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
@ -575,6 +578,7 @@ function initSettingsAutoSave(): void {
'notifyEachCompletionToggle', 'notifyEachCompletionToggle',
'streamlinkDisableAdsToggle', 'streamlinkDisableAdsToggle',
'downloadChatReplayToggle', 'downloadChatReplayToggle',
'captureLiveChatToggle',
'streamlinkQuality' 'streamlinkQuality'
] as const; ] as const;

View File

@ -126,6 +126,9 @@ function applyLanguageToStaticUI(): void {
setText('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayLabel); setText('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayLabel);
setTitle('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayHint); setTitle('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayHint);
setTitle('downloadChatReplayToggle', 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); setText('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityLabel);
setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint); setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint);
setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint); setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint);