feat: auto-record polling — set-and-forget live archival
Building on the manual REC button from 4.6.0: each streamer now also has an AUTO toggle. When enabled, a background poller in the main process checks the streamers live status every 90s (configurable 30-1800s via config.auto_record_poll_seconds). On an offline -> live transition, a live recording is queued automatically without the user having to be at the keyboard. Server: - config.auto_record_streamers: string[] holds the watched logins (deduped + normalized via normalizeAutoRecordList). Empty list stops the poller entirely so users who don't use the feature pay zero CPU. - runAutoRecordPoll iterates the list, hits getLiveStreamInfo (existing helper from 4.6.0 — Helix when authed, public GQL otherwise), tracks per-streamer last-known live state in autoRecordLastLiveState, and only triggers on the offline->live edge. If a live item already exists for that streamer (manual REC click + auto-poll racing), the auto-trigger backs off. - restartAutoRecordPoller is wired into save-config so toggling AUTO on/off or changing the interval takes effect without a restart; state for de-watched streamers is dropped so re-enabling them later doesn't suppress an immediate first-poll trigger. - Wired into app.whenReady (start) and shutdownCleanup (stop). - Initial poll fires ~1.5s after restart so a streamer that's already live when the user enables AUTO gets picked up immediately instead of after a full interval. Renderer: - AUTO pill next to REC. Off = grey outline, on = green outline + green text + faint green background. Click toggles via saveConfig with the updated auto_record_streamers array; toast confirms. - Per-streamer state survives reload (it's in the config file). DE + EN locale strings for the toggle title + on/off toasts. Why this matters: VODs vanish from Twitch within 7-60 days. Manual REC requires the user to be present when the stream starts. AUTO closes that gap — the app watches in the background and captures without supervision. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1c5462b7fe
commit
029b2bd407
146
src/main.ts
146
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<string>();
|
||||
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<string, boolean>();
|
||||
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<void> {
|
||||
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<Config>) => {
|
||||
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<Config>) => {
|
||||
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
|
||||
|
||||
2
src/renderer-globals.d.ts
vendored
2
src/renderer-globals.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
const result = await window.api.startLiveRecording(streamer);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user