From a4ca4106410f2940b6567aed7260e3d845a0373a Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Fri, 20 Mar 2026 09:27:19 +0100 Subject: [PATCH] feat: filename collision detection, queue JSON validation, download-complete notification Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.ts | 53 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/src/main.ts b/src/main.ts index ad06099..27be3ac 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme } from 'electron'; +import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Notification } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; import { spawn, ChildProcess, execSync, exec, spawnSync } from 'child_process'; @@ -340,10 +340,17 @@ function loadQueue(): QueueItem[] { try { if (fs.existsSync(QUEUE_FILE)) { const data = fs.readFileSync(QUEUE_FILE, 'utf-8'); - return JSON.parse(data); + const parsed = JSON.parse(data); + if (!Array.isArray(parsed)) return []; + return parsed.filter((item: any) => + item && typeof item.id === 'string' && + typeof item.url === 'string' && + typeof item.status === 'string' + ); } } catch (e) { console.error('Error loading queue:', e); + console.error('queue-load-error', { error: String(e) }); } return []; } @@ -1168,6 +1175,20 @@ function formatDurationDashed(seconds: number): string { return `${h.toString().padStart(2, '0')}-${m.toString().padStart(2, '0')}-${s.toString().padStart(2, '0')}`; } +function ensureUniqueFilename(filePath: string): string { + if (!fs.existsSync(filePath)) return filePath; + const dir = path.dirname(filePath); + const ext = path.extname(filePath); + const base = path.basename(filePath, ext); + let counter = 1; + let candidate = filePath; + while (fs.existsSync(candidate)) { + candidate = path.join(dir, `${base}_${counter}${ext}`); + counter++; + } + return candidate; +} + function sanitizeFilenamePart(input: string, fallback = 'unnamed'): string { const cleaned = (input || '') .replace(/[<>:"|?*\x00-\x1f]/g, '_') @@ -2565,7 +2586,7 @@ async function splitMergedFile( const startSec = i * partDurationSec; const thisDuration = Math.min(partDurationSec, totalDurationSec - startSec); - const outputFile = path.join(outputFolder, filenameGenerator(i + 1)); + const outputFile = ensureUniqueFilename(path.join(outputFolder, filenameGenerator(i + 1))); onProgress(i + 1, numParts); @@ -2890,7 +2911,7 @@ async function downloadVOD( const remainingDuration = clip.durationSec - (i * partDuration); const thisDuration = Math.min(partDuration, remainingDuration); - const partFilename = makeClipFilename(partNum, startOffset, thisDuration); + const partFilename = ensureUniqueFilename(makeClipFilename(partNum, startOffset, thisDuration)); const result = await downloadVODPart( item.url, @@ -2913,7 +2934,7 @@ async function downloadVOD( }; } else { // Single clip file - const filename = makeClipFilename(clip.startPart, clip.startSec, clip.durationSec); + const filename = ensureUniqueFilename(makeClipFilename(clip.startPart, clip.startSec, clip.durationSec)); return await downloadVODPart( item.url, filename, @@ -2930,13 +2951,13 @@ async function downloadVOD( // Check download mode if (config.download_mode === 'full' || totalDuration <= config.part_minutes * 60) { // Full download - const filename = makeTemplateFilename( + const filename = ensureUniqueFilename(makeTemplateFilename( config.filename_template_vod, DEFAULT_FILENAME_TEMPLATE_VOD, 1, 0, totalDuration - ); + )); return await downloadVODPart(item.url, filename, null, null, onProgress, item.id, 1, 1); } else { // Part-based download @@ -2951,13 +2972,13 @@ async function downloadVOD( const endSec = Math.min((i + 1) * partDuration, totalDuration); const duration = endSec - startSec; - const partFilename = makeTemplateFilename( + const partFilename = ensureUniqueFilename(makeTemplateFilename( config.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS, i + 1, startSec, duration - ); + )); const result = await downloadVODPart( item.url, @@ -3041,7 +3062,7 @@ async function processDownloadMergeGroup( saveQueue(downloadQueue); const vodItem = mg.items[i]; - const tmpFilename = path.join(folder, `merge_tmp_${i}_${Date.now()}.mp4`); + const tmpFilename = ensureUniqueFilename(path.join(folder, `merge_tmp_${i}_${Date.now()}.mp4`)); // Calculate progress weighting per VOD const vodDuration = parseDuration(vodItem.duration_str); @@ -3364,6 +3385,18 @@ async function processQueue(): Promise { saveQueue(downloadQueue); emitQueueUpdated(); mainWindow?.webContents.send('download-finished'); + try { + if (Notification.isSupported()) { + const completed = downloadQueue.filter(i => i.status === 'completed').length; + const failed = downloadQueue.filter(i => i.status === 'error').length; + new Notification({ + title: 'Twitch VOD Manager', + body: failed > 0 + ? `${completed} Downloads fertig, ${failed} fehlgeschlagen` + : `${completed} Downloads abgeschlossen` + }).show(); + } + } catch { } appendDebugLog('queue-finished', { items: downloadQueue.length }); }