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.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 () => {
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
3
src/renderer-globals.d.ts
vendored
3
src/renderer-globals.d.ts
vendored
@ -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 }>;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 {
|
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>
|
||||||
|
|||||||
@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user