# 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.