diff --git a/src/index.html b/src/index.html index 5b6eeea..10f8b57 100644 --- a/src/index.html +++ b/src/index.html @@ -535,6 +535,10 @@ Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl) +
diff --git a/src/main.ts b/src/main.ts index d8f90b3..a9635ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -225,6 +225,7 @@ interface Config { auto_cleanup_days: number; auto_cleanup_target: 'live_only' | 'all'; auto_cleanup_action: 'delete' | 'archive'; + log_stream_events: boolean; } interface RuntimeMetrics { @@ -353,7 +354,8 @@ const defaultConfig: Config = { auto_cleanup_enabled: false, auto_cleanup_days: 30, auto_cleanup_target: 'live_only', - auto_cleanup_action: 'archive' + auto_cleanup_action: 'archive', + log_stream_events: true }; const AUTO_RECORD_POLL_MIN_SECONDS = 30; @@ -467,7 +469,8 @@ function normalizeConfigTemplates(input: Config): Config { return Math.min(3650, Math.floor(n)); })(), 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(); +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): 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 { + 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) // ========================================== @@ -3722,6 +3870,26 @@ async function downloadLiveStream( 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) { void sendDiscordWebhook({ title: `Recording started: ${item.streamer}`, @@ -3744,6 +3912,13 @@ async function downloadLiveStream( if (chatSession) { stopLiveChatCapture(chatSession); } + if (eventsTracker) { + stopLiveEventsTracker(item.id, { + success: result.success, + durationMs: Date.now() - recordingStartedAt, + error: result.error + }); + } if (config.discord_notify_live_end) { const durationSec = Math.max(0, Math.floor((Date.now() - recordingStartedAt) / 1000)); @@ -3766,6 +3941,9 @@ async function downloadLiveStream( if (chatSession && fs.existsSync(chatSession.outputPath)) { outputs.push(chatSession.outputPath); } + if (eventsTracker && fs.existsSync(eventsTracker.eventsPath)) { + outputs.push(eventsTracker.eventsPath); + } return { ...result, outputFiles: outputs }; } diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index ef49aaa..ad99062 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -33,6 +33,7 @@ interface AppConfig { auto_cleanup_days?: number; auto_cleanup_target?: 'live_only' | 'all'; auto_cleanup_action?: 'delete' | 'archive'; + log_stream_events?: boolean; [key: string]: unknown; } diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 3ac62b9..269c46c 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -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.', 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).', + 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', 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 084ca63..c2c418b 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -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.', 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.', + 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', 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 6f6ba43..b0cbc4d 100644 --- a/src/renderer-settings.ts +++ b/src/renderer-settings.ts @@ -553,6 +553,7 @@ function collectDownloadSettingsPayload(): Partial { streamlink_disable_ads: byId('streamlinkDisableAdsToggle').checked, download_chat_replay: byId('downloadChatReplayToggle').checked, capture_live_chat: byId('captureLiveChatToggle').checked, + log_stream_events: byId('logStreamEventsToggle').checked, discord_webhook_url: byId('discordWebhookUrl').value.trim(), discord_notify_live_start: byId('discordNotifyLiveStartToggle').checked, discord_notify_live_end: byId('discordNotifyLiveEndToggle').checked, @@ -609,6 +610,7 @@ function getSettingsFingerprint(payload: Partial): string { effective.streamlink_disable_ads !== false, effective.download_chat_replay === true, effective.capture_live_chat === true, + effective.log_stream_events !== false, effective.discord_webhook_url ?? '', effective.discord_notify_live_start === true, effective.discord_notify_live_end === true, @@ -640,6 +642,7 @@ function syncSettingsFormFromConfig(): void { 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('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false; byId('discordWebhookUrl').value = (config.discord_webhook_url as string) || ''; byId('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true; byId('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true; @@ -765,6 +768,7 @@ function initSettingsAutoSave(): void { 'streamlinkDisableAdsToggle', 'downloadChatReplayToggle', 'captureLiveChatToggle', + 'logStreamEventsToggle', 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle', 'discordNotifyVodCompleteToggle', diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index 6a173d5..fc36d3f 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -129,6 +129,9 @@ function applyLanguageToStaticUI(): void { setText('captureLiveChatLabel', UI_TEXT.static.captureLiveChatLabel); setTitle('captureLiveChatLabel', 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); setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint); setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint);