Twitch-VOD-Manager/docs/superpowers/plans/2026-03-19-vod-merge-split.md
xRangerDE 6aae84cac7 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) <noreply@anthropic.com>
2026-03-19 17:10:15 +01:00

1230 lines
40 KiB
Markdown

# 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<number, string>;
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<QueueItem[]>;
```
- [ ] **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<boolean> {
```
to:
```typescript
async function mergeVideos(
inputFiles: string[],
outputFile: string,
onProgress: (percent: number) => void,
totalDurationSec?: number
): Promise<boolean> {
```
- [ ] **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<boolean>((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<DownloadResult> {
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<string> = 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<HTMLButtonElement>('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<void> {
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
? '<svg class="merge-group-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
: '';
const mergeMetaExtra = isMergeGroup
? ` (${item.mergeGroup!.items.length} VODs)`
: '';
return `
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}">
${showCheckbox
? `<input type="checkbox" class="queue-checkbox" ${isChecked ? 'checked' : ''} onchange="toggleQueueSelection('${item.id}')" />`
: ''
}
<div class="status ${item.status}"></div>
<div class="queue-main">
<div class="queue-title-row">
<div class="title" title="${safeTitle}">${mergeIcon}${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div>
</div>
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
<div class="queue-progress-wrap">
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
</div>
<div class="queue-progress-text">${safeProgressText}</div>
</div>
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
</div>
`;
```
- [ ] **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
<button class="btn btn-merge-group" id="btnMergeGroup" onclick="createMergeGroupFromSelection()" style="display:none">Merge &amp; Split</button>
```
- [ ] **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.