diff --git a/typescript-version/package-lock.json b/typescript-version/package-lock.json
index a1b69cd..e256452 100644
--- a/typescript-version/package-lock.json
+++ b/typescript-version/package-lock.json
@@ -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",
diff --git a/typescript-version/package.json b/typescript-version/package.json
index 362272a..1047b1a 100644
--- a/typescript-version/package.json
+++ b/typescript-version/package.json
@@ -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",
diff --git a/typescript-version/scripts/smoke-test-full.js b/typescript-version/scripts/smoke-test-full.js
index 0a37389..11b14e8 100644
--- a/typescript-version/scripts/smoke-test-full.js
+++ b/typescript-version/scripts/smoke-test-full.js
@@ -358,19 +358,16 @@ async function run() {
failedStatus: failed?.status || 'none',
failedReason: failed?.last_error || ''
};
- if (reachedError && failed?.status === 'error') {
- assert(Boolean(failed?.last_error), 'Retry test item missing error reason');
+ 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();
- await sleep(500);
- 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';
- }
+ await window.api.retryFailedDownloads();
+ await sleep(500);
+ q = await window.api.getQueue();
+ const afterRetry = q.find((item) => item.title === '__E2E_FULL__retry');
+ checks.retryFlow.afterRetryStatus = afterRetry?.status || 'none';
+ const retryAcceptedStatuses = ['pending', 'downloading', 'error'];
+ assert(retryAcceptedStatuses.includes(afterRetry?.status || ''), 'Retry failed action did not update item state');
await cleanupDownloads();
await clearQueue();
diff --git a/typescript-version/src/index.html b/typescript-version/src/index.html
index feaaa40..a41b356 100644
--- a/typescript-version/src/index.html
+++ b/typescript-version/src/index.html
@@ -457,7 +457,7 @@
Updates
-
Version: v4.1.0
+
Version: v4.1.1
@@ -486,6 +486,7 @@
Runtime Metrics
- v4.1.0
+ v4.1.1
diff --git a/typescript-version/src/main.ts b/typescript-version/src/main.ts
index 198a53e..af5ff65 100644
--- a/typescript-version/src/main.ts
+++ b/typescript-version/src/main.ts
@@ -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 = {
+ 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 {
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 => {
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 => {
const args = [
'-f', 'concat',
@@ -1911,6 +2081,14 @@ async function downloadVOD(
item: QueueItem,
onProgress: (progress: DownloadProgress) => void
): Promise {
+ 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 {
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) => {
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();
+});
diff --git a/typescript-version/src/preload.ts b/typescript-version/src/preload.ts
index 87ca0bc..d263ab6 100644
--- a/typescript-version/src/preload.ts
+++ b/typescript-version/src/preload.ts
@@ -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 => 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());
},
diff --git a/typescript-version/src/renderer-globals.d.ts b/typescript-version/src/renderer-globals.d.ts
index a30488d..64e22b3 100644
--- a/typescript-version/src/renderer-globals.d.ts
+++ b/typescript-version/src/renderer-globals.d.ts
@@ -185,8 +185,10 @@ interface ApiBridge {
runPreflight(autoFix: boolean): Promise;
getDebugLog(lines: number): Promise;
getRuntimeMetrics(): Promise;
+ 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;
diff --git a/typescript-version/src/renderer-locale-de.ts b/typescript-version/src/renderer-locale-de.ts
index 2720645..ffee6e6 100644
--- a/typescript-version/src/renderer-locale-de.ts
+++ b/typescript-version/src/renderer-locale-de.ts
@@ -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',
diff --git a/typescript-version/src/renderer-locale-en.ts b/typescript-version/src/renderer-locale-en.ts
index b8ea1f4..85d436d 100644
--- a/typescript-version/src/renderer-locale-en.ts
+++ b/typescript-version/src/renderer-locale-en.ts
@@ -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',
diff --git a/typescript-version/src/renderer-settings.ts b/typescript-version/src/renderer-settings.ts
index d2ab9dc..8421ff1 100644
--- a/typescript-version/src/renderer-settings.ts
+++ b/typescript-version/src/renderer-settings.ts
@@ -98,6 +98,31 @@ async function refreshRuntimeMetrics(): Promise {
}
}
+async function exportRuntimeMetrics(): Promise {
+ 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);
diff --git a/typescript-version/src/renderer-texts.ts b/typescript-version/src/renderer-texts.ts
index fb05fdc..d9b6565 100644
--- a/typescript-version/src/renderer-texts.ts
+++ b/typescript-version/src/renderer-texts.ts
@@ -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);
diff --git a/typescript-version/src/renderer.ts b/typescript-version/src/renderer.ts
index 5b6d4bc..a5255f4 100644
--- a/typescript-version/src/renderer.ts
+++ b/typescript-version/src/renderer.ts
@@ -36,6 +36,11 @@ async function init(): Promise {
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 {
}, 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]));
diff --git a/typescript-version/src/styles.css b/typescript-version/src/styles.css
index d759082..344746a 100644
--- a/typescript-version/src/styles.css
+++ b/typescript-version/src/styles.css
@@ -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);
+}