Compare commits

..

No commits in common. "805231ae2fa7f73e3cc3087a8722a51a0543eb18" and "398206e01c41aea8c28871a022a62b438fc3d2fa" have entirely different histories.

11 changed files with 10 additions and 183 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.6.12", "version": "4.6.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.6.12", "version": "4.6.11",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.6.12", "version": "4.6.11",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "xRangerDE", "author": "xRangerDE",

View File

@ -702,11 +702,6 @@
<span id="autoVodMaxAgeHoursLabel" style="font-size:12px; color:var(--text-secondary); margin-left:12px;">Max. Alter (Stunden)</span> <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;"> <input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" style="width:90px;">
</div> </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>
<div class="settings-card"> <div class="settings-card">

View File

@ -2946,9 +2946,6 @@ function downloadVODPart(
const autoRecordLastLiveState = new Map<string, boolean>(); const autoRecordLastLiveState = new Map<string, boolean>();
let autoRecordPollTimer: NodeJS.Timeout | null = null; let autoRecordPollTimer: NodeJS.Timeout | null = null;
let autoRecordPollInFlight = false; let autoRecordPollInFlight = false;
let autoRecordLastRunAt = 0;
let autoRecordNextRunAt = 0;
let autoRecordLastTriggerCount = 0;
function stopAutoRecordPoller(): void { function stopAutoRecordPoller(): void {
if (autoRecordPollTimer) { if (autoRecordPollTimer) {
@ -2968,16 +2965,14 @@ function restartAutoRecordPoller(): void {
appendDebugLog('auto-record-poller-start', { streamers: list.length, seconds }); appendDebugLog('auto-record-poller-start', { streamers: list.length, seconds });
autoRecordPollTimer = setInterval(() => { void runAutoRecordPoll(); }, seconds * 1000); autoRecordPollTimer = setInterval(() => { void runAutoRecordPoll(); }, seconds * 1000);
autoRecordPollTimer.unref?.(); autoRecordPollTimer.unref?.();
autoRecordNextRunAt = Date.now() + seconds * 1000;
// Kick off an immediate first poll so a freshly-enabled streamer that's // Kick off an immediate first poll so a freshly-enabled streamer that's
// already live gets picked up without waiting a full interval. // already live gets picked up without waiting a full interval.
setTimeout(() => { void runAutoRecordPoll(); }, 1500); setTimeout(() => { void runAutoRecordPoll(); }, 1500);
} }
async function runAutoRecordPoll(): Promise<number> { async function runAutoRecordPoll(): Promise<void> {
if (autoRecordPollInFlight) return 0; if (autoRecordPollInFlight) return;
autoRecordPollInFlight = true; autoRecordPollInFlight = true;
let triggered = 0;
try { try {
const list = Array.isArray(config.auto_record_streamers) ? [...config.auto_record_streamers] : []; const list = Array.isArray(config.auto_record_streamers) ? [...config.auto_record_streamers] : [];
for (const streamer of list) { for (const streamer of list) {
@ -3023,7 +3018,6 @@ async function runAutoRecordPoll(): Promise<number> {
downloadQueue.push(liveItem); downloadQueue.push(liveItem);
saveQueue(downloadQueue); saveQueue(downloadQueue);
emitQueueUpdated(); emitQueueUpdated();
triggered++;
appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title }); appendDebugLog('auto-record-triggered', { streamer, title: liveItem.title });
if (!isDownloading) { if (!isDownloading) {
@ -3034,12 +3028,7 @@ async function runAutoRecordPoll(): Promise<number> {
appendDebugLog('auto-record-poll-failed', String(e)); appendDebugLog('auto-record-poll-failed', String(e));
} finally { } finally {
autoRecordPollInFlight = false; autoRecordPollInFlight = false;
autoRecordLastRunAt = Date.now();
autoRecordLastTriggerCount = triggered;
const seconds = normalizeAutoRecordPollSeconds(config.auto_record_poll_seconds);
autoRecordNextRunAt = Date.now() + seconds * 1000;
} }
return triggered;
} }
// ========================================== // ==========================================
@ -3053,9 +3042,6 @@ async function runAutoRecordPoll(): Promise<number> {
// minute-level lag is fine. // minute-level lag is fine.
let autoVodPollTimer: NodeJS.Timeout | null = null; let autoVodPollTimer: NodeJS.Timeout | null = null;
let autoVodPollInFlight = false; let autoVodPollInFlight = false;
let autoVodLastRunAt = 0;
let autoVodNextRunAt = 0;
let autoVodLastQueuedCount = 0;
function stopAutoVodPoller(): void { function stopAutoVodPoller(): void {
if (autoVodPollTimer) { if (autoVodPollTimer) {
@ -3079,17 +3065,15 @@ function restartAutoVodPoller(): void {
appendDebugLog('auto-vod-poller-start', { streamers: list.length, minutes }); appendDebugLog('auto-vod-poller-start', { streamers: list.length, minutes });
autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000); autoVodPollTimer = setInterval(() => { void runAutoVodPoll(); }, minutes * 60 * 1000);
autoVodPollTimer.unref?.(); autoVodPollTimer.unref?.();
autoVodNextRunAt = Date.now() + minutes * 60 * 1000;
setTimeout(() => { void runAutoVodPoll(); }, 5000); setTimeout(() => { void runAutoVodPoll(); }, 5000);
} }
async function runAutoVodPoll(): Promise<number> { async function runAutoVodPoll(): Promise<void> {
if (autoVodPollInFlight) return 0; if (autoVodPollInFlight) return;
autoVodPollInFlight = true; autoVodPollInFlight = true;
let queuedCount = 0;
try { try {
const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : []; const list = Array.isArray(config.auto_vod_download_streamers) ? [...config.auto_vod_download_streamers] : [];
if (list.length === 0) return 0; if (list.length === 0) return;
const maxAgeHours = (() => { const maxAgeHours = (() => {
const n = Number(config.auto_vod_max_age_hours); const n = Number(config.auto_vod_max_age_hours);
@ -3139,7 +3123,6 @@ async function runAutoVodPoll(): Promise<number> {
}; };
downloadQueue.push(queueItem); downloadQueue.push(queueItem);
queuedUrls.add(vod.url); queuedUrls.add(vod.url);
queuedCount++;
appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title }); appendDebugLog('auto-vod-queued', { streamer, vodId: vod.id, title: queueItem.title });
if (config.discord_notify_vod_auto_queued) { if (config.discord_notify_vod_auto_queued) {
@ -3169,19 +3152,7 @@ async function runAutoVodPoll(): Promise<number> {
appendDebugLog('auto-vod-poll-failed', String(e)); appendDebugLog('auto-vod-poll-failed', String(e));
} finally { } finally {
autoVodPollInFlight = false; 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;
} }
// ========================================== // ==========================================
@ -5356,33 +5327,6 @@ function setupAutoUpdater() {
// ========================================== // ==========================================
ipcMain.handle('get-config', () => config); 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>) => { ipcMain.handle('save-config', (_, newConfig: Partial<Config>) => {
const previousClientId = config.client_id; const previousClientId = config.client_id;
const previousClientSecret = config.client_secret; const previousClientSecret = config.client_secret;

View File

@ -92,12 +92,6 @@ contextBridge.exposeInMainWorld('api', {
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'), getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options), runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath), 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 // Video Cutter
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath), getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),

View File

@ -264,13 +264,6 @@ interface ApiBridge {
getStorageStats(): Promise<StorageStatsResult>; getStorageStats(): Promise<StorageStatsResult>;
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>; 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 }>; 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>; getVideoInfo(filePath: string): Promise<VideoInfo | null>;
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>; extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;

View File

@ -93,8 +93,6 @@ 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.', 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)', autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)',
autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)', autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)',
autoVodScanNow: 'Jetzt scannen',
autoRecordScanNow: 'Live-Status pruefen',
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen', discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
backupCardTitle: 'Sicherung & Wartung', backupCardTitle: 'Sicherung & Wartung',
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.', backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
@ -276,11 +274,7 @@ const UI_TEXT_DE = {
autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.', autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.',
autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen', autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen',
autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.', 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: { vods: {
noneTitle: 'Keine VODs', noneTitle: 'Keine VODs',

View File

@ -94,8 +94,6 @@ 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.', 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)', autoVodPollMinutesLabel: 'Poll interval (minutes)',
autoVodMaxAgeHoursLabel: 'Max age (hours)', autoVodMaxAgeHoursLabel: 'Max age (hours)',
autoVodScanNow: 'Scan now',
autoRecordScanNow: 'Check live status',
backupCardTitle: 'Backup & Maintenance', backupCardTitle: 'Backup & Maintenance',
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.', backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
exportConfig: 'Export config', exportConfig: 'Export config',
@ -276,11 +274,7 @@ const UI_TEXT_EN = {
autoRecordDisabled: 'Auto-record disabled for {streamer}.', autoRecordDisabled: 'Auto-record disabled for {streamer}.',
autoVodTitle: 'Auto-download new VODs (recently published) for this streamer', autoVodTitle: 'Auto-download new VODs (recently published) for this streamer',
autoVodEnabled: 'Auto-VOD enabled for {streamer}. Will pick up new VODs.', 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: { vods: {
noneTitle: 'No VODs', noneTitle: 'No VODs',

View File

@ -162,7 +162,6 @@ function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
} }
void refreshRuntimeMetrics(false); void refreshRuntimeMetrics(false);
void refreshAutomationStatusLine();
}, 2000); }, 2000);
} }
} }
@ -200,7 +199,6 @@ function changeLanguage(lang: string): void {
} }
void refreshRuntimeMetrics(); void refreshRuntimeMetrics();
void refreshAutomationStatusLine();
validateFilenameTemplates(); validateFilenameTemplates();
} }
@ -900,79 +898,3 @@ function changeTheme(theme: string): void {
config.theme = theme; config.theme = theme;
void window.api.saveConfig({ 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;

View File

@ -202,8 +202,6 @@ function applyLanguageToStaticUI(): void {
setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro); setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro);
setText('autoVodPollMinutesLabel', UI_TEXT.static.autoVodPollMinutesLabel); setText('autoVodPollMinutesLabel', UI_TEXT.static.autoVodPollMinutesLabel);
setText('autoVodMaxAgeHoursLabel', UI_TEXT.static.autoVodMaxAgeHoursLabel); 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('backupCardTitle', UI_TEXT.static.backupCardTitle);
setText('backupCardIntro', UI_TEXT.static.backupCardIntro); setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
setText('btnExportConfig', UI_TEXT.static.exportConfig); setText('btnExportConfig', UI_TEXT.static.exportConfig);

View File

@ -124,13 +124,6 @@ async function init(): Promise<void> {
markQueueActivity(); 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(() => { window.api.onDownloadStarted(() => {
downloading = true; downloading = true;
updateDownloadButtonState(); updateDownloadButtonState();