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:
parent
f04c0b64cc
commit
933af6a6da
61
src/main.ts
61
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<void> {
|
||||
@ -3116,6 +3125,13 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
||||
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<void> {
|
||||
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<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('check-update', async () => {
|
||||
|
||||
@ -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<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||
|
||||
3
src/renderer-globals.d.ts
vendored
3
src/renderer-globals.d.ts
vendored
@ -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<string[] | null>;
|
||||
saveVideoDialog(defaultName: string): Promise<string | null>;
|
||||
openFolder(path: string): Promise<void>;
|
||||
openFile(path: string): Promise<boolean>;
|
||||
showInFolder(path: string): Promise<boolean>;
|
||||
getVideoInfo(filePath: string): Promise<VideoInfo | null>;
|
||||
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
||||
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(`<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 {
|
||||
const clipFingerprint = customClip
|
||||
? [
|
||||
@ -332,6 +379,7 @@ function renderQueue(): void {
|
||||
<div>Streamer: ${escapeHtml(item.streamer)}</div>
|
||||
<div>Dauer: ${escapeHtml(item.duration_str)}</div>
|
||||
<div>Datum: ${escapeHtml(new Date(item.date).toLocaleString())}</div>
|
||||
${renderQueueItemFileActions(item)}
|
||||
</div>
|
||||
</div>
|
||||
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user