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>
40 KiB
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:
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;):
mergeGroup?: MergeGroup;
Also add to ApiBridge (after line 168 retryFailedDownloads):
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
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:
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:
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
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(afterretry-failed-downloadshandler) -
Step 1: Add IPC method to preload.ts
After retryFailedDownloads (line 106), add:
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:
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
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
totalDurationSecparameter and ffprobe fallback
Change the mergeVideos function signature (line 2368) from:
async function mergeVideos(
inputFiles: string[],
outputFile: string,
onProgress: (percent: number) => void
): Promise<boolean> {
to:
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:
// 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:
onProgress(Math.min(99, currentUs / 10000000));
with:
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:
const concatContent = inputFiles.map((filePath) => `file '${filePath.replace(/'/g, "'\\''")}'`).join('\n');
with:
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
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 aftermergeVideos()function, around line 2477) -
Step 1: Add the
splitMergedFilefunction
After the mergeVideos() function (after line 2477), add:
// ==========================================
// 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
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 beforeprocessQueue(), around line 2857) -
Step 1: Add the main pipeline function
Before processQueue() (line 2858), add:
// ==========================================
// 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:
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
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:
const result = await downloadVOD(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
});
with:
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):
// 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
mergeGroupin 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
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:
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:
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:
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:
item.mergeGroup?.mergePhase || '',
Also clear selections when queue updates from backend. At the end of renderQueue(), add before the final lastQueueRenderFingerprint = renderFingerprint;:
updateMergeGroupButton();
- Step 5: Build to verify
Run: npm run build
Expected: No errors.
- Step 6: Commit
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:
<button class="btn btn-merge-group" id="btnMergeGroup" onclick="createMergeGroupFromSelection()" style="display:none">Merge & Split</button>
- Step 2: Add CSS styles to
src/styles.css
After .queue-item .remove:hover (line 330), add:
.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
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:
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:
"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
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
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_*.mp4files remain -
No
merged_*.mp4file remains -
Step 7: Test error recovery (optional)
Pause during download, then retry. Verify it resumes correctly.