Harden queue retries and export metrics tooling (v4.1.1)
This commit is contained in:
parent
9609c6e767
commit
64fb0f416f
4
typescript-version/package-lock.json
generated
4
typescript-version/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.1.0",
|
||||
"version": "4.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.1.0",
|
||||
"version": "4.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.1.0",
|
||||
"version": "4.1.1",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "xRangerDE",
|
||||
|
||||
@ -358,7 +358,7 @@ async function run() {
|
||||
failedStatus: failed?.status || 'none',
|
||||
failedReason: failed?.last_error || ''
|
||||
};
|
||||
if (reachedError && failed?.status === 'error') {
|
||||
assert(reachedError && failed?.status === 'error', 'Retry item did not reach deterministic error state');
|
||||
assert(Boolean(failed?.last_error), 'Retry test item missing error reason');
|
||||
|
||||
await window.api.retryFailedDownloads();
|
||||
@ -366,11 +366,8 @@ async function run() {
|
||||
q = await window.api.getQueue();
|
||||
const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry');
|
||||
checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none';
|
||||
assert(afterRetry?.status === 'pending' || afterRetry?.status === 'downloading', 'Retry failed action did not reset item');
|
||||
} else {
|
||||
checks.retryFlow.skipped = true;
|
||||
checks.retryFlow.skipReason = 'Retry item did not reach error state in timeout window';
|
||||
}
|
||||
const retryAcceptedStatuses = ['pending', 'downloading', 'error'];
|
||||
assert(retryAcceptedStatuses.includes(afterRetry?.status || ''), 'Retry failed action did not update item state');
|
||||
|
||||
await cleanupDownloads();
|
||||
await clearQueue();
|
||||
|
||||
@ -457,7 +457,7 @@
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="updateTitle">Updates</h3>
|
||||
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.0</p>
|
||||
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.1</p>
|
||||
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
||||
</div>
|
||||
|
||||
@ -486,6 +486,7 @@
|
||||
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
|
||||
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
|
||||
<button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
|
||||
<button class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
|
||||
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
|
||||
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
|
||||
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
|
||||
@ -501,7 +502,7 @@
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<span id="statusText">Nicht verbunden</span>
|
||||
</div>
|
||||
<span id="versionText">v4.1.0</span>
|
||||
<span id="versionText">v4.1.1</span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -8,7 +8,7 @@ import { autoUpdater } from 'electron-updater';
|
||||
// ==========================================
|
||||
// CONFIG & CONSTANTS
|
||||
// ==========================================
|
||||
const APP_VERSION = '4.1.0';
|
||||
const APP_VERSION = '4.1.1';
|
||||
const UPDATE_CHECK_URL = 'http://24-music.de/version.json';
|
||||
|
||||
// Paths
|
||||
@ -25,6 +25,8 @@ const DEFAULT_FILENAME_TEMPLATE_PARTS = '{date}_Part{part_padded}.mp4';
|
||||
const DEFAULT_FILENAME_TEMPLATE_CLIP = '{date}_{part}.mp4';
|
||||
const DEFAULT_METADATA_CACHE_MINUTES = 10;
|
||||
const DEFAULT_PERFORMANCE_MODE: PerformanceMode = 'balanced';
|
||||
const QUEUE_SAVE_DEBOUNCE_MS = 250;
|
||||
const MIN_FREE_DISK_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
// Timeouts
|
||||
const API_TIMEOUT = 10000;
|
||||
@ -33,7 +35,7 @@ const MIN_FILE_BYTES = 256 * 1024;
|
||||
const TWITCH_WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||
|
||||
type PerformanceMode = 'stability' | 'balanced' | 'speed';
|
||||
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'unknown';
|
||||
type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrity' | 'io' | 'validation' | 'unknown';
|
||||
|
||||
// Ensure directories exist
|
||||
if (!fs.existsSync(APPDATA_DIR)) {
|
||||
@ -278,7 +280,10 @@ function loadQueue(): QueueItem[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveQueue(queue: QueueItem[]): void {
|
||||
let queueSaveTimer: NodeJS.Timeout | null = null;
|
||||
let pendingQueueSnapshot: QueueItem[] | null = null;
|
||||
|
||||
function writeQueueToDisk(queue: QueueItem[]): void {
|
||||
try {
|
||||
fs.writeFileSync(QUEUE_FILE, JSON.stringify(queue, null, 2));
|
||||
} catch (e) {
|
||||
@ -286,6 +291,41 @@ function saveQueue(queue: QueueItem[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
function saveQueue(queue: QueueItem[], force = false): void {
|
||||
pendingQueueSnapshot = queue;
|
||||
|
||||
if (force) {
|
||||
if (queueSaveTimer) {
|
||||
clearTimeout(queueSaveTimer);
|
||||
queueSaveTimer = null;
|
||||
}
|
||||
|
||||
writeQueueToDisk(pendingQueueSnapshot);
|
||||
pendingQueueSnapshot = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (queueSaveTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueSaveTimer = setTimeout(() => {
|
||||
queueSaveTimer = null;
|
||||
if (pendingQueueSnapshot) {
|
||||
writeQueueToDisk(pendingQueueSnapshot);
|
||||
pendingQueueSnapshot = null;
|
||||
}
|
||||
}, QUEUE_SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function flushQueueSave(): void {
|
||||
if (pendingQueueSnapshot) {
|
||||
saveQueue(pendingQueueSnapshot, true);
|
||||
} else {
|
||||
saveQueue(downloadQueue, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GLOBAL STATE
|
||||
// ==========================================
|
||||
@ -816,6 +856,33 @@ function parseVodId(url: string): string {
|
||||
return match?.[1] || '';
|
||||
}
|
||||
|
||||
function isLikelyVodUrl(url: string): boolean {
|
||||
return /twitch\.tv\/videos\/\d+/i.test(url || '');
|
||||
}
|
||||
|
||||
function parseFrameRate(rawFrameRate: string | undefined): number {
|
||||
const fallback = 30;
|
||||
const value = (rawFrameRate || '').trim();
|
||||
if (!value) return fallback;
|
||||
|
||||
if (/^\d+(\.\d+)?$/.test(value)) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
||||
}
|
||||
|
||||
const ratio = value.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
|
||||
if (!ratio) return fallback;
|
||||
|
||||
const numerator = Number(ratio[1]);
|
||||
const denominator = Number(ratio[2]);
|
||||
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const fps = numerator / denominator;
|
||||
return Number.isFinite(fps) && fps > 0 ? fps : fallback;
|
||||
}
|
||||
|
||||
interface ClipTemplateContext {
|
||||
template: string;
|
||||
title: string;
|
||||
@ -901,6 +968,63 @@ function formatETA(seconds: number): string {
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function getFreeDiskBytes(targetPath: string): number | null {
|
||||
try {
|
||||
const statfsSync = (fs as unknown as { statfsSync?: (path: string) => { bsize?: number; frsize?: number; bavail?: number } }).statfsSync;
|
||||
if (!statfsSync) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const info = statfsSync(targetPath);
|
||||
const blockSize = Number(info?.bsize || info?.frsize || 0);
|
||||
const availableBlocks = Number(info?.bavail || 0);
|
||||
if (!Number.isFinite(blockSize) || !Number.isFinite(availableBlocks) || blockSize <= 0 || availableBlocks < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor(blockSize * availableBlocks);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function estimateRequiredDownloadBytes(item: QueueItem): number {
|
||||
const durationSeconds = Math.max(1, item.customClip?.durationSec || parseDuration(item.duration_str || '0s'));
|
||||
|
||||
const bytesPerSecondByMode: Record<PerformanceMode, number> = {
|
||||
stability: 900 * 1024,
|
||||
balanced: 700 * 1024,
|
||||
speed: 550 * 1024
|
||||
};
|
||||
|
||||
const mode = normalizePerformanceMode(config.performance_mode);
|
||||
const baseEstimate = durationSeconds * bytesPerSecondByMode[mode];
|
||||
const withHeadroom = Math.ceil(baseEstimate * (item.customClip ? 1.2 : 1.35));
|
||||
|
||||
return Math.max(64 * 1024 * 1024, Math.min(withHeadroom, 40 * 1024 * 1024 * 1024));
|
||||
}
|
||||
|
||||
function ensureDiskSpace(targetPath: string, requiredBytes: number, context: string): DownloadResult {
|
||||
const freeBytes = getFreeDiskBytes(targetPath);
|
||||
if (freeBytes === null) {
|
||||
appendDebugLog('disk-space-check-skipped', { targetPath, requiredBytes, context });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (freeBytes < Math.max(requiredBytes, MIN_FREE_DISK_BYTES)) {
|
||||
const message = `Zu wenig Speicherplatz fur ${context}: frei ${formatBytes(freeBytes)}, benoetigt ~${formatBytes(requiredBytes)}.`;
|
||||
appendDebugLog('disk-space-check-failed', {
|
||||
targetPath,
|
||||
requiredBytes,
|
||||
freeBytes,
|
||||
context
|
||||
});
|
||||
return { success: false, error: message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function getMetadataCacheTtlMs(): number {
|
||||
return normalizeMetadataCacheMinutes(config.metadata_cache_minutes) * 60 * 1000;
|
||||
}
|
||||
@ -921,6 +1045,7 @@ function classifyDownloadError(errorMessage: string): RetryErrorClass {
|
||||
const text = (errorMessage || '').toLowerCase();
|
||||
if (!text) return 'unknown';
|
||||
|
||||
if (text.includes('ungueltige vod-url') || text.includes('invalid vod url')) return 'validation';
|
||||
if (text.includes('429') || text.includes('rate limit') || text.includes('too many requests')) return 'rate_limit';
|
||||
if (text.includes('401') || text.includes('403') || text.includes('unauthorized') || text.includes('forbidden')) return 'auth';
|
||||
if (text.includes('timed out') || text.includes('timeout') || text.includes('network') || text.includes('connection') || text.includes('dns')) return 'network';
|
||||
@ -947,6 +1072,8 @@ function getRetryDelaySeconds(errorClass: RetryErrorClass, attempt: number): num
|
||||
return Math.min(25, 5 + attempt * 3 + jitter);
|
||||
case 'tooling':
|
||||
return DEFAULT_RETRY_DELAY_SECONDS;
|
||||
case 'validation':
|
||||
return 0;
|
||||
case 'unknown':
|
||||
default:
|
||||
return Math.min(25, DEFAULT_RETRY_DELAY_SECONDS + attempt * 2 + jitter);
|
||||
@ -1528,7 +1655,7 @@ async function getVideoInfo(filePath: string): Promise<VideoInfo | null> {
|
||||
duration: parseFloat(info.format?.duration || '0'),
|
||||
width: videoStream?.width || 0,
|
||||
height: videoStream?.height || 0,
|
||||
fps: eval(videoStream?.r_frame_rate || '30') || 30
|
||||
fps: parseFrameRate(videoStream?.r_frame_rate)
|
||||
});
|
||||
} catch {
|
||||
resolve(null);
|
||||
@ -1595,6 +1722,25 @@ async function cutVideo(
|
||||
const ffmpeg = getFFmpegPath();
|
||||
const duration = Math.max(0.1, endTime - startTime);
|
||||
|
||||
let inputBytes = 0;
|
||||
try {
|
||||
inputBytes = fs.statSync(inputFile).size;
|
||||
} catch {
|
||||
inputBytes = 0;
|
||||
}
|
||||
|
||||
const cutRequiredBytes = Math.max(96 * 1024 * 1024, Math.ceil(inputBytes * 0.75));
|
||||
const cutDiskCheck = ensureDiskSpace(path.dirname(outputFile), cutRequiredBytes, 'Video-Cut');
|
||||
if (!cutDiskCheck.success) {
|
||||
appendDebugLog('cut-video-no-disk-space', {
|
||||
inputFile,
|
||||
outputFile,
|
||||
requiredBytes: cutRequiredBytes,
|
||||
error: cutDiskCheck.error
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const runCutAttempt = async (copyMode: boolean): Promise<boolean> => {
|
||||
const args = [
|
||||
'-ss', formatDuration(startTime),
|
||||
@ -1677,6 +1823,30 @@ async function mergeVideos(
|
||||
const concatContent = inputFiles.map((filePath) => `file '${filePath.replace(/'/g, "'\\''")}'`).join('\n');
|
||||
fs.writeFileSync(concatFile, concatContent);
|
||||
|
||||
let mergeInputBytes = 0;
|
||||
for (const filePath of inputFiles) {
|
||||
try {
|
||||
mergeInputBytes += fs.statSync(filePath).size;
|
||||
} catch {
|
||||
// ignore missing file in estimation
|
||||
}
|
||||
}
|
||||
|
||||
const mergeRequiredBytes = Math.max(128 * 1024 * 1024, Math.ceil(mergeInputBytes * 1.1));
|
||||
const mergeDiskCheck = ensureDiskSpace(path.dirname(outputFile), mergeRequiredBytes, 'Video-Merge');
|
||||
if (!mergeDiskCheck.success) {
|
||||
appendDebugLog('merge-video-no-disk-space', {
|
||||
outputFile,
|
||||
files: inputFiles.length,
|
||||
requiredBytes: mergeRequiredBytes,
|
||||
error: mergeDiskCheck.error
|
||||
});
|
||||
try {
|
||||
fs.unlinkSync(concatFile);
|
||||
} catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
const runMergeAttempt = async (copyMode: boolean): Promise<boolean> => {
|
||||
const args = [
|
||||
'-f', 'concat',
|
||||
@ -1911,6 +2081,14 @@ async function downloadVOD(
|
||||
item: QueueItem,
|
||||
onProgress: (progress: DownloadProgress) => void
|
||||
): Promise<DownloadResult> {
|
||||
const vodId = parseVodId(item.url);
|
||||
if (!isLikelyVodUrl(item.url) || !vodId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Ungueltige VOD-URL'
|
||||
};
|
||||
}
|
||||
|
||||
onProgress({
|
||||
id: item.id,
|
||||
progress: -1,
|
||||
@ -1947,7 +2125,12 @@ async function downloadVOD(
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
|
||||
const totalDuration = parseDuration(item.duration_str);
|
||||
const vodId = parseVodId(item.url);
|
||||
|
||||
const requiredBytesEstimate = estimateRequiredDownloadBytes(item);
|
||||
const diskSpaceCheck = ensureDiskSpace(folder, requiredBytesEstimate, 'Download');
|
||||
if (!diskSpaceCheck.success) {
|
||||
return diskSpaceCheck;
|
||||
}
|
||||
|
||||
const makeTemplateFilename = (
|
||||
template: string,
|
||||
@ -2171,8 +2354,12 @@ async function processQueue(): Promise<void> {
|
||||
const errorClass = classifyDownloadError(result.error || '');
|
||||
runtimeMetrics.lastErrorClass = errorClass;
|
||||
|
||||
if (errorClass === 'tooling') {
|
||||
appendDebugLog('queue-item-no-retry-tooling', { itemId: item.id, error: result.error || 'unknown' });
|
||||
if (errorClass === 'tooling' || errorClass === 'validation') {
|
||||
appendDebugLog('queue-item-no-retry', {
|
||||
itemId: item.id,
|
||||
errorClass,
|
||||
error: result.error || 'unknown'
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@ -2370,6 +2557,11 @@ ipcMain.handle('get-queue', () => downloadQueue);
|
||||
ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => {
|
||||
if (config.prevent_duplicate_downloads && hasActiveDuplicate(item)) {
|
||||
runtimeMetrics.duplicateSkips += 1;
|
||||
mainWindow?.webContents.send('queue-duplicate-skipped', {
|
||||
title: item.title,
|
||||
streamer: item.streamer,
|
||||
url: item.url
|
||||
});
|
||||
appendDebugLog('queue-item-duplicate-skipped', {
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
@ -2545,6 +2737,11 @@ ipcMain.handle('download-clip', async (_, clipUrl: string) => {
|
||||
const folder = path.join(config.download_path, 'Clips', clipInfo.broadcaster_name);
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
|
||||
const clipDiskCheck = ensureDiskSpace(folder, 128 * 1024 * 1024, 'Clip-Download');
|
||||
if (!clipDiskCheck.success) {
|
||||
return { success: false, error: clipDiskCheck.error || 'Zu wenig Speicherplatz.' };
|
||||
}
|
||||
|
||||
const safeTitle = clipInfo.title.replace(/[^a-zA-Z0-9_\- ]/g, '').substring(0, 50);
|
||||
const filename = path.join(folder, `${safeTitle}.mp4`);
|
||||
|
||||
@ -2584,6 +2781,30 @@ ipcMain.handle('is-downloading', () => isDownloading);
|
||||
|
||||
ipcMain.handle('get-runtime-metrics', () => getRuntimeMetricsSnapshot());
|
||||
|
||||
ipcMain.handle('export-runtime-metrics', async () => {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const defaultName = `runtime-metrics-${timestamp}.json`;
|
||||
const preferredDir = fs.existsSync(config.download_path) ? config.download_path : app.getPath('desktop');
|
||||
|
||||
const dialogResult = await dialog.showSaveDialog(mainWindow!, {
|
||||
defaultPath: path.join(preferredDir, defaultName),
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }]
|
||||
});
|
||||
|
||||
if (dialogResult.canceled || !dialogResult.filePath) {
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
|
||||
const snapshot = getRuntimeMetricsSnapshot();
|
||||
fs.writeFileSync(dialogResult.filePath, JSON.stringify(snapshot, null, 2), 'utf-8');
|
||||
return { success: true, filePath: dialogResult.filePath };
|
||||
} catch (e) {
|
||||
appendDebugLog('runtime-metrics-export-failed', String(e));
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
// Video Cutter IPC
|
||||
ipcMain.handle('get-video-info', async (_, filePath: string) => {
|
||||
return await getVideoInfo(filePath);
|
||||
@ -2656,9 +2877,13 @@ app.on('window-all-closed', () => {
|
||||
if (currentProcess) {
|
||||
currentProcess.kill();
|
||||
}
|
||||
saveQueue(downloadQueue);
|
||||
flushQueueSave();
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
flushQueueSave();
|
||||
});
|
||||
|
||||
@ -138,6 +138,8 @@ contextBridge.exposeInMainWorld('api', {
|
||||
runPreflight: (autoFix: boolean) => ipcRenderer.invoke('run-preflight', autoFix),
|
||||
getDebugLog: (lines: number) => ipcRenderer.invoke('get-debug-log', lines),
|
||||
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
|
||||
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-runtime-metrics'),
|
||||
|
||||
// Events
|
||||
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {
|
||||
@ -146,6 +148,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
onQueueUpdated: (callback: (queue: QueueItem[]) => void) => {
|
||||
ipcRenderer.on('queue-updated', (_, queue) => callback(queue));
|
||||
},
|
||||
onQueueDuplicateSkipped: (callback: (payload: { title: string; streamer: string; url: string }) => void) => {
|
||||
ipcRenderer.on('queue-duplicate-skipped', (_, payload) => callback(payload));
|
||||
},
|
||||
onDownloadStarted: (callback: () => void) => {
|
||||
ipcRenderer.on('download-started', () => callback());
|
||||
},
|
||||
|
||||
2
typescript-version/src/renderer-globals.d.ts
vendored
2
typescript-version/src/renderer-globals.d.ts
vendored
@ -185,8 +185,10 @@ interface ApiBridge {
|
||||
runPreflight(autoFix: boolean): Promise<PreflightResult>;
|
||||
getDebugLog(lines: number): Promise<string>;
|
||||
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
|
||||
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
|
||||
onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
|
||||
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;
|
||||
onDownloadStarted(callback: () => void): void;
|
||||
onDownloadFinished(callback: () => void): void;
|
||||
onCutProgress(callback: (percent: number) => void): void;
|
||||
|
||||
@ -76,9 +76,13 @@ const UI_TEXT_DE = {
|
||||
templateGuideContextClipLive: 'Kontext: Aktuelle Auswahl im Clip-Dialog',
|
||||
runtimeMetricsTitle: 'Runtime Metrics',
|
||||
runtimeMetricsRefresh: 'Aktualisieren',
|
||||
runtimeMetricsExport: 'Export JSON',
|
||||
runtimeMetricsAutoRefresh: 'Auto-Refresh',
|
||||
runtimeMetricsLoading: 'Metriken werden geladen...',
|
||||
runtimeMetricsError: 'Runtime-Metriken konnten nicht geladen werden.',
|
||||
runtimeMetricsExportDone: 'Runtime-Metriken wurden exportiert.',
|
||||
runtimeMetricsExportCancelled: 'Export der Runtime-Metriken abgebrochen.',
|
||||
runtimeMetricsExportFailed: 'Export der Runtime-Metriken fehlgeschlagen.',
|
||||
runtimeMetricQueue: 'Queue',
|
||||
runtimeMetricMode: 'Modus',
|
||||
runtimeMetricRetries: 'Retries',
|
||||
|
||||
@ -76,9 +76,13 @@ const UI_TEXT_EN = {
|
||||
templateGuideContextClipLive: 'Context: Current clip dialog selection',
|
||||
runtimeMetricsTitle: 'Runtime Metrics',
|
||||
runtimeMetricsRefresh: 'Refresh',
|
||||
runtimeMetricsExport: 'Export JSON',
|
||||
runtimeMetricsAutoRefresh: 'Auto refresh',
|
||||
runtimeMetricsLoading: 'Loading metrics...',
|
||||
runtimeMetricsError: 'Could not load runtime metrics.',
|
||||
runtimeMetricsExportDone: 'Runtime metrics exported successfully.',
|
||||
runtimeMetricsExportCancelled: 'Runtime metrics export cancelled.',
|
||||
runtimeMetricsExportFailed: 'Runtime metrics export failed.',
|
||||
runtimeMetricQueue: 'Queue',
|
||||
runtimeMetricMode: 'Mode',
|
||||
runtimeMetricRetries: 'Retries',
|
||||
|
||||
@ -98,6 +98,31 @@ async function refreshRuntimeMetrics(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRuntimeMetrics(): Promise<void> {
|
||||
const result = await window.api.exportRuntimeMetrics();
|
||||
|
||||
const toast = (window as unknown as { showAppToast?: (message: string, type?: 'info' | 'warn') => void }).showAppToast;
|
||||
const notify = (message: string, type: 'info' | 'warn' = 'info') => {
|
||||
if (typeof toast === 'function') {
|
||||
toast(message, type);
|
||||
} else if (type === 'warn') {
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
notify(UI_TEXT.static.runtimeMetricsExportDone, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.cancelled) {
|
||||
notify(UI_TEXT.static.runtimeMetricsExportCancelled, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
notify(`${UI_TEXT.static.runtimeMetricsExportFailed}${result.error ? `\n${result.error}` : ''}`, 'warn');
|
||||
}
|
||||
|
||||
function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
|
||||
if (runtimeMetricsAutoRefreshTimer) {
|
||||
clearInterval(runtimeMetricsAutoRefreshTimer);
|
||||
|
||||
@ -125,6 +125,7 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('autoRefreshText', UI_TEXT.static.autoRefresh);
|
||||
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
|
||||
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);
|
||||
setText('btnExportMetrics', UI_TEXT.static.runtimeMetricsExport);
|
||||
setText('runtimeMetricsAutoRefreshText', UI_TEXT.static.runtimeMetricsAutoRefresh);
|
||||
setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading);
|
||||
setText('updateText', UI_TEXT.updates.bannerDefault);
|
||||
|
||||
@ -36,6 +36,11 @@ async function init(): Promise<void> {
|
||||
renderQueue();
|
||||
});
|
||||
|
||||
window.api.onQueueDuplicateSkipped((payload) => {
|
||||
const title = payload?.title ? ` (${payload.title})` : '';
|
||||
showAppToast(`${UI_TEXT.queue.duplicateSkipped}${title}`, 'warn');
|
||||
});
|
||||
|
||||
window.api.onDownloadProgress((progress: DownloadProgress) => {
|
||||
const item = queue.find((i: QueueItem) => i.id === progress.id);
|
||||
if (!item) {
|
||||
@ -98,6 +103,37 @@ async function init(): Promise<void> {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
let toastHideTimer: number | null = null;
|
||||
|
||||
function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void {
|
||||
let toast = document.getElementById('appToast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'appToast';
|
||||
toast.className = 'app-toast';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
toast.textContent = message;
|
||||
toast.classList.remove('warn', 'show');
|
||||
if (type === 'warn') {
|
||||
toast.classList.add('warn');
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast?.classList.add('show');
|
||||
});
|
||||
|
||||
if (toastHideTimer) {
|
||||
clearTimeout(toastHideTimer);
|
||||
toastHideTimer = null;
|
||||
}
|
||||
|
||||
toastHideTimer = window.setTimeout(() => {
|
||||
toast?.classList.remove('show');
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
function mergeQueueState(nextQueue: QueueItem[]): QueueItem[] {
|
||||
const prevById = new Map(queue.map((item) => [item.id, item]));
|
||||
|
||||
|
||||
@ -1379,3 +1379,32 @@ body.theme-apple {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 16px;
|
||||
z-index: 2200;
|
||||
max-width: min(90vw, 520px);
|
||||
background: rgba(20, 20, 24, 0.96);
|
||||
color: #e6e6ea;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.app-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.app-toast.warn {
|
||||
border-color: rgba(255, 167, 38, 0.7);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user