diff --git a/src/index.html b/src/index.html
index 2250a5a..6f6d9e0 100644
--- a/src/index.html
+++ b/src/index.html
@@ -702,6 +702,11 @@
Max. Alter (Stunden)
+
+
+
+
+
diff --git a/src/main.ts b/src/main.ts
index bece7cc..7338e83 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2946,6 +2946,9 @@ function downloadVODPart(
const autoRecordLastLiveState = new Map();
let autoRecordPollTimer: NodeJS.Timeout | null = null;
let autoRecordPollInFlight = false;
+let autoRecordLastRunAt = 0;
+let autoRecordNextRunAt = 0;
+let autoRecordLastTriggerCount = 0;
function stopAutoRecordPoller(): void {
if (autoRecordPollTimer) {
@@ -2965,14 +2968,16 @@ function restartAutoRecordPoller(): void {
appendDebugLog('auto-record-poller-start', { streamers: list.length, seconds });
autoRecordPollTimer = setInterval(() => { void runAutoRecordPoll(); }, seconds * 1000);
autoRecordPollTimer.unref?.();
+ autoRecordNextRunAt = Date.now() + seconds * 1000;
// 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;
+async function runAutoRecordPoll(): Promise {
+ if (autoRecordPollInFlight) return 0;
autoRecordPollInFlight = true;
+ let triggered = 0;
try {
const list = Array.isArray(config.auto_record_streamers) ? [...config.auto_record_streamers] : [];
for (const streamer of list) {
@@ -3018,6 +3023,7 @@ async function runAutoRecordPoll(): Promise {
downloadQueue.push(liveItem);
saveQueue(downloadQueue);
emitQueueUpdated();
+ triggered++;
appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title });
if (!isDownloading) {
@@ -3028,7 +3034,12 @@ async function runAutoRecordPoll(): Promise {
appendDebugLog('auto-record-poll-failed', String(e));
} finally {
autoRecordPollInFlight = false;
+ autoRecordLastRunAt = Date.now();
+ autoRecordLastTriggerCount = triggered;
+ const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds);
+ autoRecordNextRunAt = Date.now() + seconds * 1000;
}
+ return triggered;
}
// ==========================================
@@ -3042,6 +3053,9 @@ async function runAutoRecordPoll(): Promise {
// minute-level lag is fine.
let autoVodPollTimer: NodeJS.Timeout | null = null;
let autoVodPollInFlight = false;
+let autoVodLastRunAt = 0;
+let autoVodNextRunAt = 0;
+let autoVodLastQueuedCount = 0;
function stopAutoVodPoller(): void {
if (autoVodPollTimer) {
@@ -3065,15 +3079,17 @@ function restartAutoVodPoller(): void {
appendDebugLog('auto-vod-poller-start', { streamers: list.length, minutes });
autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000);
autoVodPollTimer.unref?.();
+ autoVodNextRunAt = Date.now() + minutes * 60 * 1000;
setTimeout(() => { void runAutoVodPoll(); }, 5000);
}
-async function runAutoVodPoll(): Promise {
- if (autoVodPollInFlight) return;
+async function runAutoVodPoll(): Promise {
+ if (autoVodPollInFlight) return 0;
autoVodPollInFlight = true;
+ let queuedCount = 0;
try {
const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : [];
- if (list.length === 0) return;
+ if (list.length === 0) return 0;
const maxAgeHours = (() => {
const n = Number(config.auto_vod_max_age_hours);
@@ -3123,6 +3139,7 @@ async function runAutoVodPoll(): Promise {
};
downloadQueue.push(queueItem);
queuedUrls.add(vod.url);
+ queuedCount++;
appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title });
if (config.discord_notify_vod_auto_queued) {
@@ -3152,7 +3169,19 @@ async function runAutoVodPoll(): Promise {
appendDebugLog('auto-vod-poll-failed', String(e));
} finally {
autoVodPollInFlight = false;
+ autoVodLastRunAt = Date.now();
+ autoVodLastQueuedCount = queuedCount;
+ const minutes = (() => {
+ const n = Number(config.auto_vod_download_poll_minutes);
+ if (!Number.isFinite(n)) return 15;
+ return Math.max(5, Math.min(360, Math.floor(n)));
+ })();
+ autoVodNextRunAt = Date.now() + minutes * 60 * 1000;
+ if (queuedCount > 0 && mainWindow) {
+ mainWindow.webContents.send('auto-vod-scan-completed', { queuedCount });
+ }
}
+ return queuedCount;
}
// ==========================================
@@ -5327,6 +5356,33 @@ function setupAutoUpdater() {
// ==========================================
ipcMain.handle('get-config', () => config);
+ipcMain.handle('get-automation-status', () => ({
+ autoRecord: {
+ watching: Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers.length : 0,
+ lastRunAt: autoRecordLastRunAt,
+ nextRunAt: autoRecordNextRunAt,
+ lastTriggeredCount: autoRecordLastTriggerCount,
+ inFlight: autoRecordPollInFlight
+ },
+ autoVod: {
+ watching: Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers.length : 0,
+ lastRunAt: autoVodLastRunAt,
+ nextRunAt: autoVodNextRunAt,
+ lastQueuedCount: autoVodLastQueuedCount,
+ inFlight: autoVodPollInFlight
+ }
+}));
+
+ipcMain.handle('trigger-auto-record-scan', async () => {
+ const triggered = await runAutoRecordPoll();
+ return { triggered };
+});
+
+ipcMain.handle('trigger-auto-vod-scan', async () => {
+ const queuedCount = await runAutoVodPoll();
+ return { queuedCount };
+});
+
ipcMain.handle('save-config', (_, newConfig: Partial) => {
const previousClientId = config.client_id;
const previousClientSecret = config.client_secret;
diff --git a/src/preload.ts b/src/preload.ts
index bc43bb7..815dc58 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -92,6 +92,12 @@ contextBridge.exposeInMainWorld('api', {
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
+ getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'),
+ triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'),
+ triggerAutoRecordScan: () => ipcRenderer.invoke('trigger-auto-record-scan'),
+ onAutoVodScanCompleted: (callback: (info: { queuedCount: number }) => void) => {
+ ipcRenderer.on('auto-vod-scan-completed', (_, info) => callback(info));
+ },
// Video Cutter
getVideoInfo: (filePath: string): Promise => ipcRenderer.invoke('get-video-info', filePath),
diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts
index 1ae7065..2a14c3c 100644
--- a/src/renderer-globals.d.ts
+++ b/src/renderer-globals.d.ts
@@ -264,6 +264,13 @@ interface ApiBridge {
getStorageStats(): Promise;
runStorageCleanup(options?: { dryRun?: boolean }): Promise;
readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array>; truncated?: boolean; total?: number }>;
+ getAutomationStatus(): Promise<{
+ autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean };
+ autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean };
+ }>;
+ triggerAutoVodScan(): Promise<{ queuedCount: number }>;
+ triggerAutoRecordScan(): Promise<{ triggered: number }>;
+ onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void;
getVideoInfo(filePath: string): Promise;
extractFrame(filePath: string, timeSeconds: number): Promise;
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts
index fd1af84..522a292 100644
--- a/src/renderer-locale-de.ts
+++ b/src/renderer-locale-de.ts
@@ -93,6 +93,8 @@ const UI_TEXT_DE = {
autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.',
autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)',
autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)',
+ autoVodScanNow: 'Jetzt scannen',
+ autoRecordScanNow: 'Live-Status pruefen',
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
backupCardTitle: 'Sicherung & Wartung',
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
@@ -274,7 +276,11 @@ const UI_TEXT_DE = {
autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.',
autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen',
autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.',
- autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.'
+ autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.',
+ autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.',
+ autoVodScanEmpty: 'Keine neuen VODs gefunden.',
+ autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.',
+ autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.'
},
vods: {
noneTitle: 'Keine VODs',
diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts
index 5b4ba40..dfaf82e 100644
--- a/src/renderer-locale-en.ts
+++ b/src/renderer-locale-en.ts
@@ -94,6 +94,8 @@ const UI_TEXT_EN = {
autoVodCardIntro: 'Streamers with the VOD toggle on are scanned for new Twitch VODs at the interval set here. New VODs within the age window are added to the download queue automatically.',
autoVodPollMinutesLabel: 'Poll interval (minutes)',
autoVodMaxAgeHoursLabel: 'Max age (hours)',
+ autoVodScanNow: 'Scan now',
+ autoRecordScanNow: 'Check live status',
backupCardTitle: 'Backup & Maintenance',
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
exportConfig: 'Export config',
@@ -274,7 +276,11 @@ const UI_TEXT_EN = {
autoRecordDisabled: 'Auto-record disabled for {streamer}.',
autoVodTitle: 'Auto-download new VODs (recently published) for this streamer',
autoVodEnabled: 'Auto-VOD enabled for {streamer}. Will pick up new VODs.',
- autoVodDisabled: 'Auto-VOD disabled for {streamer}.'
+ autoVodDisabled: 'Auto-VOD disabled for {streamer}.',
+ autoVodScanQueued: '{count} new VOD(s) auto-queued.',
+ autoVodScanEmpty: 'No new VODs found.',
+ autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.',
+ autoRecordScanEmpty: 'Manual scan: no streamers currently live.'
},
vods: {
noneTitle: 'No VODs',
diff --git a/src/renderer-settings.ts b/src/renderer-settings.ts
index a500a96..0dd8354 100644
--- a/src/renderer-settings.ts
+++ b/src/renderer-settings.ts
@@ -162,6 +162,7 @@ function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
}
void refreshRuntimeMetrics(false);
+ void refreshAutomationStatusLine();
}, 2000);
}
}
@@ -199,6 +200,7 @@ function changeLanguage(lang: string): void {
}
void refreshRuntimeMetrics();
+ void refreshAutomationStatusLine();
validateFilenameTemplates();
}
@@ -898,3 +900,79 @@ function changeTheme(theme: string): void {
config.theme = theme;
void window.api.saveConfig({ theme });
}
+
+function formatRelativeTime(ms: number, future: boolean): string {
+ if (!Number.isFinite(ms) || ms <= 0) {
+ return future ? UI_TEXT.streamers.autoVodScanEmpty || '' : '-';
+ }
+ const seconds = Math.max(0, Math.floor(ms / 1000));
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ return `${hours}h ${minutes % 60}m`;
+}
+
+async function refreshAutomationStatusLine(): Promise {
+ const lineEl = document.getElementById('autoVodStatusLine');
+ if (!lineEl) return;
+ try {
+ const status = await window.api.getAutomationStatus();
+ const now = Date.now();
+ const parts: string[] = [];
+
+ if (status.autoVod.watching > 0) {
+ const lastAgo = status.autoVod.lastRunAt > 0 ? formatRelativeTime(now - status.autoVod.lastRunAt, false) : '-';
+ const nextIn = status.autoVod.nextRunAt > now ? formatRelativeTime(status.autoVod.nextRunAt - now, true) : '-';
+ parts.push(`VOD: ${status.autoVod.watching} watched · last ${lastAgo} ago · next in ${nextIn} · last run +${status.autoVod.lastQueuedCount}`);
+ }
+ if (status.autoRecord.watching > 0) {
+ const lastAgo = status.autoRecord.lastRunAt > 0 ? formatRelativeTime(now - status.autoRecord.lastRunAt, false) : '-';
+ const nextIn = status.autoRecord.nextRunAt > now ? formatRelativeTime(status.autoRecord.nextRunAt - now, true) : '-';
+ parts.push(`REC: ${status.autoRecord.watching} watched · last ${lastAgo} ago · next in ${nextIn}`);
+ }
+ if (parts.length === 0) parts.push('No streamers watched.');
+ lineEl.textContent = parts.join(' · ');
+ } catch (_) {
+ lineEl.textContent = '';
+ }
+}
+
+async function triggerManualAutoVodScan(): Promise {
+ const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
+ const btn = document.getElementById('btnAutoVodScanNow') as HTMLButtonElement | null;
+ if (btn) btn.disabled = true;
+ try {
+ const result = await window.api.triggerAutoVodScan();
+ if (toast) {
+ const tmpl = result.queuedCount > 0
+ ? UI_TEXT.streamers.autoVodScanQueued
+ : UI_TEXT.streamers.autoVodScanEmpty;
+ toast((tmpl || '').replace('{count}', String(result.queuedCount)), 'info');
+ }
+ } finally {
+ if (btn) btn.disabled = false;
+ void refreshAutomationStatusLine();
+ }
+}
+
+async function triggerManualAutoRecordScan(): Promise {
+ const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
+ const btn = document.getElementById('btnAutoRecordScanNow') as HTMLButtonElement | null;
+ if (btn) btn.disabled = true;
+ try {
+ const result = await window.api.triggerAutoRecordScan();
+ if (toast) {
+ const tmpl = result.triggered > 0
+ ? UI_TEXT.streamers.autoRecordScanTriggered
+ : UI_TEXT.streamers.autoRecordScanEmpty;
+ toast((tmpl || '').replace('{count}', String(result.triggered)), 'info');
+ }
+ } finally {
+ if (btn) btn.disabled = false;
+ void refreshAutomationStatusLine();
+ }
+}
+
+(window as unknown as { triggerManualAutoVodScan: typeof triggerManualAutoVodScan }).triggerManualAutoVodScan = triggerManualAutoVodScan;
+(window as unknown as { triggerManualAutoRecordScan: typeof triggerManualAutoRecordScan }).triggerManualAutoRecordScan = triggerManualAutoRecordScan;
diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts
index 111070d..1772602 100644
--- a/src/renderer-texts.ts
+++ b/src/renderer-texts.ts
@@ -202,6 +202,8 @@ function applyLanguageToStaticUI(): void {
setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro);
setText('autoVodPollMinutesLabel', UI_TEXT.static.autoVodPollMinutesLabel);
setText('autoVodMaxAgeHoursLabel', UI_TEXT.static.autoVodMaxAgeHoursLabel);
+ setText('btnAutoVodScanNow', UI_TEXT.static.autoVodScanNow);
+ setText('btnAutoRecordScanNow', UI_TEXT.static.autoRecordScanNow);
setText('backupCardTitle', UI_TEXT.static.backupCardTitle);
setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
setText('btnExportConfig', UI_TEXT.static.exportConfig);
diff --git a/src/renderer.ts b/src/renderer.ts
index 050cecc..9340045 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -124,6 +124,13 @@ async function init(): Promise {
markQueueActivity();
});
+ window.api.onAutoVodScanCompleted(({ queuedCount }) => {
+ if (queuedCount > 0) {
+ const tmpl = UI_TEXT.streamers.autoVodScanQueued || '{count} new VOD(s) auto-queued.';
+ showAppToast(tmpl.replace('{count}', String(queuedCount)), 'info');
+ }
+ });
+
window.api.onDownloadStarted(() => {
downloading = true;
updateDownloadButtonState();