diff --git a/src/main.ts b/src/main.ts index 9b50147..59c1453 100644 --- a/src/main.ts +++ b/src/main.ts @@ -208,6 +208,8 @@ interface Config { streamlink_quality: string; notify_on_each_completion: boolean; streamlink_disable_ads: boolean; + auto_record_streamers: string[]; + auto_record_poll_seconds: number; } interface RuntimeMetrics { @@ -324,9 +326,34 @@ const defaultConfig: Config = { downloaded_vod_ids: [], streamlink_quality: 'best', notify_on_each_completion: false, - streamlink_disable_ads: true + streamlink_disable_ads: true, + auto_record_streamers: [], + auto_record_poll_seconds: 90 }; +const AUTO_RECORD_POLL_MIN_SECONDS = 30; +const AUTO_RECORD_POLL_MAX_SECONDS = 1800; +function normalizeAutoRecordPollSeconds(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 90; + return Math.max(AUTO_RECORD_POLL_MIN_SECONDS, Math.min(AUTO_RECORD_POLL_MAX_SECONDS, Math.floor(parsed))); +} + +function normalizeAutoRecordList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: string[] = []; + for (const v of value) { + if (typeof v !== 'string') continue; + const cleaned = normalizeLogin(v); + if (cleaned && !seen.has(cleaned)) { + seen.add(cleaned); + out.push(cleaned); + } + } + return out; +} + // Whitelist of streamlink stream specifiers we surface in Settings. The // user's choice is passed to streamlink with "best" appended as a fallback // (streamlink supports comma-separated stream lists, picks the first match) @@ -396,7 +423,9 @@ function normalizeConfigTemplates(input: Config): Config { notify_on_each_completion: input.notify_on_each_completion === true, // Default-true on first launch (most users hit this), but respect // an explicit `false` from the loaded 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_poll_seconds: normalizeAutoRecordPollSeconds(input.auto_record_poll_seconds) }; } @@ -2841,6 +2870,102 @@ function downloadVODPart( }); } +// ========================================== +// AUTO-RECORD POLLER +// ========================================== +// Tracks the last-known live state of every streamer in +// config.auto_record_streamers. When a streamer transitions from +// offline -> live AND no live recording is already in flight for them, +// we auto-queue a live recording. Polling stops when no streamer has +// auto-record enabled. +const autoRecordLastLiveState = new Map(); +let autoRecordPollTimer: NodeJS.Timeout | null = null; +let autoRecordPollInFlight = false; + +function stopAutoRecordPoller(): void { + if (autoRecordPollTimer) { + clearInterval(autoRecordPollTimer); + autoRecordPollTimer = null; + } +} + +function restartAutoRecordPoller(): void { + stopAutoRecordPoller(); + const list = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : []; + if (list.length === 0) { + appendDebugLog('auto-record-poller-idle', { reason: 'no streamers' }); + return; + } + const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds); + appendDebugLog('auto-record-poller-start', { streamers: list.length, seconds }); + autoRecordPollTimer = setInterval(() => { void runAutoRecordPoll(); }, seconds * 1000); + autoRecordPollTimer.unref?.(); + // Kick off an immediate first poll so a freshly-enabled streamer that's + // already live gets picked up without waiting a full interval. + setTimeout(() => { void runAutoRecordPoll(); }, 1500); +} + +async function runAutoRecordPoll(): Promise { + if (autoRecordPollInFlight) return; + autoRecordPollInFlight = true; + try { + const list = Array.isArray(config.auto_record_streamers) ? [...config.auto_record_streamers] : []; + for (const streamer of list) { + // Check if list still contains streamer (config may have changed + // mid-iteration via save-config from the renderer). + if (!config.auto_record_streamers.includes(streamer)) continue; + + const info = await getLiveStreamInfo(streamer); + if (info === null) { + // Couldn't determine live state — skip this streamer this + // round. Don't update lastLiveState so a subsequent successful + // poll can still detect an offline->live transition cleanly. + continue; + } + + const wasLive = autoRecordLastLiveState.get(streamer) === true; + autoRecordLastLiveState.set(streamer, info.isLive); + + if (!info.isLive || wasLive) continue; + + // offline -> live transition. Don't double-record if a live item + // already exists in the queue (e.g. user manually triggered it). + const alreadyRecording = downloadQueue.some((it) => + it.isLive && it.streamer === streamer + && (it.status === 'pending' || it.status === 'downloading') + ); + if (alreadyRecording) { + appendDebugLog('auto-record-skip-already', { streamer }); + continue; + } + + const liveItem: QueueItem = { + id: generateQueueItemId(), + title: info.title || `${streamer} (LIVE)`, + url: `https://www.twitch.tv/${streamer}`, + date: new Date().toISOString(), + streamer, + duration_str: '0s', + status: 'pending', + progress: 0, + isLive: true + }; + downloadQueue.push(liveItem); + saveQueue(downloadQueue); + emitQueueUpdated(); + appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title }); + + if (!isDownloading) { + void processQueue(); + } + } + } catch (e) { + appendDebugLog('auto-record-poll-failed', String(e)); + } finally { + autoRecordPollInFlight = false; + } +} + async function downloadLiveStream( item: QueueItem, onProgress: (progress: DownloadProgress) => void @@ -3983,6 +4108,8 @@ ipcMain.handle('save-config', (_, newConfig: Partial) => { const previousCacheMinutes = config.metadata_cache_minutes; const previousPersistQueueOnRestart = config.persist_queue_on_restart; const previousTheme = config.theme; + const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []); + const previousAutoRecordSeconds = config.auto_record_poll_seconds; config = normalizeConfigTemplates({ ...config, ...newConfig }); @@ -4012,6 +4139,19 @@ ipcMain.handle('save-config', (_, newConfig: Partial) => { saveQueue(downloadQueue, true); } + // Restart auto-record poller if its inputs changed (added/removed + // streamers or interval changed). Drop transition state for any + // streamer no longer being watched so re-enabling them later doesn't + // suppress an immediate first-poll trigger. + const newAutoRecordList = JSON.stringify(config.auto_record_streamers || []); + if (newAutoRecordList !== previousAutoRecordList || config.auto_record_poll_seconds !== previousAutoRecordSeconds) { + const watched = new Set(config.auto_record_streamers || []); + for (const k of Array.from(autoRecordLastLiveState.keys())) { + if (!watched.has(k)) autoRecordLastLiveState.delete(k); + } + restartAutoRecordPoller(); + } + return config; }); @@ -4710,6 +4850,7 @@ app.whenReady().then(() => { refreshBundledToolPaths(true); startMetadataCacheCleanup(); startDebugLogFlushTimer(); + restartAutoRecordPoller(); createWindow(); appendDebugLog('startup-tools-check-skipped', 'Deferred to first use'); @@ -4736,6 +4877,7 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void { stopMetadataCacheCleanup(); cleanupMetadataCaches('shutdown'); stopAutoUpdatePolling(); + stopAutoRecordPoller(); // Kill all active children: queue downloads, standalone clip downloads, // and any in-flight cutter/merger/splitter ffmpeg. before-quit used to diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index f789c60..0c1bfdc 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -21,6 +21,8 @@ interface AppConfig { streamlink_quality?: string; notify_on_each_completion?: boolean; streamlink_disable_ads?: boolean; + auto_record_streamers?: string[]; + auto_record_poll_seconds?: number; [key: string]: unknown; } diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index 42e7596..f8697b7 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -205,7 +205,10 @@ const UI_TEXT_DE = { liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.', liveRecordingOffline: '{streamer} ist gerade offline.', liveRecordingAlreadyActive: 'Aufnahme von {streamer} laeuft bereits.', - liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden' + liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden', + autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf', + autoRecordEnabled: 'Auto-Aufnahme aktiviert fuer {streamer}. Live-Status wird geprueft...', + autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.' }, vods: { noneTitle: 'Keine VODs', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 512d2f2..2d46e16 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -205,7 +205,10 @@ const UI_TEXT_EN = { liveRecordingStarted: 'Live recording started for {streamer}.', liveRecordingOffline: '{streamer} is offline right now.', liveRecordingAlreadyActive: 'Already recording {streamer}.', - liveRecordingFailed: 'Could not start live recording' + liveRecordingFailed: 'Could not start live recording', + autoRecordTitle: 'Auto-record: when this streamer goes live the app records automatically', + autoRecordEnabled: 'Auto-record enabled for {streamer}. Polling for live state...', + autoRecordDisabled: 'Auto-record disabled for {streamer}.' }, vods: { noneTitle: 'No VODs', diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index 54e5239..d9a8f97 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -406,6 +406,21 @@ function renderStreamers(): void { const nameSpan = document.createElement('span'); nameSpan.textContent = streamer; + + // AUTO toggle — when enabled, the main-process auto-record poller + // watches this channel for offline->live transitions and queues a + // live recording automatically. Off by default, click to toggle. + const autoList = (config.auto_record_streamers as string[] | undefined) || []; + const isAutoOn = autoList.includes(streamer); + const autoBtn = document.createElement('span'); + autoBtn.className = 'streamer-auto' + (isAutoOn ? ' active' : ''); + autoBtn.textContent = 'AUTO'; + autoBtn.title = UI_TEXT.streamers?.autoRecordTitle || 'Auto-record when this streamer goes live'; + autoBtn.addEventListener('click', (e) => { + e.stopPropagation(); + void toggleAutoRecord(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'); @@ -423,7 +438,7 @@ function renderStreamers(): void { e.stopPropagation(); void removeStreamer(streamer); }); - item.append(nameSpan, recBtn, removeSpan); + item.append(nameSpan, autoBtn, recBtn, removeSpan); item.addEventListener('click', () => { // Skip click if drag was just released — drop fires after dragend @@ -841,6 +856,25 @@ function clearVodSelection(): void { if (lastLoadedStreamer) renderVodGridFromCurrentState(); } +async function toggleAutoRecord(streamer: string): Promise { + const current = ((config.auto_record_streamers as string[]) || []).slice(); + const idx = current.indexOf(streamer); + if (idx >= 0) { + current.splice(idx, 1); + } else { + current.push(streamer); + } + config = await window.api.saveConfig({ auto_record_streamers: current }); + renderStreamers(); + + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) { + const wasAdded = idx < 0; + const tmpl = wasAdded ? UI_TEXT.streamers.autoRecordEnabled : UI_TEXT.streamers.autoRecordDisabled; + toast(tmpl.replace('{streamer}', streamer), 'info'); + } +} + 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); diff --git a/src/styles.css b/src/styles.css index ae98933..85036b6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -626,7 +626,6 @@ body { } .streamer-rec { - margin-left: auto; margin-right: 6px; color: #ff4444; font-size: 10px; @@ -644,6 +643,32 @@ body { background: rgba(255, 68, 68, 0.15); } +.streamer-auto { + margin-left: auto; + margin-right: 4px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + cursor: pointer; + padding: 2px 5px; + border: 1px solid var(--border-soft); + border-radius: 3px; + background: transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.streamer-auto.active { + color: #00c853; + border-color: rgba(0, 200, 83, 0.45); + background: rgba(0, 200, 83, 0.10); +} + +.streamer-auto:hover { + background: rgba(0, 200, 83, 0.18); + color: #00c853; +} + .queue-live-badge { display: inline-block; background: #ff4444;