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

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 (after retry-failed-downloads handler)

  • 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 totalDurationSec parameter 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 after mergeVideos() function, around line 2477)

  • Step 1: Add the splitMergedFile function

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 before processQueue(), 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 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
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 &amp; 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_*.mp4 files remain

  • No merged_*.mp4 file remains

  • Step 7: Test error recovery (optional)

Pause during download, then retry. Verify it resumes correctly.