From 56261216a9863bac78a0a6c6ed156346186ed3a6 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Sun, 10 May 2026 20:30:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20live=20stream=20recording=20=E2=80=94?= =?UTF-8?q?=20record=20streamers=20as=20they=20go=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VODs disappear from Twitch after 7-60 days depending on the channel partnership tier. Anyone serious about archiving needs to capture streams while they are still live, not after. The downloader is now a recorder too. End-user surface: - Each streamer in the sidebar has a small red "REC" pill next to the remove-x. Click it -> server checks Helix (or public GQL when no client_id is configured) for live status. If the channel is online a new queue item is added with isLive: true, status: pending; the existing queue scheduler picks it up. Toast feedback for offline / already-recording / generic-failure cases. - Live items render with a pulsing red REC badge in the queue title row and skip the bulk-select checkbox + the merge-group selector (they don't make sense for an open-ended capture). - Output goes to {download_path}/{streamer}/live/ {streamer}_LIVE_{YYYY-MM-DD}_{HH-mm-ss}.mp4 — timestamped so back- to-back recordings of the same channel never collide. - Streamlink runs without --hls-start-offset / --hls-duration so it records until the stream actually ends or the user hits cancel / remove. The existing per-item filename claim, integrity check on close, and downloaded_vod_ids tracking apply unchanged (live recordings are not added to downloaded_vod_ids since they have no Twitch VOD ID). Server plumbing: - New getLiveStreamInfo(login) helper. Helix /streams when an app token is available (better metadata: title + game), public GQL fallback otherwise so users in public-mode still get live status. - New IPC start-live-recording(streamerName) does the live check, refuses with ALREADY_RECORDING if a live item for the same channel is already pending or downloading. - downloadVOD branches into a small downloadLiveStream helper when item.isLive — computes the timestamped filename, ensures the per-streamer/live folder exists, hands off to downloadVODPart with null start/end times. - sanitizeQueueItem preserves the isLive flag across queue file reload so a recording in progress survives an app restart in state (though streamlink itself dies on app exit and the user has to re-trigger). DE + EN locale strings for every toast + tooltip + the queue badge. CSS animation for the pulsing badge so it visually distinguishes live recordings from regular VOD downloads at a glance. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 150 ++++++++++++++++++++++++++++++++++++++ src/preload.ts | 1 + src/renderer-globals.d.ts | 2 + src/renderer-locale-de.ts | 10 ++- src/renderer-locale-en.ts | 10 ++- src/renderer-queue.ts | 7 +- src/renderer-streamers.ts | 31 +++++++- src/styles.css | 37 ++++++++++ src/types.ts | 6 ++ 9 files changed, 249 insertions(+), 5 deletions(-) diff --git a/src/main.ts b/src/main.ts index f3b9074..9b50147 100644 --- a/src/main.ts +++ b/src/main.ts @@ -572,6 +572,10 @@ function sanitizeQueueItem(raw: unknown): QueueItem | null { if (files.length > 0) item.outputFiles = files; } + if (raw.isLive === true) { + item.isLive = true; + } + const customClip = sanitizeCustomClip(raw.customClip); if (customClip) item.customClip = customClip; @@ -2127,6 +2131,63 @@ async function getVODs(userId: string, forceRefresh = false): Promise { }); } +interface LiveStreamInfo { + isLive: boolean; + title?: string; + gameName?: string; +} + +// Returns whether the streamer is currently live + a little metadata if +// available. Tries Helix first (better data), falls back to public GQL when +// the user has no client_id/secret configured. A `null` return means we +// couldn't determine — caller should treat as "best-effort". +async function getLiveStreamInfo(login: string): Promise { + const normalized = normalizeLogin(login); + if (!normalized) return null; + + if (await ensureTwitchAuth()) { + try { + const response = await axios.get('https://api.twitch.tv/helix/streams', { + params: { user_login: normalized, first: 1 }, + headers: { + 'Client-ID': config.client_id, + 'Authorization': `Bearer ${accessToken}` + }, + timeout: API_TIMEOUT + }); + const entries = response.data?.data || []; + if (entries.length === 0) return { isLive: false }; + const e = entries[0]; + return { + isLive: e.type === 'live', + title: typeof e.title === 'string' ? e.title : undefined, + gameName: typeof e.game_name === 'string' ? e.game_name : undefined + }; + } catch (e) { + appendDebugLog('helix-streams-failed', { login: normalized, error: String(e) }); + // fall through to public GQL + } + } + + type StreamQueryResult = { + user: { + stream: { id: string; type: string; title?: string; game?: { name?: string } } | null; + } | null; + }; + const data = await fetchPublicTwitchGql( + 'query($login:String!){ user(login:$login){ stream{ id type title game{ name } } } }', + { login: normalized } + ); + if (!data) return null; + const stream = data.user?.stream; + if (!stream) return { isLive: false }; + return { + isLive: stream.type === 'live', + title: stream.title, + gameName: stream.game?.name + }; +} + async function getClipInfo(clipId: string): Promise { const cachedClip = getCachedValue(clipInfoCache, clipId); if (cachedClip !== undefined) { @@ -2780,10 +2841,55 @@ function downloadVODPart( }); } +async function downloadLiveStream( + item: QueueItem, + onProgress: (progress: DownloadProgress) => void +): Promise { + const streamlinkReady = await ensureStreamlinkInstalled(); + if (!streamlinkReady) { + return { success: false, error: tBackend('streamlinkAutoInstallFailed') }; + } + + onProgress({ + id: item.id, + progress: -1, + speed: '', + eta: '', + status: tBackend('statusDownloadStarted'), + currentPart: 0, + totalParts: 0 + }); + + const safeStreamer = (item.streamer || 'live').replace(/[^a-zA-Z0-9_-]/g, ''); + const now = new Date(); + const dateStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`; + const timeStr = `${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now.getSeconds().toString().padStart(2, '0')}`; + const folder = path.join(config.download_path, safeStreamer, 'live'); + fs.mkdirSync(folder, { recursive: true }); + + const filename = ensureUniqueFilename( + path.join(folder, `${safeStreamer}_LIVE_${dateStr}_${timeStr}.mp4`), + item.id + ); + + // 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; +} + async function downloadVOD( item: QueueItem, onProgress: (progress: DownloadProgress) => void ): Promise { + // Live-recording branch: URL is the channel page, no VOD id, no time + // window. Streamlink runs until the stream ends, then we treat the + // whole capture as a single output file. + if (item.isLive) { + return await downloadLiveStream(item, onProgress); + } + const vodId = parseVodId(item.url); if (!isLikelyVodUrl(item.url) || !vodId) { return { @@ -3923,6 +4029,50 @@ ipcMain.handle('get-vods', async (_, userId: string, forceRefresh: boolean = fal ipcMain.handle('get-queue', () => downloadQueue); +ipcMain.handle('start-live-recording', async (_, streamerName: string) => { + if (typeof streamerName !== 'string' || !streamerName) { + return { success: false, error: 'Invalid streamer name' }; + } + const login = normalizeLogin(streamerName); + if (!login) return { success: false, error: 'Invalid streamer name' }; + + const liveInfo = await getLiveStreamInfo(login); + if (liveInfo === null) { + return { success: false, error: 'Could not check live status. Try again.' }; + } + if (!liveInfo.isLive) { + return { success: false, error: 'OFFLINE', streamer: login }; + } + + const channelUrl = `https://www.twitch.tv/${login}`; + const liveItem: QueueItem = { + id: generateQueueItemId(), + title: liveInfo.title || `${login} (LIVE)`, + url: channelUrl, + date: new Date().toISOString(), + streamer: login, + duration_str: '0s', // unknown — stream is in progress + status: 'pending', + progress: 0, + isLive: true + }; + + // Duplicate guard — refuse to start a second live recording of the + // same channel while one is already active or pending. + const dup = downloadQueue.some((it) => it.isLive && it.streamer === login + && (it.status === 'pending' || it.status === 'downloading')); + if (dup) { + return { success: false, error: 'ALREADY_RECORDING', streamer: login }; + } + + downloadQueue.push(liveItem); + saveQueue(downloadQueue); + emitQueueUpdated(); + if (!isDownloading) void processQueue(); + appendDebugLog('live-recording-queued', { streamer: login, title: liveItem.title }); + return { success: true, streamer: login, title: liveInfo.title || login }; +}); + ipcMain.handle('add-to-queue', (_, item: Omit) => { if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) { runtimeMetrics.duplicateSkips += 1; diff --git a/src/preload.ts b/src/preload.ts index 0700350..96a8f0f 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -64,6 +64,7 @@ contextBridge.exposeInMainWorld('api', { // Queue getQueue: () => ipcRenderer.invoke('get-queue'), addToQueue: (item: Omit) => ipcRenderer.invoke('add-to-queue', item), + startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName), removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id), reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds), clearCompleted: () => ipcRenderer.invoke('clear-completed'), diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index e23e54d..f789c60 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -81,6 +81,7 @@ interface QueueItem { customClip?: CustomClip; mergeGroup?: MergeGroup; outputFiles?: string[]; + isLive?: boolean; } interface DownloadProgress { @@ -188,6 +189,7 @@ interface ApiBridge { getVODs(userId: string, forceRefresh?: boolean): Promise; getQueue(): Promise; addToQueue(item: Omit): Promise; + startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>; removeFromQueue(id: string): Promise; reorderQueue(orderIds: string[]): Promise; clearCompleted(): Promise; diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 9d12633..42e7596 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -197,7 +197,15 @@ const UI_TEXT_DE = { ctxCopyUrl: 'URL kopieren', ctxOpenOnTwitch: 'Auf Twitch oeffnen', ctxRemove: 'Aus Queue entfernen', - ctxCopiedUrl: 'URL in Zwischenablage kopiert.' + ctxCopiedUrl: 'URL in Zwischenablage kopiert.', + liveRecordingTitle: 'Live-Aufnahme - laeuft bis der Stream endet' + }, + streamers: { + recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)', + liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.', + liveRecordingOffline: '{streamer} ist gerade offline.', + liveRecordingAlreadyActive: 'Aufnahme von {streamer} laeuft bereits.', + liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden' }, vods: { noneTitle: 'Keine VODs', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 0610217..512d2f2 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -197,7 +197,15 @@ const UI_TEXT_EN = { ctxCopyUrl: 'Copy URL', ctxOpenOnTwitch: 'Open on Twitch', ctxRemove: 'Remove from queue', - ctxCopiedUrl: 'URL copied to clipboard.' + ctxCopiedUrl: 'URL copied to clipboard.', + liveRecordingTitle: 'Live recording — captures until the stream ends' + }, + streamers: { + recordLiveTitle: 'Record this streamer live (captures until stream ends)', + liveRecordingStarted: 'Live recording started for {streamer}.', + liveRecordingOffline: '{streamer} is offline right now.', + liveRecordingAlreadyActive: 'Already recording {streamer}.', + liveRecordingFailed: 'Could not start live recording' }, vods: { noneTitle: 'No VODs', diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts index 6a6de0c..400b03c 100644 --- a/src/renderer-queue.ts +++ b/src/renderer-queue.ts @@ -499,12 +499,15 @@ function renderQueue(): void { const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : ''; const isMergeGroup = !!item.mergeGroup; - const showSelector = item.status === 'pending' && !isMergeGroup; + const showSelector = item.status === 'pending' && !isMergeGroup && !item.isLive; const selectionIndex = selectedQueueIds.indexOf(item.id); const isSelected = selectionIndex >= 0; const mergeIcon = isMergeGroup ? ' ' : ''; + const liveBadge = item.isLive + ? `REC ` + : ''; const mergeMetaExtra = isMergeGroup ? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})` : ''; @@ -518,7 +521,7 @@ function renderQueue(): void {
-
${mergeIcon}${isClip}${safeTitle}
+
${liveBadge}${mergeIcon}${isClip}${safeTitle}
${safeStatusLabel}
${safeMeta}${mergeMetaExtra}
diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index b8a7dec..54e5239 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -406,6 +406,16 @@ function renderStreamers(): void { const nameSpan = document.createElement('span'); nameSpan.textContent = streamer; + // Live-record button — small red dot, only triggers a live capture + // when the streamer is currently online (server checks via Helix). + const recBtn = document.createElement('span'); + recBtn.className = 'streamer-rec'; + recBtn.textContent = 'REC'; + recBtn.title = UI_TEXT.streamers?.recordLiveTitle || 'Record live now'; + recBtn.addEventListener('click', (e) => { + e.stopPropagation(); + void triggerLiveRecording(streamer); + }); const removeSpan = document.createElement('span'); removeSpan.className = 'remove'; removeSpan.textContent = 'x'; @@ -413,7 +423,7 @@ function renderStreamers(): void { e.stopPropagation(); void removeStreamer(streamer); }); - item.append(nameSpan, removeSpan); + item.append(nameSpan, recBtn, removeSpan); item.addEventListener('click', () => { // Skip click if drag was just released — drop fires after dragend @@ -831,6 +841,25 @@ function clearVodSelection(): void { if (lastLoadedStreamer) renderVodGridFromCurrentState(); } +async function triggerLiveRecording(streamer: string): Promise { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + const result = await window.api.startLiveRecording(streamer); + if (!toast) return; + if (result.success) { + toast(UI_TEXT.streamers.liveRecordingStarted.replace('{streamer}', streamer), 'info'); + return; + } + if (result.error === 'OFFLINE') { + toast(UI_TEXT.streamers.liveRecordingOffline.replace('{streamer}', streamer), 'warn'); + return; + } + if (result.error === 'ALREADY_RECORDING') { + toast(UI_TEXT.streamers.liveRecordingAlreadyActive.replace('{streamer}', streamer), 'warn'); + return; + } + toast(UI_TEXT.streamers.liveRecordingFailed + (result.error ? `: ${result.error}` : ''), 'warn'); +} + async function bulkMarkSelectedDownloaded(mark: boolean): Promise { const urls = Array.from(selectedVodUrls); if (urls.length === 0) return; diff --git a/src/styles.css b/src/styles.css index 38f61ce..ae98933 100644 --- a/src/styles.css +++ b/src/styles.css @@ -625,6 +625,43 @@ body { opacity: 0.4; } +.streamer-rec { + margin-left: auto; + margin-right: 6px; + color: #ff4444; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: pointer; + padding: 2px 5px; + border: 1px solid rgba(255, 68, 68, 0.4); + border-radius: 3px; + background: transparent; + transition: background 0.15s; +} + +.streamer-rec:hover { + background: rgba(255, 68, 68, 0.15); +} + +.queue-live-badge { + display: inline-block; + background: #ff4444; + color: white; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 1px 5px; + border-radius: 3px; + vertical-align: middle; + animation: queue-live-pulse 1.5s ease-in-out infinite; +} + +@keyframes queue-live-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + .vod-thumbnail { width: 100%; aspect-ratio: 16/9; diff --git a/src/types.ts b/src/types.ts index 51eaf86..1c6ff89 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,12 @@ export interface QueueItem { // for parts/merge-group splits). Persisted with the queue so completed // items keep their "Open file" / "Show in folder" actions across restarts. outputFiles?: string[]; + // Live stream recording — when true, item.url is the channel URL + // (https://twitch.tv/{streamer}) and streamlink runs until the stream + // ends instead of using --hls-start-offset / --hls-duration. The output + // filename includes a timestamp so consecutive live recordings of the + // same streamer don't collide. + isLive?: boolean; } export interface DownloadProgress {