feat: manual scan-now buttons + automation status line
Pairs with 4.6.10 (auto-VOD) and 4.6.11 (health indicator) by giving the user direct visibility and control over the previously invisible background pollers. Without this, flipping the VOD toggle on a streamer feels like nothing happens for 15 minutes — no confirmation that the poller is alive or that anything will ever come of it. Both run* functions now return the count they handled. Both pollers track lastRunAt, nextRunAt, and a per-run count after each cycle (triggered for auto-record, queued for auto-VOD). Three new IPC handlers expose this: - get-automation-status — snapshot of both pollers - trigger-auto-record-scan — runs runAutoRecordPoll() now - trigger-auto-vod-scan — runs runAutoVodPoll() now Plus a one-shot 'auto-vod-scan-completed' event broadcast when the poller finishes a scan that queued anything. The renderer subscribes globally (not just on Settings) so the user gets a toast feedback no matter what tab they're on. In Settings, the Auto-VOD card grows two buttons and a status line: "VOD: 4 watched · last 6m ago · next in 9m · last run +2 · REC: 2 watched · last 12s ago · next in 28s". Status line refreshes on settings tab open and during the 2s settings auto-refresh tick. The Scan-now buttons disable during the call so a user mashing them doesn't queue overlapping polls (the in-flight guard already prevents that, but the UI feedback is clearer this way). Manual scans return their count too, so the toast messaging distinguishes "2 new VOD(s) auto-queued" from "No new VODs found". Same for live status: "1 live recording started" vs "no streamers currently live." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
398206e01c
commit
28692b2e54
@ -702,6 +702,11 @@
|
||||
<span id="autoVodMaxAgeHoursLabel" style="font-size:12px; color:var(--text-secondary); margin-left:12px;">Max. Alter (Stunden)</span>
|
||||
<input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" style="width:90px;">
|
||||
</div>
|
||||
<div class="form-row" style="align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<button class="btn-secondary" id="btnAutoVodScanNow" onclick="triggerManualAutoVodScan()">Jetzt scannen</button>
|
||||
<button class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status pruefen</button>
|
||||
<span id="autoVodStatusLine" style="font-size:12px; color: var(--text-secondary);"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
|
||||
66
src/main.ts
66
src/main.ts
@ -2946,6 +2946,9 @@ function downloadVODPart(
|
||||
const autoRecordLastLiveState = new Map<string, boolean>();
|
||||
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<void> {
|
||||
if (autoRecordPollInFlight) return;
|
||||
async function runAutoRecordPoll(): Promise<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
if (autoVodPollInFlight) return;
|
||||
async function runAutoVodPoll(): Promise<number> {
|
||||
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<void> {
|
||||
};
|
||||
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<void> {
|
||||
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<Config>) => {
|
||||
const previousClientId = config.client_id;
|
||||
const previousClientSecret = config.client_secret;
|
||||
|
||||
@ -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<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||
|
||||
7
src/renderer-globals.d.ts
vendored
7
src/renderer-globals.d.ts
vendored
@ -264,6 +264,13 @@ interface ApiBridge {
|
||||
getStorageStats(): Promise<StorageStatsResult>;
|
||||
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
|
||||
readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; 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<VideoInfo | null>;
|
||||
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
||||
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -124,6 +124,13 @@ async function init(): Promise<void> {
|
||||
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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user