From 6aae84cac7d2551f89fe0e4595692fb9221bba5d Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Thu, 19 Mar 2026 17:10:15 +0100 Subject: [PATCH] docs: add VOD merge+split implementation plan 12-task step-by-step plan with exact code, file paths, and line numbers. Reviewed and fixed 5 issues (locale schema, language-awareness, TypeScript union types, execSync import, cleanup scope). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-19-vod-merge-split.md | 1229 +++++++++++++++++ 1 file changed, 1229 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-19-vod-merge-split.md diff --git a/docs/superpowers/plans/2026-03-19-vod-merge-split.md b/docs/superpowers/plans/2026-03-19-vod-merge-split.md new file mode 100644 index 0000000..adcb102 --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-vod-merge-split.md @@ -0,0 +1,1229 @@ +# VOD Merge+Split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to select 2+ pending VODs in the queue, merge them into one group, download all, merge via FFmpeg, and split into time-based parts automatically. + +**Architecture:** Extend `QueueItem` with optional `mergeGroup` field. Add `processDownloadMergeGroup()` as a 4-phase pipeline (download → merge → split → cleanup) called from `processQueue()`. UI adds checkboxes to pending queue items with a "Merge & Split" action button. + +**Tech Stack:** TypeScript, Electron IPC, FFmpeg (concat demuxer + stream-copy split), vanilla HTML/CSS + +**Spec:** `docs/superpowers/specs/2026-03-19-vod-merge-split-design.md` + +--- + +## File Structure + +| File | Role | Action | +|------|------|--------| +| `src/renderer-globals.d.ts` | Type definitions for renderer | Modify: add `MergeGroupItem`, `MergeGroup` interfaces, extend `QueueItem` | +| `src/preload.ts` | IPC bridge | Modify: add same interfaces, add `createMergeGroup` API method | +| `src/main.ts` | Core logic | Modify: add interfaces, `processDownloadMergeGroup()`, `splitMergedFile()`, fix `mergeVideos()` progress, IPC handler, extend `processQueue()` | +| `src/renderer-locale-en.ts` | English strings | Modify: add `mergeGroup` block | +| `src/renderer-locale-de.ts` | German strings | Modify: add `mergeGroup` block | +| `src/renderer-shared.ts` | Renderer global state | Modify: add `selectedQueueIds` | +| `src/renderer-queue.ts` | Queue UI rendering | Modify: checkboxes, merge button, merge-group rendering | +| `src/styles.css` | Styles | Modify: checkbox and merge-group styles | +| `scripts/smoke-test-merge-split-logic.js` | Unit tests | Create | + +--- + +### Task 1: Type Definitions — `MergeGroupItem`, `MergeGroup`, extend `QueueItem` + +**Files:** +- Modify: `src/renderer-globals.d.ts:32-58` +- Modify: `src/preload.ts:4-26` +- Modify: `src/main.ts:146-173` + +- [ ] **Step 1: Add interfaces to `src/renderer-globals.d.ts`** + +After the `CustomClip` interface (line 38), before `QueueItem` (line 40), add: + +```typescript +interface MergeGroupItem { + url: string; + title: string; + date: string; + streamer: string; + duration_str: string; +} + +interface MergeGroup { + items: MergeGroupItem[]; + mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done'; + currentItemIndex: number; + downloadedFiles: Record; + mergedFile?: string; + splitFiles?: string[]; + totalDurationSec?: number; +} +``` + +Then add to `QueueItem` (after line 57 `customClip?: CustomClip;`): + +```typescript + mergeGroup?: MergeGroup; +``` + +Also add to `ApiBridge` (after line 168 `retryFailedDownloads`): + +```typescript + createMergeGroup(itemIds: string[]): Promise; +``` + +- [ ] **Step 2: Add same interfaces to `src/preload.ts`** + +After the `CustomClip` interface (line 10), before `QueueItem` (line 12), add the same `MergeGroupItem` and `MergeGroup` interfaces. + +Then add `mergeGroup?: MergeGroup;` to `QueueItem` (after line 25 `customClip?: CustomClip;`). + +- [ ] **Step 3: Add same interfaces to `src/main.ts`** + +After the `CustomClip` interface (line 154), before `QueueItem` (line 156), add the same `MergeGroupItem` and `MergeGroup` interfaces. + +Then add `mergeGroup?: MergeGroup;` to `QueueItem` (after line 172 `customClip?: CustomClip;`). + +- [ ] **Step 4: Build to verify types compile** + +Run: `npm run build` +Expected: No type errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer-globals.d.ts src/preload.ts src/main.ts +git commit -m "feat(merge-split): add MergeGroup type definitions to all interface locations" +``` + +--- + +### Task 2: Localization Strings + +**Files:** +- Modify: `src/renderer-locale-en.ts:197` +- Modify: `src/renderer-locale-de.ts:197` + +- [ ] **Step 1: Add English AND German strings (both files at once — required for TypeScript union type compatibility)** + +In `src/renderer-locale-en.ts`, after the `merge` block closing brace (line 197), add: + +```typescript + mergeGroup: { + btn: 'Merge & Split', + phaseDownloading: 'Downloading VOD', + phaseMerging: 'Merging...', + phaseSplitting: 'Splitting Part', + phaseCleanup: 'Cleaning up...', + needMinTwo: 'Select at least 2 VODs', + titleTwo: 'Merge: {title1} + {title2}', + titleMany: 'Merge: {title1} + {count} more', + metaLabel: '{count} VODs', + }, +``` + +In `src/renderer-locale-de.ts`, after the `merge` block closing brace (line 197), add: + +```typescript + mergeGroup: { + btn: 'Zusammenfugen & Splitten', + phaseDownloading: 'VOD wird heruntergeladen', + phaseMerging: 'Zusammenfugen...', + phaseSplitting: 'Part wird erstellt', + phaseCleanup: 'Aufraumen...', + needMinTwo: 'Mindestens 2 VODs auswahlen', + titleTwo: 'Merge: {title1} + {title2}', + titleMany: 'Merge: {title1} + {count} weitere', + metaLabel: '{count} VODs', + }, +``` + +**Important:** Both files must be updated before building. The `UI_TEXT` type is a union of both locale types — if they differ in structure, TypeScript will fail. + +- [ ] **Step 2: Build to verify** + +Run: `npm run build` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer-locale-en.ts src/renderer-locale-de.ts +git commit -m "feat(merge-split): add EN and DE localization strings for merge group" +``` + +--- + +### Task 3: IPC — `createMergeGroup` in Preload + Main Process Handler + +**Files:** +- Modify: `src/preload.ts:106` +- Modify: `src/main.ts:3476` (after `retry-failed-downloads` handler) + +- [ ] **Step 1: Add IPC method to preload.ts** + +After `retryFailedDownloads` (line 106), add: + +```typescript + createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds), +``` + +- [ ] **Step 2: Add IPC handler to main.ts** + +After the `retry-failed-downloads` handler (after line 3476), add: + +```typescript +ipcMain.handle('create-merge-group', (_, itemIds: string[]) => { + const selectedItems = downloadQueue.filter(item => itemIds.includes(item.id)); + + if (selectedItems.length < 2) { + return downloadQueue; + } + + // Validate all are pending + if (selectedItems.some(item => item.status !== 'pending')) { + return downloadQueue; + } + + // Sort chronologically by ISO timestamp (handles same-day different times) + const sorted = [...selectedItems].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + // Calculate total duration + const totalDurationSec = sorted.reduce((sum, item) => sum + parseDuration(item.duration_str), 0); + const totalDurationStr = (() => { + const h = Math.floor(totalDurationSec / 3600); + const m = Math.floor((totalDurationSec % 3600) / 60); + const s = totalDurationSec % 60; + const parts: string[] = []; + if (h > 0) parts.push(`${h}h`); + if (m > 0) parts.push(`${m}m`); + if (s > 0 || parts.length === 0) parts.push(`${s}s`); + return parts.join(''); + })(); + + // Generate title (language-aware) + const first = sorted[0]; + const isEnglish = config.language === 'en'; + const title = sorted.length === 2 + ? `Merge: ${first.title} + ${sorted[1].title}` + : `Merge: ${first.title} + ${sorted.length - 1} ${isEnglish ? 'more' : 'weitere'}`; + + // Build merge group + const mergeGroup: MergeGroup = { + items: sorted.map(item => ({ + url: item.url, + title: item.title, + date: item.date, + streamer: item.streamer, + duration_str: item.duration_str + })), + mergePhase: 'downloading', + currentItemIndex: 0, + downloadedFiles: {}, + totalDurationSec + }; + + // Create merged queue item + const mergedItem: QueueItem = { + id: generateQueueItemId(), + title, + url: first.url, + date: first.date, + streamer: first.streamer, + duration_str: totalDurationStr, + status: 'pending', + progress: 0, + mergeGroup + }; + + // Find position of first selected item + const firstIndex = downloadQueue.findIndex(item => itemIds.includes(item.id)); + + // Remove selected items and insert merged item at first position + downloadQueue = downloadQueue.filter(item => !itemIds.includes(item.id)); + downloadQueue.splice(firstIndex >= 0 ? Math.min(firstIndex, downloadQueue.length) : downloadQueue.length, 0, mergedItem); + + saveQueue(downloadQueue); + emitQueueUpdated(); + return downloadQueue; +}); +``` + +- [ ] **Step 3: Build to verify** + +Run: `npm run build` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/preload.ts src/main.ts +git commit -m "feat(merge-split): add createMergeGroup IPC handler" +``` + +--- + +### Task 4: Fix `mergeVideos()` Progress Bug + Add Optional `totalDurationSec` + +**Files:** +- Modify: `src/main.ts:2368-2477` + +- [ ] **Step 1: Add `totalDurationSec` parameter and ffprobe fallback** + +Change the `mergeVideos` function signature (line 2368) from: + +```typescript +async function mergeVideos( + inputFiles: string[], + outputFile: string, + onProgress: (percent: number) => void +): Promise { +``` + +to: + +```typescript +async function mergeVideos( + inputFiles: string[], + outputFile: string, + onProgress: (percent: number) => void, + totalDurationSec?: number +): Promise { +``` + +- [ ] **Step 2: Add ffprobe duration detection before the merge attempts** + +After the disk space check (after line 2406, before `const runMergeAttempt`), add: + +```typescript + // Determine total duration for accurate progress + let mergeTotalDurationUs = 0; + if (totalDurationSec && totalDurationSec > 0) { + mergeTotalDurationUs = totalDurationSec * 1_000_000; + } else { + // Fallback: use ffprobe to get total duration of all input files + const ffprobe = getFFprobePath(); + for (const filePath of inputFiles) { + try { + const result = execSync( + `"${ffprobe}" -v quiet -show_entries format=duration -of csv=p=0 "${filePath}"`, + { timeout: 10000, windowsHide: true } + ).toString().trim(); + const dur = parseFloat(result); + if (!isNaN(dur)) { + mergeTotalDurationUs += dur * 1_000_000; + } + } catch { + // If ffprobe fails, fall back to old behavior + } + } + } +``` + +- [ ] **Step 3: Fix the progress calculation inside `runMergeAttempt`** + +Replace line 2440: + +```typescript + onProgress(Math.min(99, currentUs / 10000000)); +``` + +with: + +```typescript + if (mergeTotalDurationUs > 0) { + onProgress(Math.min(99, (currentUs / mergeTotalDurationUs) * 100)); + } else { + onProgress(Math.min(99, currentUs / 10000000)); + } +``` + +- [ ] **Step 4: Also normalize Windows paths in the concat file** + +Replace line 2381: + +```typescript + const concatContent = inputFiles.map((filePath) => `file '${filePath.replace(/'/g, "'\\''")}'`).join('\n'); +``` + +with: + +```typescript + const concatContent = inputFiles.map((filePath) => { + const normalized = filePath.replace(/\\/g, '/'); + return `file '${normalized.replace(/'/g, "'\\''")}'`; + }).join('\n'); +``` + +- [ ] **Step 5: Build to verify** + +Run: `npm run build` +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/main.ts +git commit -m "fix(merge): fix progress formula for long videos, add optional totalDurationSec param, normalize Windows paths" +``` + +--- + +### Task 5: `splitMergedFile()` — FFmpeg Split Function + +**Files:** +- Modify: `src/main.ts` (add after `mergeVideos()` function, around line 2477) + +- [ ] **Step 1: Add the `splitMergedFile` function** + +After the `mergeVideos()` function (after line 2477), add: + +```typescript +// ========================================== +// SPLIT MERGED FILE +// ========================================== +async function splitMergedFile( + inputFile: string, + outputFolder: string, + partDurationSec: number, + totalDurationSec: number, + filenameGenerator: (partNum: number) => string, + onProgress: (currentPart: number, totalParts: number) => void +): Promise<{ success: boolean; files: string[] }> { + const ffmpegReady = await ensureFfmpegInstalled(); + if (!ffmpegReady) { + appendDebugLog('split-merged-missing-ffmpeg'); + return { success: false, files: [] }; + } + + const ffmpeg = getFFmpegPath(); + const numParts = Math.ceil(totalDurationSec / partDurationSec); + const splitFiles: string[] = []; + + for (let i = 0; i < numParts; i++) { + if (currentDownloadCancelled) { + return { success: false, files: splitFiles }; + } + + const startSec = i * partDurationSec; + const thisDuration = Math.min(partDurationSec, totalDurationSec - startSec); + const outputFile = path.join(outputFolder, filenameGenerator(i + 1)); + + onProgress(i + 1, numParts); + + const args = [ + '-ss', formatDuration(startSec), + '-i', inputFile, + '-t', formatDuration(thisDuration), + '-c', 'copy', + '-y', outputFile + ]; + + appendDebugLog('split-merged-part', { part: i + 1, total: numParts, startSec, duration: thisDuration }); + + const success = await new Promise((resolve) => { + const proc = spawn(ffmpeg, args, { windowsHide: true }); + currentProcess = proc; + + proc.on('close', (code) => { + currentProcess = null; + resolve(code === 0 && fs.existsSync(outputFile)); + }); + + proc.on('error', () => { + currentProcess = null; + resolve(false); + }); + }); + + if (!success) { + appendDebugLog('split-merged-part-failed', { part: i + 1, outputFile }); + return { success: false, files: splitFiles }; + } + + splitFiles.push(outputFile); + } + + return { success: true, files: splitFiles }; +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `npm run build` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/main.ts +git commit -m "feat(merge-split): add splitMergedFile() function using FFmpeg stream-copy" +``` + +--- + +### Task 6: `processDownloadMergeGroup()` — The 4-Phase Pipeline + +**Files:** +- Modify: `src/main.ts` (add before `processQueue()`, around line 2857) + +- [ ] **Step 1: Add the main pipeline function** + +Before `processQueue()` (line 2858), add: + +```typescript +// ========================================== +// MERGE GROUP DOWNLOAD PIPELINE +// ========================================== +async function processDownloadMergeGroup( + item: QueueItem, + onProgress: (progress: DownloadProgress) => void +): Promise { + const mg = item.mergeGroup!; + const totalDurationSec = mg.totalDurationSec || mg.items.reduce((sum, i) => sum + parseDuration(i.duration_str), 0); + mg.totalDurationSec = totalDurationSec; + + // ---- PHASE 1: DOWNLOADING ---- + if (mg.mergePhase === 'downloading') { + const streamlinkReady = await ensureStreamlinkInstalled(); + if (!streamlinkReady) { + return { success: false, error: 'Streamlink fehlt.' }; + } + + const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(mg.items[0].date); + const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; + const folder = path.join(config.download_path, streamer, dateStr); + fs.mkdirSync(folder, { recursive: true }); + + // Disk space pre-check: 3x total estimated size + const estimatedBytes = mg.items.reduce((sum, i) => { + const dur = parseDuration(i.duration_str); + return sum + Math.ceil(dur * 500_000); // ~500KB/s estimate + }, 0); + const requiredBytes = Math.max(256 * 1024 * 1024, estimatedBytes * 3); + const diskCheck = ensureDiskSpace(folder, requiredBytes, 'Merge-Group-Download'); + if (!diskCheck.success) { + return diskCheck; + } + + for (let i = 0; i < mg.items.length; i++) { + if (currentDownloadCancelled) { + return { success: false, error: 'Download wurde abgebrochen.' }; + } + + // Skip already downloaded files (retry recovery) + if (mg.downloadedFiles[i] && fs.existsSync(mg.downloadedFiles[i])) { + appendDebugLog('merge-group-skip-existing', { index: i, file: mg.downloadedFiles[i] }); + continue; + } + + currentDownloadCancelled = false; // Reset stale cancel state + mg.currentItemIndex = i; + mg.mergePhase = 'downloading'; + saveQueue(downloadQueue); + + const vodItem = mg.items[i]; + const tmpFilename = path.join(folder, `merge_tmp_${i}_${Date.now()}.mp4`); + + // Calculate progress weighting per VOD + const vodDuration = parseDuration(vodItem.duration_str); + const vodWeight = vodDuration / totalDurationSec; + const priorWeight = mg.items.slice(0, i).reduce((s, v) => s + parseDuration(v.duration_str), 0) / totalDurationSec; + + const result = await downloadVODPart( + vodItem.url, + tmpFilename, + null, // startTime: null = full VOD + null, // endTime: null = full VOD + (progress) => { + // Weighted progress: download phase = 0-70% + const vodProgress = progress.progress > 0 ? progress.progress : 0; + const overallProgress = (priorWeight + vodWeight * (vodProgress / 100)) * 70; + onProgress({ + ...progress, + id: item.id, + progress: overallProgress, + status: `${getMergeGroupPhaseText('downloading')} ${i + 1}/${mg.items.length} — ${progress.status}`, + currentPart: i + 1, + totalParts: mg.items.length + }); + }, + item.id, + i + 1, + mg.items.length + ); + + if (!result.success) { + return result; + } + + mg.downloadedFiles[i] = tmpFilename; + saveQueue(downloadQueue); + } + } + + // ---- PHASE 2: MERGING ---- + mg.mergePhase = 'merging'; + saveQueue(downloadQueue); + emitQueueUpdated(); + + // Check all downloaded files exist (retry recovery) + for (let i = 0; i < mg.items.length; i++) { + if (!mg.downloadedFiles[i] || !fs.existsSync(mg.downloadedFiles[i])) { + mg.mergePhase = 'downloading'; + return { success: false, error: `Heruntergeladene Datei ${i + 1} fehlt.` }; + } + } + + if (!mg.mergedFile || !fs.existsSync(mg.mergedFile)) { + const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(mg.items[0].date); + const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; + const folder = path.join(config.download_path, streamer, dateStr); + const mergedFilePath = path.join(folder, `merged_${Date.now()}.mp4`); + + // Get files in correct order (explicit sort by index) + const sortedFiles = Object.keys(mg.downloadedFiles) + .sort((a, b) => Number(a) - Number(b)) + .map(k => mg.downloadedFiles[Number(k)]); + + const mergeSuccess = await mergeVideos( + sortedFiles, + mergedFilePath, + (percent) => { + const overallProgress = 70 + (percent / 100) * 20; // merge = 70-90% + onProgress({ + id: item.id, + progress: overallProgress, + speed: '', + eta: '', + status: getMergeGroupPhaseText('merging'), + currentPart: 0, + totalParts: 0 + }); + }, + totalDurationSec + ); + + if (!mergeSuccess) { + return { success: false, error: 'FFmpeg Merge fehlgeschlagen.' }; + } + + mg.mergedFile = mergedFilePath; + saveQueue(downloadQueue); + } + + // ---- PHASE 3: SPLITTING ---- + mg.mergePhase = 'splitting'; + saveQueue(downloadQueue); + emitQueueUpdated(); + + if (currentDownloadCancelled) { + return { success: false, error: 'Download wurde abgebrochen.' }; + } + + const partDuration = config.part_minutes * 60; + const streamer = mg.items[0].streamer.replace(/[^a-zA-Z0-9_-]/g, ''); + const date = new Date(mg.items[0].date); + const dateStr = `${date.getDate().toString().padStart(2, '0')}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getFullYear()}`; + const folder = path.join(config.download_path, streamer, dateStr); + const vodId = parseVodId(mg.items[0].url) || 'merged'; + + const splitResult = await splitMergedFile( + mg.mergedFile!, + folder, + partDuration, + totalDurationSec, + (partNum: number) => { + const startSec = (partNum - 1) * partDuration; + const thisDuration = Math.min(partDuration, totalDurationSec - startSec); + return renderClipFilenameTemplate({ + template: normalizeFilenameTemplate(config.filename_template_parts, DEFAULT_FILENAME_TEMPLATE_PARTS), + title: mg.items[0].title, + vodId, + channel: mg.items[0].streamer, + date, + part: partNum, + partPadded: partNum.toString().padStart(2, '0'), + trimStartSec: startSec, + trimEndSec: startSec + thisDuration, + trimLengthSec: thisDuration, + fullLengthSec: totalDurationSec + }); + }, + (currentPart, totalParts) => { + const overallProgress = 90 + (currentPart / totalParts) * 10; // split = 90-100% + onProgress({ + id: item.id, + progress: overallProgress, + speed: '', + eta: '', + status: `${getMergeGroupPhaseText('splitting')} ${currentPart}/${totalParts}...`, + currentPart, + totalParts + }); + } + ); + + if (!splitResult.success) { + return { success: false, error: 'FFmpeg Split fehlgeschlagen.' }; + } + + mg.splitFiles = splitResult.files; + + // ---- PHASE 4: CLEANUP ---- + mg.mergePhase = 'cleanup'; + saveQueue(downloadQueue); + + // Delete individual downloads + for (const key of Object.keys(mg.downloadedFiles)) { + const filePath = mg.downloadedFiles[Number(key)]; + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch { } + } + + // Delete merged file + if (mg.mergedFile) { + try { + if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); + } catch { } + } + + mg.mergePhase = 'done'; + appendDebugLog('merge-group-complete', { + itemId: item.id, + parts: splitResult.files.length, + totalDurationSec + }); + + return { success: true }; +} +``` + +- [ ] **Step 2: Add `getMergeGroupPhaseText()` helper for language-aware main-process labels** + +Near the top of `main.ts` (around line 90, near other constants), add: + +```typescript +function getMergeGroupPhaseText(phase: string): string { + const isEnglish = config.language === 'en'; + switch (phase) { + case 'downloading': return isEnglish ? 'Downloading VOD' : 'VOD wird heruntergeladen'; + case 'merging': return isEnglish ? 'Merging...' : 'Zusammenfugen...'; + case 'splitting': return isEnglish ? 'Splitting Part' : 'Part wird erstellt'; + case 'cleanup': return isEnglish ? 'Cleaning up...' : 'Aufraumen...'; + default: return phase; + } +} +``` + +**Note:** This uses `config.language` which is available in main process. Simple switch avoids needing a full locale import. + +- [ ] **Step 3: Build to verify** + +Run: `npm run build` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/main.ts +git commit -m "feat(merge-split): add processDownloadMergeGroup() 4-phase pipeline" +``` + +--- + +### Task 7: Extend `processQueue()` to Handle Merge Groups + +**Files:** +- Modify: `src/main.ts:2858-2997` + +- [ ] **Step 1: Add merge-group branching in the download call** + +Replace line 2903: + +```typescript + const result = await downloadVOD(item, (progress) => { + mainWindow?.webContents.send('download-progress', progress); + }); +``` + +with: + +```typescript + const result = item.mergeGroup + ? await processDownloadMergeGroup(item, (progress) => { + mainWindow?.webContents.send('download-progress', progress); + }) + : await downloadVOD(item, (progress) => { + mainWindow?.webContents.send('download-progress', progress); + }); +``` + +- [ ] **Step 2: Add cleanup on merge-group removal** + +In the `remove-from-queue` handler (lines 3415-3433), **before** the `downloadQueue.filter` (line 3429) and **outside** the `if (wasActiveItem)` block, add cleanup for merge-group temp files. This ensures cleanup runs for ALL merge groups being removed (not just actively downloading ones): + +```typescript + // Clean up merge-group temp files (must run for any merge group, not just active) + const removedItem = downloadQueue.find(item => item.id === id); + if (removedItem?.mergeGroup) { + const mg = removedItem.mergeGroup; + for (const key of Object.keys(mg.downloadedFiles)) { + try { if (fs.existsSync(mg.downloadedFiles[Number(key)])) fs.unlinkSync(mg.downloadedFiles[Number(key)]); } catch { } + } + if (mg.mergedFile) { + try { if (fs.existsSync(mg.mergedFile)) fs.unlinkSync(mg.mergedFile); } catch { } + } + } +``` + +Place this after the closing brace of `if (wasActiveItem) { ... }` (line 3427) and before `downloadQueue = downloadQueue.filter(...)` (line 3429). + +- [ ] **Step 3: Preserve `mergeGroup` in retry handler** + +The existing `retry-failed-downloads` handler (line 3456-3466) already uses spread (`{ ...item }`), which preserves `mergeGroup`. No code change needed — just verify the spread preserves `mergeGroup` by inspection. The `status: 'pending'` and `progress: 0` reset is acceptable (see spec Section 5, Retry Logic). + +- [ ] **Step 4: Build to verify** + +Run: `npm run build` +Expected: No errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/main.ts +git commit -m "feat(merge-split): integrate merge-group pipeline into processQueue and cleanup handlers" +``` + +--- + +### Task 8: UI — Queue Checkboxes + Selection State + +**Files:** +- Modify: `src/renderer-shared.ts:26` +- Modify: `src/renderer-queue.ts:141-195` +- Modify: `src/styles.css:230` + +- [ ] **Step 1: Add selection state to `src/renderer-shared.ts`** + +After `let queue: QueueItem[] = [];` (line 26), add: + +```typescript +let selectedQueueIds: Set = new Set(); +``` + +- [ ] **Step 2: Add toggle function and merge-group action to `src/renderer-queue.ts`** + +Before the `renderQueue()` function (before line 141), add: + +```typescript +function toggleQueueSelection(id: string): void { + if (selectedQueueIds.has(id)) { + selectedQueueIds.delete(id); + } else { + selectedQueueIds.add(id); + } + renderQueue(); + updateMergeGroupButton(); +} + +function updateMergeGroupButton(): void { + const btn = byId('btnMergeGroup'); + if (!btn) return; + + // Clean up selections: only keep IDs that are still pending in queue + const validIds = new Set( + queue.filter(item => item.status === 'pending' && !item.mergeGroup).map(item => item.id) + ); + selectedQueueIds = new Set([...selectedQueueIds].filter(id => validIds.has(id))); + + if (selectedQueueIds.size >= 2) { + btn.style.display = ''; + btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.size})`; + btn.disabled = false; + } else { + btn.style.display = 'none'; + } +} + +async function createMergeGroupFromSelection(): Promise { + if (selectedQueueIds.size < 2) return; + + const ids = [...selectedQueueIds]; + selectedQueueIds.clear(); + queue = await window.api.createMergeGroup(ids); + renderQueue(); + updateMergeGroupButton(); +} +``` + +- [ ] **Step 3: Modify `renderQueue()` to add checkboxes** + +In the `renderQueue()` function, inside the `queue.map()` callback (around line 175-191), change the queue item HTML to add a checkbox for pending non-merge items. + +Replace the `return` template (lines 175-191) with: + +```typescript + const isMergeGroup = !!item.mergeGroup; + const showCheckbox = item.status === 'pending' && !isMergeGroup; + const isChecked = selectedQueueIds.has(item.id); + const mergeIcon = isMergeGroup + ? ' ' + : ''; + const mergeMetaExtra = isMergeGroup + ? ` (${item.mergeGroup!.items.length} VODs)` + : ''; + + return ` +
+ ${showCheckbox + ? `` + : '' + } +
+
+
+
${mergeIcon}${isClip}${safeTitle}
+
${safeStatusLabel}
+
+
${safeMeta}${mergeMetaExtra}
+
+
+
+
${safeProgressText}
+
+ x +
+ `; +``` + +- [ ] **Step 4: Add fingerprint consideration for checkboxes** + +In `getQueueRenderFingerprint()` (lines 23-38), add `mergeGroup` phase to the fingerprint. After `item.last_error || ''` (line 34), add: + +```typescript + item.mergeGroup?.mergePhase || '', +``` + +Also clear selections when queue updates from backend. At the end of `renderQueue()`, add before the final `lastQueueRenderFingerprint = renderFingerprint;`: + +```typescript + updateMergeGroupButton(); +``` + +- [ ] **Step 5: Build to verify** + +Run: `npm run build` +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer-shared.ts src/renderer-queue.ts +git commit -m "feat(merge-split): add queue checkboxes and merge-group selection UI" +``` + +--- + +### Task 9: UI — "Merge & Split" Button in HTML + CSS Styles + +**Files:** +- Modify: `src/index.html:214-218` +- Modify: `src/styles.css:332` + +- [ ] **Step 1: Add the button to queue actions in `src/index.html`** + +After the Start button (line 215), add the merge-group button: + +```html + +``` + +- [ ] **Step 2: Add CSS styles to `src/styles.css`** + +After `.queue-item .remove:hover` (line 330), add: + +```css +.queue-checkbox { + width: 14px; + height: 14px; + margin-top: 2px; + cursor: pointer; + accent-color: var(--accent); + flex-shrink: 0; +} + +.queue-item.merge-group { + border-left: 3px solid var(--accent); +} + +.merge-group-icon { + vertical-align: middle; + margin-right: 2px; + opacity: 0.8; +} + +.btn-merge-group { + background: var(--accent); + color: var(--bg-primary); +} + +.btn-merge-group:hover { + opacity: 0.9; +} +``` + +- [ ] **Step 3: Build and visually verify** + +Run: `npm start` +Expected: App launches. Add 2+ VODs to queue (they show as pending). Checkboxes appear next to pending items. Select 2 → "Merge & Split (2)" button appears. Click → creates merge group item with accent border and merge icon. + +- [ ] **Step 4: Commit** + +```bash +git add src/index.html src/styles.css +git commit -m "feat(merge-split): add Merge & Split button and queue checkbox/merge-group styles" +``` + +--- + +### Task 10: Unit Tests — Merge-Split Logic + +**Files:** +- Create: `scripts/smoke-test-merge-split-logic.js` +- Modify: `package.json` (add test script) + +- [ ] **Step 1: Create the test file** + +Create `scripts/smoke-test-merge-split-logic.js`: + +```javascript +const path = require('path'); + +// Load compiled modules +const mainModule = path.join(process.cwd(), 'dist', 'main.js'); + +function run() { + const failures = []; + const assert = (condition, message) => { + if (!condition) failures.push(message); + }; + + // ---- Test 1: parseDuration summation ---- + // Simulate parseDuration logic (same as main.ts:1114-1125) + function parseDuration(duration) { + let seconds = 0; + const hours = duration.match(/(\d+)h/); + const minutes = duration.match(/(\d+)m/); + const secs = duration.match(/(\d+)s/); + if (hours) seconds += parseInt(hours[1]) * 3600; + if (minutes) seconds += parseInt(minutes[1]) * 60; + if (secs) seconds += parseInt(secs[1]); + return seconds; + } + + const vods = [ + { duration_str: '2h30m0s' }, + { duration_str: '1h45m30s' } + ]; + const totalDuration = vods.reduce((sum, v) => sum + parseDuration(v.duration_str), 0); + assert(totalDuration === 15330, `Duration sum: expected 15330, got ${totalDuration}`); + + // ---- Test 2: Chronological sort by ISO timestamp ---- + const items = [ + { date: '2026-03-01T18:00:00Z', title: 'Evening' }, + { date: '2026-03-01T16:00:00Z', title: 'Afternoon' }, + { date: '2026-03-02T10:00:00Z', title: 'Next Day' } + ]; + const sorted = [...items].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + assert(sorted[0].title === 'Afternoon', `Sort[0]: expected Afternoon, got ${sorted[0].title}`); + assert(sorted[1].title === 'Evening', `Sort[1]: expected Evening, got ${sorted[1].title}`); + assert(sorted[2].title === 'Next Day', `Sort[2]: expected Next Day, got ${sorted[2].title}`); + + // ---- Test 3: Same day, different times ---- + const sameDay = [ + { date: '2026-03-01T18:30:00Z', title: 'Later' }, + { date: '2026-03-01T16:15:00Z', title: 'Earlier' } + ]; + const sortedSameDay = [...sameDay].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + assert(sortedSameDay[0].title === 'Earlier', `SameDay[0]: expected Earlier, got ${sortedSameDay[0].title}`); + assert(sortedSameDay[1].title === 'Later', `SameDay[1]: expected Later, got ${sortedSameDay[1].title}`); + + // ---- Test 4: Merge group title generation ---- + function makeMergeTitle(items) { + if (items.length === 2) return `Merge: ${items[0].title} + ${items[1].title}`; + return `Merge: ${items[0].title} + ${items.length - 1} weitere`; + } + assert( + makeMergeTitle([{ title: 'A' }, { title: 'B' }]) === 'Merge: A + B', + 'Title 2 items failed' + ); + assert( + makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }]) === 'Merge: A + 2 weitere', + 'Title 3 items failed' + ); + + // ---- Test 5: Progress weighting (70/20/10) ---- + // Download phase: 2 VODs, first=60min, second=120min, total=180min + // During VOD 2 at 50%: + const totalSec = 10800; // 180min + const vod1Dur = 3600; // 60min + const vod2Dur = 7200; // 120min + const vod1Weight = vod1Dur / totalSec; // 0.333 + const vod2Weight = vod2Dur / totalSec; // 0.667 + const priorWeight = vod1Weight; // VOD 1 done + const vodProgress = 50; // 50% + const overallProgress = (priorWeight + vod2Weight * (vodProgress / 100)) * 70; + // = (0.333 + 0.667 * 0.5) * 70 = (0.333 + 0.333) * 70 = 0.667 * 70 = 46.67 + assert( + Math.abs(overallProgress - 46.67) < 0.1, + `Progress weighting: expected ~46.67, got ${overallProgress}` + ); + + // ---- Test 6: Split part count ---- + const partMinutes = 60; + const mergedDuration = 15330; // 4h15m30s + const numParts = Math.ceil(mergedDuration / (partMinutes * 60)); + assert(numParts === 5, `Split parts: expected 5, got ${numParts}`); + + // ---- Test 7: Object.keys explicit sort for downloadedFiles ---- + const downloadedFiles = { 2: '/path/c.mp4', 0: '/path/a.mp4', 1: '/path/b.mp4' }; + const sortedPaths = Object.keys(downloadedFiles) + .sort((a, b) => Number(a) - Number(b)) + .map(k => downloadedFiles[Number(k)]); + assert(sortedPaths[0] === '/path/a.mp4', `Sort files[0]: expected a.mp4, got ${sortedPaths[0]}`); + assert(sortedPaths[1] === '/path/b.mp4', `Sort files[1]: expected b.mp4, got ${sortedPaths[1]}`); + assert(sortedPaths[2] === '/path/c.mp4', `Sort files[2]: expected c.mp4, got ${sortedPaths[2]}`); + + // ---- Test 8: FFmpeg split args order (-ss before -i) ---- + function buildSplitArgs(startSec, inputFile, durationSec) { + const formatDur = (s) => { + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = Math.floor(s % 60); + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; + }; + return ['-ss', formatDur(startSec), '-i', inputFile, '-t', formatDur(durationSec), '-c', 'copy', '-y', 'out.mp4']; + } + const args = buildSplitArgs(3600, 'input.mp4', 3600); + const ssIndex = args.indexOf('-ss'); + const iIndex = args.indexOf('-i'); + assert(ssIndex < iIndex, `FFmpeg args: -ss (${ssIndex}) must be before -i (${iIndex})`); + + // ---- Results ---- + if (failures.length > 0) { + console.error(`FAIL: ${failures.length} test(s) failed:`); + failures.forEach(f => console.error(` - ${f}`)); + process.exit(1); + } + + console.log('All merge-split logic tests passed!'); + process.exit(0); +} + +run(); +``` + +- [ ] **Step 2: Add test script to `package.json`** + +Add to the `scripts` section: + +```json +"test:merge-split": "node scripts/smoke-test-merge-split-logic.js", +``` + +- [ ] **Step 3: Run the tests** + +Run: `npm run test:merge-split` +Expected: `All merge-split logic tests passed!` + +- [ ] **Step 4: Commit** + +```bash +git add scripts/smoke-test-merge-split-logic.js package.json +git commit -m "test(merge-split): add unit tests for merge-split logic" +``` + +--- + +### Task 11: Integration Test — Build + Smoke Test + +**Files:** None new — uses existing test infrastructure + +- [ ] **Step 1: Full build** + +Run: `npm run build` +Expected: Clean build, no errors. + +- [ ] **Step 2: Run existing unit tests** + +Run: `npm run test:e2e:update-logic` +Expected: All tests pass (no regressions). + +- [ ] **Step 3: Run merge-split unit tests** + +Run: `npm run test:merge-split` +Expected: All tests pass. + +- [ ] **Step 4: Run smoke test** + +Run: `npm run test:e2e` +Expected: Smoke test passes (app launches, basic functionality works). + +- [ ] **Step 5: Commit all remaining changes if any** + +```bash +git add -A +git commit -m "chore(merge-split): verify build and all tests pass" +``` + +--- + +### Task 12: Manual Verification Checklist + +**No code changes — verification only.** + +- [ ] **Step 1: Launch app** + +Run: `npm start` + +- [ ] **Step 2: Add 2+ VODs to queue** + +Add at least 2 VODs from a streamer. Verify both show as "pending" with checkboxes visible. + +- [ ] **Step 3: Select 2 VODs** + +Check both checkboxes. Verify "Merge & Split (2)" button appears. + +- [ ] **Step 4: Create merge group** + +Click "Merge & Split". Verify: +- Individual items are replaced by a single merge-group item +- Title shows "Merge: VOD1 + VOD2" +- Accent border on left side +- Merge icon visible +- Duration shows combined total +- Status is "Waiting" + +- [ ] **Step 5: Start download** + +Click Start. Verify: +- Phase text shows "VOD 1/2 wird heruntergeladen" +- Progress bar advances +- After VOD 1: "VOD 2/2 wird heruntergeladen" +- After both: "Zusammenfugen..." +- After merge: "Part 1/N wird erstellt..." +- After split: "Done" at 100% + +- [ ] **Step 6: Verify output files** + +Check the download folder. Verify: +- Only the split part files exist (Part1.mp4, Part2.mp4, etc.) +- No `merge_tmp_*.mp4` files remain +- No `merged_*.mp4` file remains + +- [ ] **Step 7: Test error recovery (optional)** + +Pause during download, then retry. Verify it resumes correctly.