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;
|
streamlink_quality: string;
|
||||||
notify_on_each_completion: boolean;
|
notify_on_each_completion: boolean;
|
||||||
streamlink_disable_ads: boolean;
|
streamlink_disable_ads: boolean;
|
||||||
|
auto_record_streamers: string[];
|
||||||
|
auto_record_poll_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeMetrics {
|
interface RuntimeMetrics {
|
||||||
@ -324,9 +326,34 @@ const defaultConfig: Config = {
|
|||||||
downloaded_vod_ids: [],
|
downloaded_vod_ids: [],
|
||||||
streamlink_quality: 'best',
|
streamlink_quality: 'best',
|
||||||
notify_on_each_completion: false,
|
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
|
// Whitelist of streamlink stream specifiers we surface in Settings. The
|
||||||
// user's choice is passed to streamlink with "best" appended as a fallback
|
// user's choice is passed to streamlink with "best" appended as a fallback
|
||||||
// (streamlink supports comma-separated stream lists, picks the first match)
|
// (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,
|
notify_on_each_completion: input.notify_on_each_completion === true,
|
||||||
// Default-true on first launch (most users hit this), but respect
|
// Default-true on first launch (most users hit this), but respect
|
||||||
// an explicit `false` from the loaded config.
|
// 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(
|
async function downloadLiveStream(
|
||||||
item: QueueItem,
|
item: QueueItem,
|
||||||
onProgress: (progress: DownloadProgress) => void
|
onProgress: (progress: DownloadProgress) => void
|
||||||
@ -3983,6 +4108,8 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
|||||||
const previousCacheMinutes = config.metadata_cache_minutes;
|
const previousCacheMinutes = config.metadata_cache_minutes;
|
||||||
const previousPersistQueueOnRestart = config.persist_queue_on_restart;
|
const previousPersistQueueOnRestart = config.persist_queue_on_restart;
|
||||||
const previousTheme = config.theme;
|
const previousTheme = config.theme;
|
||||||
|
const previousAutoRecordList = JSON.stringify(config.auto_record_streamers || []);
|
||||||
|
const previousAutoRecordSeconds = config.auto_record_poll_seconds;
|
||||||
|
|
||||||
config = normalizeConfigTemplates({ ...config, ...newConfig });
|
config = normalizeConfigTemplates({ ...config, ...newConfig });
|
||||||
|
|
||||||
@ -4012,6 +4139,19 @@ ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
|
|||||||
saveQueue(downloadQueue, true);
|
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;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -4710,6 +4850,7 @@ app.whenReady().then(() => {
|
|||||||
refreshBundledToolPaths(true);
|
refreshBundledToolPaths(true);
|
||||||
startMetadataCacheCleanup();
|
startMetadataCacheCleanup();
|
||||||
startDebugLogFlushTimer();
|
startDebugLogFlushTimer();
|
||||||
|
restartAutoRecordPoller();
|
||||||
createWindow();
|
createWindow();
|
||||||
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
appendDebugLog('startup-tools-check-skipped', 'Deferred to first use');
|
||||||
|
|
||||||
@ -4736,6 +4877,7 @@ function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
|
|||||||
stopMetadataCacheCleanup();
|
stopMetadataCacheCleanup();
|
||||||
cleanupMetadataCaches('shutdown');
|
cleanupMetadataCaches('shutdown');
|
||||||
stopAutoUpdatePolling();
|
stopAutoUpdatePolling();
|
||||||
|
stopAutoRecordPoller();
|
||||||
|
|
||||||
// Kill all active children: queue downloads, standalone clip downloads,
|
// Kill all active children: queue downloads, standalone clip downloads,
|
||||||
// and any in-flight cutter/merger/splitter ffmpeg. before-quit used to
|
// 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;
|
streamlink_quality?: string;
|
||||||
notify_on_each_completion?: boolean;
|
notify_on_each_completion?: boolean;
|
||||||
streamlink_disable_ads?: boolean;
|
streamlink_disable_ads?: boolean;
|
||||||
|
auto_record_streamers?: string[];
|
||||||
|
auto_record_poll_seconds?: number;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -205,7 +205,10 @@ const UI_TEXT_DE = {
|
|||||||
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.',
|
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.',
|
||||||
liveRecordingOffline: '{streamer} ist gerade offline.',
|
liveRecordingOffline: '{streamer} ist gerade offline.',
|
||||||
liveRecordingAlreadyActive: 'Aufnahme von {streamer} laeuft bereits.',
|
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: {
|
vods: {
|
||||||
noneTitle: 'Keine VODs',
|
noneTitle: 'Keine VODs',
|
||||||
|
|||||||
@ -205,7 +205,10 @@ const UI_TEXT_EN = {
|
|||||||
liveRecordingStarted: 'Live recording started for {streamer}.',
|
liveRecordingStarted: 'Live recording started for {streamer}.',
|
||||||
liveRecordingOffline: '{streamer} is offline right now.',
|
liveRecordingOffline: '{streamer} is offline right now.',
|
||||||
liveRecordingAlreadyActive: 'Already recording {streamer}.',
|
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: {
|
vods: {
|
||||||
noneTitle: 'No VODs',
|
noneTitle: 'No VODs',
|
||||||
|
|||||||
@ -406,6 +406,21 @@ function renderStreamers(): void {
|
|||||||
|
|
||||||
const nameSpan = document.createElement('span');
|
const nameSpan = document.createElement('span');
|
||||||
nameSpan.textContent = streamer;
|
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
|
// Live-record button — small red dot, only triggers a live capture
|
||||||
// when the streamer is currently online (server checks via Helix).
|
// when the streamer is currently online (server checks via Helix).
|
||||||
const recBtn = document.createElement('span');
|
const recBtn = document.createElement('span');
|
||||||
@ -423,7 +438,7 @@ function renderStreamers(): void {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void removeStreamer(streamer);
|
void removeStreamer(streamer);
|
||||||
});
|
});
|
||||||
item.append(nameSpan, recBtn, removeSpan);
|
item.append(nameSpan, autoBtn, recBtn, removeSpan);
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
// Skip click if drag was just released — drop fires after dragend
|
// Skip click if drag was just released — drop fires after dragend
|
||||||
@ -841,6 +856,25 @@ function clearVodSelection(): void {
|
|||||||
if (lastLoadedStreamer) renderVodGridFromCurrentState();
|
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> {
|
async function triggerLiveRecording(streamer: string): Promise<void> {
|
||||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||||
const result = await window.api.startLiveRecording(streamer);
|
const result = await window.api.startLiveRecording(streamer);
|
||||||
|
|||||||
@ -626,7 +626,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.streamer-rec {
|
.streamer-rec {
|
||||||
margin-left: auto;
|
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
color: #ff4444;
|
color: #ff4444;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@ -644,6 +643,32 @@ body {
|
|||||||
background: rgba(255, 68, 68, 0.15);
|
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 {
|
.queue-live-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #ff4444;
|
background: #ff4444;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user