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>
1230 lines
40 KiB
Markdown
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 & 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.
|