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>
|
<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">
|
||||||
|
|||||||
66
src/main.ts
66
src/main.ts
@ -2946,6 +2946,9 @@ 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) {
|
||||||
@ -2965,14 +2968,16 @@ 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<void> {
|
async function runAutoRecordPoll(): Promise<number> {
|
||||||
if (autoRecordPollInFlight) return;
|
if (autoRecordPollInFlight) return 0;
|
||||||
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) {
|
||||||
@ -3018,6 +3023,7 @@ async function runAutoRecordPoll(): Promise<void> {
|
|||||||
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) {
|
||||||
@ -3028,7 +3034,12 @@ async function runAutoRecordPoll(): Promise<void> {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -3042,6 +3053,9 @@ async function runAutoRecordPoll(): Promise<void> {
|
|||||||
// 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) {
|
||||||
@ -3065,15 +3079,17 @@ 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<void> {
|
async function runAutoVodPoll(): Promise<number> {
|
||||||
if (autoVodPollInFlight) return;
|
if (autoVodPollInFlight) return 0;
|
||||||
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;
|
if (list.length === 0) return 0;
|
||||||
|
|
||||||
const maxAgeHours = (() => {
|
const maxAgeHours = (() => {
|
||||||
const n = Number(config.auto_vod_max_age_hours);
|
const n = Number(config.auto_vod_max_age_hours);
|
||||||
@ -3123,6 +3139,7 @@ async function runAutoVodPoll(): Promise<void> {
|
|||||||
};
|
};
|
||||||
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) {
|
||||||
@ -3152,8 +3169,20 @@ async function runAutoVodPoll(): Promise<void> {
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// CHAT REPLAY DOWNLOAD
|
// CHAT REPLAY DOWNLOAD
|
||||||
@ -5327,6 +5356,33 @@ 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;
|
||||||
|
|||||||
@ -92,6 +92,12 @@ 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),
|
||||||
|
|||||||
7
src/renderer-globals.d.ts
vendored
7
src/renderer-globals.d.ts
vendored
@ -264,6 +264,13 @@ 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 }>;
|
||||||
|
|||||||
@ -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.',
|
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.',
|
||||||
@ -274,7 +276,11 @@ 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',
|
||||||
|
|||||||
@ -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.',
|
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',
|
||||||
@ -274,7 +276,11 @@ 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',
|
||||||
|
|||||||
@ -162,6 +162,7 @@ function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void refreshRuntimeMetrics(false);
|
void refreshRuntimeMetrics(false);
|
||||||
|
void refreshAutomationStatusLine();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -199,6 +200,7 @@ function changeLanguage(lang: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void refreshRuntimeMetrics();
|
void refreshRuntimeMetrics();
|
||||||
|
void refreshAutomationStatusLine();
|
||||||
validateFilenameTemplates();
|
validateFilenameTemplates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -898,3 +900,79 @@ 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;
|
||||||
|
|||||||
@ -202,6 +202,8 @@ 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);
|
||||||
|
|||||||
@ -124,6 +124,13 @@ 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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user