diff --git a/src/main.ts b/src/main.ts index 1d7ebfb..bb066db 100644 --- a/src/main.ts +++ b/src/main.ts @@ -408,6 +408,11 @@ function sanitizeQueueItem(raw: unknown): QueueItem | null { if (typeof raw.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes; if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes; + if (Array.isArray(raw.outputFiles)) { + const files = raw.outputFiles.filter((f): f is string => typeof f === 'string' && f.length > 0); + if (files.length > 0) item.outputFiles = files; + } + const customClip = sanitizeCustomClip(raw.customClip); if (customClip) item.customClip = customClip; @@ -2709,12 +2714,13 @@ async function downloadVOD( return { success: downloadedFiles.length === numParts, - error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Clip-Teile konnten heruntergeladen werden.' + error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Clip-Teile konnten heruntergeladen werden.', + outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined }; } else { // Single clip file const filename = ensureUniqueFilename(makeClipFilename(clip.startPart, clip.startSec, clip.durationSec), item.id); - return await downloadVODPart( + const result = await downloadVODPart( item.url, filename, formatDuration(clip.startSec), @@ -2724,6 +2730,7 @@ async function downloadVOD( 1, 1 ); + return result.success ? { ...result, outputFiles: [filename] } : result; } } @@ -2737,7 +2744,8 @@ async function downloadVOD( 0, totalDuration ), item.id); - return await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1); + const result = await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1); + return result.success ? { ...result, outputFiles: [filename] } : result; } else { // Part-based download const partDuration = config.part_minutes * 60; @@ -2779,7 +2787,8 @@ async function downloadVOD( return { success: downloadedFiles.length === numParts, - error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Teile konnten heruntergeladen werden.' + error: downloadedFiles.length === numParts ? undefined : 'Nicht alle Teile konnten heruntergeladen werden.', + outputFiles: downloadedFiles.length === numParts ? [...downloadedFiles] : undefined }; } } @@ -3021,7 +3030,7 @@ async function processDownloadMergeGroup( totalDurationSec }); - return { success: true }; + return { success: true, outputFiles: [...splitResult.files] }; } async function processOneQueueItem(item: QueueItem): Promise { @@ -3116,6 +3125,13 @@ async function processOneQueueItem(item: QueueItem): Promise { item.progress = finalResult.success ? 100 : item.progress; item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download'); + if (finalResult.success && Array.isArray(finalResult.outputFiles) && finalResult.outputFiles.length > 0) { + // Attach the produced file paths so the renderer can offer + // "Open file" / "Show in folder" actions on completed items, + // surviving a queue persistence round-trip. + item.outputFiles = [...finalResult.outputFiles]; + } + if (finalResult.success) { runtimeMetrics.downloadsCompleted += 1; } else if (!wasPaused) { @@ -3202,12 +3218,28 @@ async function processQueue(): Promise { if (Notification.isSupported()) { const completed = downloadQueue.filter(i => i.status === 'completed').length; const failed = downloadQueue.filter(i => i.status === 'error').length; - new Notification({ + const notification = new Notification({ title: 'Twitch VOD Manager', body: failed > 0 ? `${completed} Downloads fertig, ${failed} fehlgeschlagen` : `${completed} Downloads abgeschlossen` - }).show(); + }); + // Click brings the app to the foreground AND opens the download + // folder so the user can immediately see the output files. + notification.on('click', () => { + try { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + if (config.download_path && fs.existsSync(config.download_path)) { + void shell.openPath(config.download_path); + } + } catch (e) { + appendDebugLog('notification-click-failed', String(e)); + } + }); + notification.show(); } } catch { } appendDebugLog('queue-finished', { items: downloadQueue.length }); @@ -3860,6 +3892,21 @@ ipcMain.handle('open-folder', (_, folderPath: string) => { } }); +ipcMain.handle('open-file', async (_, filePath: string): Promise => { + if (typeof filePath !== 'string' || !filePath) return false; + if (!fs.existsSync(filePath)) return false; + const result = await shell.openPath(filePath); + // shell.openPath returns '' on success, an error string on failure. + return result === ''; +}); + +ipcMain.handle('show-in-folder', (_, filePath: string): boolean => { + if (typeof filePath !== 'string' || !filePath) return false; + if (!fs.existsSync(filePath)) return false; + shell.showItemInFolder(filePath); + return true; +}); + ipcMain.handle('get-version', () => APP_VERSION); ipcMain.handle('check-update', async () => { diff --git a/src/preload.ts b/src/preload.ts index 833c32c..e4706ba 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -83,6 +83,8 @@ contextBridge.exposeInMainWorld('api', { selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'), saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName), openFolder: (path: string) => ipcRenderer.invoke('open-folder', path), + openFile: (path: string) => ipcRenderer.invoke('open-file', path), + showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path), // Video Cutter getVideoInfo: (filePath: string): Promise => ipcRenderer.invoke('get-video-info', filePath), diff --git a/src/renderer-globals.d.ts b/src/renderer-globals.d.ts index 9b12f8b..65ea9b7 100644 --- a/src/renderer-globals.d.ts +++ b/src/renderer-globals.d.ts @@ -75,6 +75,7 @@ interface QueueItem { last_error?: string; customClip?: CustomClip; mergeGroup?: MergeGroup; + outputFiles?: string[]; } interface DownloadProgress { @@ -197,6 +198,8 @@ interface ApiBridge { selectMultipleVideos(): Promise; saveVideoDialog(defaultName: string): Promise; openFolder(path: string): Promise; + openFile(path: string): Promise; + showInFolder(path: string): Promise; getVideoInfo(filePath: string): Promise; extractFrame(filePath: string, timeSeconds: number): Promise; cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>; diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index c230a57..dcac842 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -153,7 +153,11 @@ const UI_TEXT_DE = { eta: 'Restzeit', part: 'Teil', emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.', - duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.' + duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.', + openFile: 'Datei oeffnen', + showInFolder: 'Im Ordner zeigen', + openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).', + outputFilesLabel: '{count} Ausgabedateien' }, vods: { noneTitle: 'Keine VODs', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index 11fc605..aac8bcc 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -153,7 +153,11 @@ const UI_TEXT_EN = { eta: 'ETA', part: 'Part', emptyAlert: 'Queue is empty. Add a VOD or clip first.', - duplicateSkipped: 'This item is already active in the queue.' + duplicateSkipped: 'This item is already active in the queue.', + openFile: 'Open file', + showInFolder: 'Show in folder', + openFileFailed: 'Could not open the file (it may have been moved or deleted).', + outputFilesLabel: '{count} output files' }, vods: { noneTitle: 'No VODs', diff --git a/src/renderer-queue.ts b/src/renderer-queue.ts index 353ae3e..36a0805 100644 --- a/src/renderer-queue.ts +++ b/src/renderer-queue.ts @@ -1,3 +1,50 @@ +function renderQueueItemFileActions(item: QueueItem): string { + if (item.status !== 'completed' || !item.outputFiles || item.outputFiles.length === 0) { + return ''; + } + + const first = item.outputFiles[0]; + if (typeof first !== 'string' || !first) return ''; + const safeFirst = escapeHtml(first); + const safeFirstAttr = first.replace(/'/g, "\\'").replace(/"/g, '"'); + const buttons: string[] = []; + + // "Open file" only makes sense when there's exactly one output (a clip / + // full VOD download). For multi-part downloads "open the first part" is + // surprising — the user almost always wants the folder. + if (item.outputFiles.length === 1) { + buttons.push(``); + } + buttons.push(``); + + const fileLabel = item.outputFiles.length === 1 + ? safeFirst + : `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`; + + return ` +
+ ${buttons.join('')} + ${fileLabel} +
+ `; +} + +async function invokeOpenFile(filePath: string): Promise { + const ok = await window.api.openFile(filePath); + if (!ok) { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn'); + } +} + +async function invokeShowInFolder(filePath: string): Promise { + const ok = await window.api.showInFolder(filePath); + if (!ok) { + const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast; + if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn'); + } +} + function buildQueueFingerprint(url: string, streamer: string, date: string, customClip?: CustomClip): string { const clipFingerprint = customClip ? [ @@ -332,6 +379,7 @@ function renderQueue(): void {
Streamer: ${escapeHtml(item.streamer)}
Dauer: ${escapeHtml(item.duration_str)}
Datum: ${escapeHtml(new Date(item.date).toLocaleString())}
+ ${renderQueueItemFileActions(item)} x diff --git a/src/types.ts b/src/types.ts index 1e74ec2..51eaf86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,10 @@ export interface QueueItem { last_error?: string; customClip?: CustomClip; mergeGroup?: MergeGroup; + // File paths produced by the download (single file for VOD/clip, multiple + // for parts/merge-group splits). Persisted with the queue so completed + // items keep their "Open file" / "Show in folder" actions across restarts. + outputFiles?: string[]; } export interface DownloadProgress { @@ -60,4 +64,5 @@ export interface DownloadProgress { export interface DownloadResult { success: boolean; error?: string; + outputFiles?: string[]; }