feat: queue "Open file" / "Show in folder" + clickable finish notification

After a download completes there was no way to jump to the result
without manually navigating the download folder.

Server-side:
- DownloadResult and QueueItem gain optional outputFiles: string[]
  (single entry for VOD/clip, multi for parts/merge-group splits).
  Threaded through every downloadVOD / processDownloadMergeGroup
  branch into processOneQueueItem which attaches it to the queue
  item on success. Persisted via sanitizeQueueItem so the actions
  survive a queue file reload.
- New IPC handlers open-file (shell.openPath) and show-in-folder
  (shell.showItemInFolder), both with existence + type checks.
- The "downloads finished" Notification gets a click handler that
  brings the window to the foreground and opens the download folder.

Renderer-side:
- Expanded queue-item details now render an action row when
  status === completed and outputFiles is non-empty.
- "Open file" only shown when there is exactly one file (so multi-
  part downloads do not surprise the user by opening just part 1).
  "Show in folder" always shown.
- DE / EN locale strings + a graceful toast if the file was moved
  or deleted between completion and click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-10 12:19:29 +02:00
parent f04c0b64cc
commit 933af6a6da
7 changed files with 122 additions and 9 deletions

View File

@ -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.downloadedBytes === 'number' && Number.isFinite(raw.downloadedBytes)) item.downloadedBytes = raw.downloadedBytes;
if (typeof raw.totalBytes === 'number' && Number.isFinite(raw.totalBytes)) item.totalBytes = raw.totalBytes; 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); const customClip = sanitizeCustomClip(raw.customClip);
if (customClip) item.customClip = customClip; if (customClip) item.customClip = customClip;
@ -2709,12 +2714,13 @@ async function downloadVOD(
return { return {
success: downloadedFiles.length === numParts, 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 { } else {
// Single clip file // Single clip file
const filename = ensureUniqueFilename(makeClipFilename(clip.startPart, clip.startSec, clip.durationSec), item.id); const filename = ensureUniqueFilename(makeClipFilename(clip.startPart, clip.startSec, clip.durationSec), item.id);
return await downloadVODPart( const result = await downloadVODPart(
item.url, item.url,
filename, filename,
formatDuration(clip.startSec), formatDuration(clip.startSec),
@ -2724,6 +2730,7 @@ async function downloadVOD(
1, 1,
1 1
); );
return result.success ? { ...result, outputFiles: [filename] } : result;
} }
} }
@ -2737,7 +2744,8 @@ async function downloadVOD(
0, 0,
totalDuration totalDuration
), item.id); ), 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 { } else {
// Part-based download // Part-based download
const partDuration = config.part_minutes * 60; const partDuration = config.part_minutes * 60;
@ -2779,7 +2787,8 @@ async function downloadVOD(
return { return {
success: downloadedFiles.length === numParts, 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 totalDurationSec
}); });
return { success: true }; return { success: true, outputFiles: [...splitResult.files] };
} }
async function processOneQueueItem(item: QueueItem): Promise<void> { async function processOneQueueItem(item: QueueItem): Promise<void> {
@ -3116,6 +3125,13 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
item.progress = finalResult.success ? 100 : item.progress; item.progress = finalResult.success ? 100 : item.progress;
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download'); 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) { if (finalResult.success) {
runtimeMetrics.downloadsCompleted += 1; runtimeMetrics.downloadsCompleted += 1;
} else if (!wasPaused) { } else if (!wasPaused) {
@ -3202,12 +3218,28 @@ async function processQueue(): Promise<void> {
if (Notification.isSupported()) { if (Notification.isSupported()) {
const completed = downloadQueue.filter(i => i.status === 'completed').length; const completed = downloadQueue.filter(i => i.status === 'completed').length;
const failed = downloadQueue.filter(i => i.status === 'error').length; const failed = downloadQueue.filter(i => i.status === 'error').length;
new Notification({ const notification = new Notification({
title: 'Twitch VOD Manager', title: 'Twitch VOD Manager',
body: failed > 0 body: failed > 0
? `${completed} Downloads fertig, ${failed} fehlgeschlagen` ? `${completed} Downloads fertig, ${failed} fehlgeschlagen`
: `${completed} Downloads abgeschlossen` : `${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 { } } catch { }
appendDebugLog('queue-finished', { items: downloadQueue.length }); appendDebugLog('queue-finished', { items: downloadQueue.length });
@ -3860,6 +3892,21 @@ ipcMain.handle('open-folder', (_, folderPath: string) => {
} }
}); });
ipcMain.handle('open-file', async (_, filePath: string): Promise<boolean> => {
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('get-version', () => APP_VERSION);
ipcMain.handle('check-update', async () => { ipcMain.handle('check-update', async () => {

View File

@ -83,6 +83,8 @@ contextBridge.exposeInMainWorld('api', {
selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'), selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'),
saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName), saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName),
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path), 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 // 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

@ -75,6 +75,7 @@ interface QueueItem {
last_error?: string; last_error?: string;
customClip?: CustomClip; customClip?: CustomClip;
mergeGroup?: MergeGroup; mergeGroup?: MergeGroup;
outputFiles?: string[];
} }
interface DownloadProgress { interface DownloadProgress {
@ -197,6 +198,8 @@ interface ApiBridge {
selectMultipleVideos(): Promise<string[] | null>; selectMultipleVideos(): Promise<string[] | null>;
saveVideoDialog(defaultName: string): Promise<string | null>; saveVideoDialog(defaultName: string): Promise<string | null>;
openFolder(path: string): Promise<void>; openFolder(path: string): Promise<void>;
openFile(path: string): Promise<boolean>;
showInFolder(path: string): Promise<boolean>;
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

@ -153,7 +153,11 @@ const UI_TEXT_DE = {
eta: 'Restzeit', eta: 'Restzeit',
part: 'Teil', part: 'Teil',
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.', 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: { vods: {
noneTitle: 'Keine VODs', noneTitle: 'Keine VODs',

View File

@ -153,7 +153,11 @@ const UI_TEXT_EN = {
eta: 'ETA', eta: 'ETA',
part: 'Part', part: 'Part',
emptyAlert: 'Queue is empty. Add a VOD or clip first.', 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: { vods: {
noneTitle: 'No VODs', noneTitle: 'No VODs',

View File

@ -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, '&quot;');
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(`<button class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
}
buttons.push(`<button class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
const fileLabel = item.outputFiles.length === 1
? safeFirst
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
return `
<div class="queue-output-row" style="display:flex; gap:6px; margin-top:6px; flex-wrap:wrap; align-items:center;">
${buttons.join('')}
<span style="color: var(--text-secondary,#888); font-size:11px; word-break:break-all;">${fileLabel}</span>
</div>
`;
}
async function invokeOpenFile(filePath: string): Promise<void> {
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<void> {
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 { function buildQueueFingerprint(url: string, streamer: string, date: string, customClip?: CustomClip): string {
const clipFingerprint = customClip const clipFingerprint = customClip
? [ ? [
@ -332,6 +379,7 @@ function renderQueue(): void {
<div>Streamer: ${escapeHtml(item.streamer)}</div> <div>Streamer: ${escapeHtml(item.streamer)}</div>
<div>Dauer: ${escapeHtml(item.duration_str)}</div> <div>Dauer: ${escapeHtml(item.duration_str)}</div>
<div>Datum: ${escapeHtml(new Date(item.date).toLocaleString())}</div> <div>Datum: ${escapeHtml(new Date(item.date).toLocaleString())}</div>
${renderQueueItemFileActions(item)}
</div> </div>
</div> </div>
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span> <span class="remove" onclick="removeFromQueue('${item.id}')">x</span>

View File

@ -42,6 +42,10 @@ export interface QueueItem {
last_error?: string; last_error?: string;
customClip?: CustomClip; customClip?: CustomClip;
mergeGroup?: MergeGroup; 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 { export interface DownloadProgress {
@ -60,4 +64,5 @@ export interface DownloadProgress {
export interface DownloadResult { export interface DownloadResult {
success: boolean; success: boolean;
error?: string; error?: string;
outputFiles?: string[];
} }