feat: stream-events log — track title/game changes during live recording

Sibling .events.jsonl alongside each live recording. Default-on
because the cost is one Helix/GQL hit per minute per active
recording — trivial — and the value is real: when seeking inside
a 6h archived stream, "at minute 142 he switched from Just Chatting
to Counter-Strike" is exactly the kind of thing you want answered.

Server:
- new LiveEventTracker (one per active live recording, keyed by
  queue item id). Holds an open file descriptor for the .events.jsonl
  output, last-seen title + game, recording start timestamp.
- start writes a recording_start line with the initial Helix
  metadata snapshot. Stop writes a recording_end line with
  duration + success flag + error message if any.
- Background pollLiveEventsForChanges fires every 60s while at
  least one tracker is active (timer auto-stops when the last
  recording ends so an idle app pays nothing). Per tracker, hits
  getLiveStreamInfo, compares against the cached title/game, emits
  title_change / game_change lines on diff. Game changes also
  trigger a Discord webhook ping when the user has the live-start
  notification enabled — game flips matter more than title micro-
  edits, so we only ping for game.
- JSON Lines format like the chat capture file — a kill mid-stream
  preserves prior data, no need to rewrite.

Wire-up:
- downloadLiveStream starts the tracker after the chat session is
  spun up but before streamlink launches, so the recording_start
  line lands first. Stops it after streamlink exits with the
  result.success flag carried into recording_end. The .events.jsonl
  path is added to outputFiles when it exists so the renderer's
  Open file / Show in folder UI lists it alongside the video and
  chat file.

Renderer / settings:
- new log_stream_events: boolean (default true — it's cheap).
  Settings -> Download card gets a toggle with hint explaining the
  Helix-call-per-minute trade-off.
- AppConfig type, autosave fingerprint, syncSettingsForm,
  applyLanguageToStaticUI, 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 21:38:40 +02:00
parent cd5c4daccf
commit 55434f499d
7 changed files with 196 additions and 2 deletions

View File

@ -535,6 +535,10 @@
<input type="checkbox" id="captureLiveChatToggle"> <input type="checkbox" id="captureLiveChatToggle">
<span id="captureLiveChatLabel">Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)</span> <span id="captureLiveChatLabel">Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)</span>
</label> </label>
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
<input type="checkbox" id="logStreamEventsToggle" checked>
<span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.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

@ -225,6 +225,7 @@ interface Config {
auto_cleanup_days: number; auto_cleanup_days: number;
auto_cleanup_target: 'live_only' | 'all'; auto_cleanup_target: 'live_only' | 'all';
auto_cleanup_action: 'delete' | 'archive'; auto_cleanup_action: 'delete' | 'archive';
log_stream_events: boolean;
} }
interface RuntimeMetrics { interface RuntimeMetrics {
@ -353,7 +354,8 @@ const defaultConfig: Config = {
auto_cleanup_enabled: false, auto_cleanup_enabled: false,
auto_cleanup_days: 30, auto_cleanup_days: 30,
auto_cleanup_target: 'live_only', auto_cleanup_target: 'live_only',
auto_cleanup_action: 'archive' auto_cleanup_action: 'archive',
log_stream_events: true
}; };
const AUTO_RECORD_POLL_MIN_SECONDS = 30; const AUTO_RECORD_POLL_MIN_SECONDS = 30;
@ -467,7 +469,8 @@ function normalizeConfigTemplates(input: Config): Config {
return Math.min(3650, Math.floor(n)); return Math.min(3650, Math.floor(n));
})(), })(),
auto_cleanup_target: input.auto_cleanup_target === 'all' ? 'all' : 'live_only', auto_cleanup_target: input.auto_cleanup_target === 'all' ? 'all' : 'live_only',
auto_cleanup_action: input.auto_cleanup_action === 'delete' ? 'delete' : 'archive' auto_cleanup_action: input.auto_cleanup_action === 'delete' ? 'delete' : 'archive',
log_stream_events: input.log_stream_events !== false
}; };
} }
@ -3506,6 +3509,151 @@ async function sendDiscordWebhook(payload: {
} }
} }
// ==========================================
// LIVE RECORDING EVENTS LOG
// ==========================================
// Sibling .events.jsonl file alongside each live recording. Tracks
// recording start/end + Twitch metadata changes (title / game) that
// happen while the stream is being captured. Useful when seeking
// inside a long archived stream — tells you "at minute 142 he switched
// from Just Chatting to Counter-Strike". Independent of chat capture
// (lives even if capture_live_chat is off) and uses JSON Lines for
// the same crash-safety reason.
interface LiveEventTracker {
itemId: string;
streamer: string;
eventsPath: string;
fileHandle: number | null;
startedAt: number; // Date.now() when recording started
lastTitle: string;
lastGame: string;
closing: boolean;
}
const liveEventTrackers = new Map<string, LiveEventTracker>();
let liveEventsPollTimer: NodeJS.Timeout | null = null;
function eventsLogPathFor(videoPath: string): string {
const ext = path.extname(videoPath);
const base = ext ? videoPath.slice(0, -ext.length) : videoPath;
return `${base}.events.jsonl`;
}
function appendEventLine(tracker: LiveEventTracker, payload: Record<string, unknown>): void {
if (tracker.fileHandle === null) return;
const line = JSON.stringify({ t: new Date().toISOString(), ...payload }) + '\n';
try {
fs.writeSync(tracker.fileHandle, line);
} catch (e) {
appendDebugLog('events-log-write-failed', { itemId: tracker.itemId, error: String(e) });
}
}
function startLiveEventsTracker(itemId: string, streamer: string, videoPath: string, initialTitle: string, initialGame: string): LiveEventTracker | null {
const eventsPath = eventsLogPathFor(videoPath);
let fd: number;
try {
fd = fs.openSync(eventsPath, 'w');
} catch (e) {
appendDebugLog('events-log-open-failed', { itemId, eventsPath, error: String(e) });
return null;
}
const tracker: LiveEventTracker = {
itemId,
streamer,
eventsPath,
fileHandle: fd,
startedAt: Date.now(),
lastTitle: initialTitle,
lastGame: initialGame,
closing: false
};
appendEventLine(tracker, {
type: 'recording_start',
streamer,
title: initialTitle,
game: initialGame
});
liveEventTrackers.set(itemId, tracker);
ensureLiveEventsPollTimer();
return tracker;
}
function stopLiveEventsTracker(itemId: string, finalNote?: { success: boolean; durationMs: number; error?: string }): void {
const tracker = liveEventTrackers.get(itemId);
if (!tracker || tracker.closing) return;
tracker.closing = true;
appendEventLine(tracker, {
type: 'recording_end',
durationSeconds: finalNote ? Math.floor(finalNote.durationMs / 1000) : Math.floor((Date.now() - tracker.startedAt) / 1000),
success: finalNote?.success === true,
error: finalNote?.error || ''
});
if (tracker.fileHandle !== null) {
try { fs.closeSync(tracker.fileHandle); } catch { /* ignore */ }
tracker.fileHandle = null;
}
liveEventTrackers.delete(itemId);
if (liveEventTrackers.size === 0 && liveEventsPollTimer) {
clearInterval(liveEventsPollTimer);
liveEventsPollTimer = null;
}
}
function ensureLiveEventsPollTimer(): void {
if (liveEventsPollTimer) return;
// Same cadence as auto-record polling; metadata changes don't need
// sub-minute resolution and we want to keep API load bounded.
liveEventsPollTimer = setInterval(() => { void pollLiveEventsForChanges(); }, 60 * 1000);
liveEventsPollTimer.unref?.();
}
async function pollLiveEventsForChanges(): Promise<void> {
if (liveEventTrackers.size === 0) return;
for (const tracker of liveEventTrackers.values()) {
if (tracker.closing) continue;
const info = await getLiveStreamInfo(tracker.streamer);
if (!info || !info.isLive) continue;
const currentTitle = info.title || '';
const currentGame = info.gameName || '';
if (currentTitle !== tracker.lastTitle) {
appendEventLine(tracker, {
type: 'title_change',
from: tracker.lastTitle,
to: currentTitle
});
tracker.lastTitle = currentTitle;
}
if (currentGame !== tracker.lastGame) {
appendEventLine(tracker, {
type: 'game_change',
from: tracker.lastGame,
to: currentGame
});
tracker.lastGame = currentGame;
// Also fire a webhook ping if the user wants it. Game changes
// matter more than title micro-tweaks, so we only ping for game.
if (config.discord_notify_live_start) {
void sendDiscordWebhook({
title: `Game change: ${tracker.streamer}`,
description: `Now playing **${currentGame || 'unknown'}**`,
color: 'info',
fields: [
{ name: 'Title', value: currentTitle || '-', inline: false }
]
});
}
}
}
}
// ========================================== // ==========================================
// LIVE CHAT CAPTURE (during live recording) // LIVE CHAT CAPTURE (during live recording)
// ========================================== // ==========================================
@ -3722,6 +3870,26 @@ async function downloadLiveStream(
chatSession = startLiveChatCapture(item.streamer, chatPath); chatSession = startLiveChatCapture(item.streamer, chatPath);
} }
// Stream-events tracker — records title/game changes that happen
// while we're capturing. Cheap (one Helix/GQL hit per minute) and
// useful for long archives where the user later wants to seek.
let eventsTracker: LiveEventTracker | null = null;
if (config.log_stream_events) {
// Best-effort initial metadata snapshot. If the call fails the
// tracker still starts with empty title/game and the first
// successful poll shows them as a change.
let initialTitle = '';
let initialGame = '';
try {
const info = await getLiveStreamInfo(item.streamer);
if (info) {
initialTitle = info.title || '';
initialGame = info.gameName || '';
}
} catch { /* ignore */ }
eventsTracker = startLiveEventsTracker(item.id, item.streamer, filename, initialTitle, initialGame);
}
if (config.discord_notify_live_start) { if (config.discord_notify_live_start) {
void sendDiscordWebhook({ void sendDiscordWebhook({
title: `Recording started: ${item.streamer}`, title: `Recording started: ${item.streamer}`,
@ -3744,6 +3912,13 @@ async function downloadLiveStream(
if (chatSession) { if (chatSession) {
stopLiveChatCapture(chatSession); stopLiveChatCapture(chatSession);
} }
if (eventsTracker) {
stopLiveEventsTracker(item.id, {
success: result.success,
durationMs: Date.now() - recordingStartedAt,
error: result.error
});
}
if (config.discord_notify_live_end) { if (config.discord_notify_live_end) {
const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000)); const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000));
@ -3766,6 +3941,9 @@ async function downloadLiveStream(
if (chatSession && fs.existsSync(chatSession.outputPath)) { if (chatSession && fs.existsSync(chatSession.outputPath)) {
outputs.push(chatSession.outputPath); outputs.push(chatSession.outputPath);
} }
if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) {
outputs.push(eventsTracker.eventsPath);
}
return { ...result, outputFiles: outputs }; return { ...result, outputFiles: outputs };
} }

View File

@ -33,6 +33,7 @@ interface AppConfig {
auto_cleanup_days?: number; auto_cleanup_days?: number;
auto_cleanup_target?: 'live_only' | 'all'; auto_cleanup_target?: 'live_only' | 'all';
auto_cleanup_action?: 'delete' | 'archive'; auto_cleanup_action?: 'delete' | 'archive';
log_stream_events?: boolean;
[key: string]: unknown; [key: string]: unknown;
} }

View File

@ -112,6 +112,8 @@ const UI_TEXT_DE = {
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)', 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).', 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).',
logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)',
logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr guenstig — ein zusaetzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.',
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

@ -112,6 +112,8 @@ const UI_TEXT_EN = {
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)', 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.', 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.',
logStreamEventsLabel: 'Log stream events during live recording (.events.jsonl)',
logStreamEventsHint: 'Polls the streamer once a minute and writes title / game changes to a sibling .events.jsonl file. Useful for seeking inside long archived streams ("when did he switch to CS:GO?"). Cheap — one extra Helix/GQL hit per minute per active recording.',
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

@ -553,6 +553,7 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
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, capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
log_stream_events: byId<HTMLInputElement>('logStreamEventsToggle').checked,
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(), discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked, discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked, discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
@ -609,6 +610,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
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.capture_live_chat === true,
effective.log_stream_events !== false,
effective.discord_webhook_url ?? '', effective.discord_webhook_url ?? '',
effective.discord_notify_live_start === true, effective.discord_notify_live_start === true,
effective.discord_notify_live_end === true, effective.discord_notify_live_end === true,
@ -640,6 +642,7 @@ function syncSettingsFormFromConfig(): void {
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<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || ''; byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true; byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true; byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
@ -765,6 +768,7 @@ function initSettingsAutoSave(): void {
'streamlinkDisableAdsToggle', 'streamlinkDisableAdsToggle',
'downloadChatReplayToggle', 'downloadChatReplayToggle',
'captureLiveChatToggle', 'captureLiveChatToggle',
'logStreamEventsToggle',
'discordNotifyLiveStartToggle', 'discordNotifyLiveStartToggle',
'discordNotifyLiveEndToggle', 'discordNotifyLiveEndToggle',
'discordNotifyVodCompleteToggle', 'discordNotifyVodCompleteToggle',

View File

@ -129,6 +129,9 @@ function applyLanguageToStaticUI(): void {
setText('captureLiveChatLabel', UI_TEXT.static.captureLiveChatLabel); setText('captureLiveChatLabel', UI_TEXT.static.captureLiveChatLabel);
setTitle('captureLiveChatLabel', UI_TEXT.static.captureLiveChatHint); setTitle('captureLiveChatLabel', UI_TEXT.static.captureLiveChatHint);
setTitle('captureLiveChatToggle', UI_TEXT.static.captureLiveChatHint); setTitle('captureLiveChatToggle', UI_TEXT.static.captureLiveChatHint);
setText('logStreamEventsLabel', UI_TEXT.static.logStreamEventsLabel);
setTitle('logStreamEventsLabel', UI_TEXT.static.logStreamEventsHint);
setTitle('logStreamEventsToggle', UI_TEXT.static.logStreamEventsHint);
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);