Compare commits

..

14 Commits

Author SHA1 Message Date
xRangerDE
3af159f8e7 release: 4.3.0 add VOD merge+split feature
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:23:40 +01:00
xRangerDE
6c082a87ab fix(merge-split): fix premature 100% progress, add ffmpeg preflight, clean partial splits, use locale metaLabel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:12:50 +01:00
xRangerDE
30c94b550e test(merge-split): add unit tests for merge-split logic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:57:21 +01:00
xRangerDE
ad4e540952 feat(merge-split): add queue checkboxes and merge-group selection UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:55:33 +01:00
xRangerDE
c1a72ebd66 feat(merge-split): add Merge & Split button and queue checkbox/merge-group styles
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:55:14 +01:00
xRangerDE
409c976df0 feat(merge-split): integrate merge-group pipeline into processQueue and cleanup handlers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:53:55 +01:00
xRangerDE
8501bd17f7 feat(merge-split): add processDownloadMergeGroup() 4-phase pipeline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:52:41 +01:00
xRangerDE
03f47a7240 feat(merge-split): add splitMergedFile() function using FFmpeg stream-copy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:49:15 +01:00
xRangerDE
5a20c1c6a4 fix(merge): fix progress formula for long videos, add optional totalDurationSec param, normalize Windows paths
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:48:02 +01:00
xRangerDE
645d2f147b feat(merge-split): add createMergeGroup IPC handler
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:47:36 +01:00
xRangerDE
4750af2f97 feat(merge-split): add MergeGroup type definitions to all interface locations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:46:20 +01:00
xRangerDE
03c6e68da0 feat(merge-split): add EN and DE localization strings for merge group
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:46:20 +01:00
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
xRangerDE
1abc87d17d docs: add VOD merge+split feature design spec
Comprehensive design for combining multiple queue VODs into a merge group,
downloading them, merging via FFmpeg, and splitting into time-based parts.
Reviewed through two spec-review iterations fixing 14 issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:56:38 +01:00
14 changed files with 2400 additions and 14 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,432 @@
# VOD Merge+Split Feature — Design Spec
**Date:** 2026-03-19
**Status:** Approved
**Revision:** 3 (second spec-review fixes)
## Summary
Allow users to select 2+ pending VODs in the download queue, combine them into a single "Merge Group" queue item, and have the system automatically: download all VODs, merge them chronologically via FFmpeg, split the merged result into time-based parts (using the existing `part_minutes` setting), and clean up all temporary files.
## Requirements
1. User selects 2+ pending queue items via checkboxes
2. Clicking "Merge & Split" creates a single merge-group queue item
3. VODs are auto-sorted chronologically by full ISO timestamp (date + time)
4. The entire download → merge → split → cleanup runs as one automated job
5. Part naming uses the existing `filename_template_parts` setting
6. Date for naming is taken from the first (earliest) VOD
7. Temporary files (individual downloads, merged file) are deleted after successful split
8. On failure, the entire group fails; retry resumes from where it left off
---
## 1. Data Model
### New Interfaces
```typescript
interface MergeGroupItem {
url: string;
title: string;
date: string; // Full ISO timestamp from Twitch API (includes time)
streamer: string;
duration_str: string;
}
interface MergeGroup {
items: MergeGroupItem[]; // VODs, sorted chronologically by date
mergePhase: 'downloading' | 'merging' | 'splitting' | 'cleanup' | 'done';
currentItemIndex: number; // Index of VOD currently being downloaded
downloadedFiles: Record<number, string>; // Sparse map: VOD index → file path (not array)
mergedFile?: string; // Path of concatenated file
splitFiles?: string[]; // Paths of final split parts
totalDurationSec?: number; // Cached total duration for progress calculation
}
```
**Note on `downloadedFiles`:** Uses `Record<number, string>` (sparse map keyed by VOD index) instead of `string[]` to avoid ambiguity during retry. On retry, `downloadedFiles[i]` is checked — if the key exists AND the file exists on disk, that VOD is skipped. A dense array would cause index misalignment when earlier downloads succeed but later ones fail.
### QueueItem Extension
```typescript
interface QueueItem {
// ... all existing fields unchanged ...
mergeGroup?: MergeGroup; // Present → this is a merge group
}
```
**Important:** The `mergeGroup` field must be added to ALL THREE copies of the `QueueItem` interface:
- `src/main.ts` (lines ~156-173)
- `src/preload.ts` (lines ~12-26)
- `src/renderer-globals.d.ts` (lines ~40-58)
**Type sharing strategy:** The `MergeGroup` and `MergeGroupItem` interfaces must be defined in ALL locations where `QueueItem` is defined:
- **`src/main.ts`**: Full interface definitions (canonical source)
- **`src/renderer-globals.d.ts`**: Full interface definitions (for renderer TypeScript)
- **`src/preload.ts`**: Full interface definitions (for preload TypeScript — required because `preload.ts` has its own copy of `QueueItem` and TypeScript strict mode requires `MergeGroup` to be in scope)
This follows the existing pattern: `QueueItem` and `CustomClip` are already duplicated across all three files.
### Conventions
- When `mergeGroup` is set, the top-level `url`, `title`, `date`, `streamer`, `duration_str` are populated from the **first** (chronologically earliest) item in the group, for backwards compatibility with existing queue code.
- `title` is generated: `"Merge: {title1} + {title2}"` (3+ items: `"Merge: {title1} + {n-1} weitere"`)
- `duration_str` is the sum of all individual durations.
- `url` is set to the first VOD's URL (used as identifier only, not for downloading — each item in `mergeGroup.items` has its own URL).
---
## 2. Download Flow — `processDownloadMergeGroup()`
A new function handling the 4-phase pipeline. Called from `processQueue()` when `item.mergeGroup` is present.
**Critical: Download mode override.** Merge group VODs are ALWAYS downloaded in full-file mode, regardless of the user's `config.download_mode` setting. This ensures each VOD produces exactly one file. The user's parts setting is only applied during Phase 3 (splitting the merged result). Without this override, `download_mode === 'parts'` would produce multiple files per VOD, breaking the merge pipeline.
**Implementation approach:** `processDownloadMergeGroup()` does NOT call `downloadVOD()` (which reads `config.download_mode` from the global config and would split into parts). Instead, it calls `downloadVODPart()` directly with `startTime=null, endTime=null` (meaning "download the entire VOD as one file"). The merge-group function replicates the necessary setup from `downloadVOD()` itself:
- Tool verification (`ensureStreamlinkInstalled()`, `ensureFfmpegInstalled()`)
- Output folder creation (`ensureDownloadFolder()`)
- Disk space pre-check (`ensureDiskSpace()` with 3x estimate)
- Filename generation (using first VOD's date, streamer, and a temp naming scheme like `merge_tmp_{vodIndex}_{timestamp}.mp4`)
This avoids mutating the global `config.download_mode` (which would be a race condition in async code) and avoids refactoring `downloadVOD()` to accept an override parameter.
### Phase 1: Downloading (`mergePhase: 'downloading'`)
```
For each VOD in mergeGroup.items (chronological order):
0. Reset currentDownloadCancelled = false (CRITICAL: prevents stale cancel state
from a prior VOD or previous retry from aborting the current download)
1. Set currentItemIndex = i
2. Skip if downloadedFiles[i] exists in map AND file exists on disk (retry recovery)
3. Call downloadVODPart() directly with startTime=null, endTime=null
(bypasses downloadVOD() which checks config.download_mode)
5. Store resulting file path: downloadedFiles[i] = path
6. Report weighted progress: each VOD's share proportional to its duration
7. On error → entire group status = 'error', phase stays 'downloading'
8. saveQueue() after each completed VOD (crash recovery)
```
### Phase 2: Merging (`mergePhase: 'merging'`)
```
1. Use existing mergeVideos() function (FFmpeg concat demuxer)
BUT pass totalDurationSec so progress can be calculated correctly
2. Input: files sorted explicitly by index:
Object.keys(downloadedFiles).sort((a, b) => Number(a) - Number(b)).map(k => downloadedFiles[Number(k)])
(Do NOT rely on implicit Object.values() ordering — VOD order is correctness-critical)
3. Output: temporary file "merged_{timestamp}.mp4" in download directory
4. Two-stage strategy: stream copy first, re-encode fallback
5. Progress: mergeVideos() must use actual total duration for correct percentage:
percent = (out_time_us / (totalDurationSec * 1_000_000)) * 100
(The existing formula `currentUs / 10_000_000` is a bug — it caps at 99% after
16.5 minutes regardless of actual file length. Must be fixed.)
6. On error → status = 'error', phase stays 'merging'
7. Store path in mergeGroup.mergedFile, saveQueue()
```
**Note on existing mergeVideos() progress bug and signature change:** The current implementation at main.ts:2440 uses `Math.min(99, currentUs / 10000000)` which hits 99% after ~16 minutes. The fix:
- Add an **optional** `totalDurationSec?: number` parameter to `mergeVideos()` (4th parameter after `onProgress`)
- When provided: `percent = Math.min(99, (currentUs / (totalDurationSec * 1_000_000)) * 100)`
- When NOT provided (standalone Merge Videos tab): use `ffprobe` to determine total duration of all input files before starting the merge. This is a lightweight call (~100ms) that runs once before the merge begins.
- The existing IPC handler `merge-videos` does NOT need signature changes — it calls `mergeVideos()` internally and simply omits the `totalDurationSec` parameter, triggering the ffprobe fallback.
- `processDownloadMergeGroup()` passes `totalDurationSec` directly (already computed from VOD durations), skipping the ffprobe call.
**Note on Windows path escaping:** The existing `mergeVideos()` function uses Unix-style single-quote escaping (`'\\''`) in the FFmpeg concat file. This is a pre-existing issue that should be verified on Windows during testing. FFmpeg's concat demuxer on Windows typically handles forward-slash paths correctly, so the implementation should normalize backslashes to forward slashes in the concat file.
### Phase 3: Splitting (`mergePhase: 'splitting'`)
```
1. Calculate total duration from mergeGroup.items (or use cached totalDurationSec)
2. Calculate numParts = ceil(totalDuration / (config.part_minutes * 60))
3. For each part:
ffmpeg -ss <startSec> -i mergedFile -t <partDuration> -c copy -y part_X.mp4
(Note: -ss BEFORE -i for fast seeking, matching existing cutVideo() pattern)
4. Naming via existing filename_template_parts, date = first VOD's date
5. Progress: simple part counter (copy mode with fast-seek is near-instant)
6. Store paths in mergeGroup.splitFiles
7. On error → status = 'error', phase stays 'splitting'
```
**Split approach:** `-c copy` (stream copy, no re-encoding) with `-ss` before `-i` (fast seek)
- Near-instant execution (fast seek skips directly to the target position)
- Quality-lossless
- Minor keyframe-boundary imprecision at split points (irrelevant for 60+ min parts)
- Matches the existing `cutVideo()` pattern at main.ts:2303-2306
### Phase 4: Cleanup (`mergePhase: 'cleanup'`)
```
1. Delete all individual downloads: all values from downloadedFiles
2. Delete merged file: mergedFile
3. Only split parts remain on disk
4. Set mergePhase = 'done', status = 'completed', progress = 100
5. saveQueue()
```
### Progress Weighting
| Phase | Weight | Display |
|-------------|--------|--------------------------------------------|
| Downloading | 70% | "VOD 1/2 wird heruntergeladen — 45%" |
| Merging | 20% | "Zusammenfugen... 60%" |
| Splitting | 10% | "Part 2/5 wird erstellt..." |
| Cleanup | 0% | "Aufraumen..." (instant) |
Overall progress = phase_base + (phase_progress × phase_weight)
Example for 2 VODs: During download of VOD 2 at 50%:
- VOD 1 complete = 35% (50% of 70%)
- VOD 2 at 50% = 17.5% (50% of remaining 35%)
- Total: 52.5%
---
## 3. UI Changes
### 3a) Queue Item Checkboxes
- Each queue item gets a checkbox to the left of the status indicator
- Checkbox only visible/enabled for items with `status === 'pending'`
- Hidden for: downloading, paused, completed, error items
- Hidden for items that are already merge groups
### 3b) "Merge & Split" Button
- Appears in the queue action bar when 2+ checkboxes are checked
- Label: "Merge & Split (N)" where N = number of selected items
- Clicking it:
1. Collects selected items
2. Sorts them chronologically by `date` (ISO timestamp comparison)
3. Creates a new merge-group QueueItem
4. Removes the individual items from the queue
5. Adds the merge-group item in their place
6. Clears all checkboxes
7. Calls `window.api.createMergeGroup(selectedIds)` → main process
### 3c) Merge Group Rendering
A merge group in the queue looks like a regular item but visually distinct:
```
┌─────────────────────────────────────────────────┐
│ [merge-icon] Merge: VOD Title 1 + VOD Title 2 │
│ 4h15m (2 VODs) | 01.03.2026 Running │
│ VOD 1/2 wird heruntergeladen — 45.0% │
│ ████████████░░░░░░░░░░░░░░░░░ 45% │
│ [x]│
└─────────────────────────────────────────────────┘
```
- Merge icon via CSS/SVG (chain-link or similar), no emoji
- Meta line shows total duration and VOD count
- Progress text shows current phase with detail
- Remove button cancels and cleans up all temp files
### 3d) Localization Strings
**English (`renderer-locale-en.ts`):**
```typescript
mergeGroup: {
btn: 'Merge & Split',
phaseDownloading: 'Downloading VOD {current}/{total}',
phaseMerging: 'Merging...',
phaseSplitting: 'Splitting Part {current}/{total}...',
phaseCleanup: 'Cleaning up...',
titleTwo: 'Merge: {title1} + {title2}',
titleMany: 'Merge: {title1} + {count} more',
needMinTwo: 'Select at least 2 VODs',
metaLabel: '{duration} ({count} VODs)',
}
```
**German (`renderer-locale-de.ts`):**
```typescript
mergeGroup: {
btn: 'Zusammenfugen & Splitten',
phaseDownloading: 'VOD {current}/{total} wird heruntergeladen',
phaseMerging: 'Zusammenfugen...',
phaseSplitting: 'Part {current}/{total} wird erstellt...',
phaseCleanup: 'Aufraumen...',
titleTwo: 'Merge: {title1} + {title2}',
titleMany: 'Merge: {title1} + {count} weitere',
needMinTwo: 'Mindestens 2 VODs auswahlen',
metaLabel: '{duration} ({count} VODs)',
}
```
---
## 4. IPC Interface
### New IPC Channel: `create-merge-group`
```typescript
// Preload
createMergeGroup: (itemIds: string[]): Promise<QueueItem[]> =>
ipcRenderer.invoke('create-merge-group', itemIds),
// Main process handler
ipcMain.handle('create-merge-group', async (_, itemIds: string[]) => {
// 1. Find items in downloadQueue by IDs
// 2. Validate all are 'pending'
// 3. Sort chronologically by date (ISO timestamp — handles same-day different times)
// 4. Create MergeGroup object with downloadedFiles = {}
// 5. Create new QueueItem with mergeGroup field
// - url = first item's url
// - title = generated merge title
// - date = first item's date
// - duration_str = sum of durations
// 6. Remove individual items from queue
// 7. Insert merge-group item at position of first removed item
// 8. saveQueue() and emit 'queue-updated'
// 9. Return updated queue
});
```
No other new IPC channels needed — progress reuses existing `download-progress` event.
---
## 5. Error Handling
| Scenario | Behavior |
|----------|----------|
| VOD download fails | Entire group → `error`. Retry skips already-downloaded files. |
| FFmpeg merge fails | Group → `error`, phase stays `merging`. Retry re-attempts merge. |
| FFmpeg split fails | Group → `error`, phase stays `splitting`. Retry checks if merged file exists. |
| Disk space insufficient | Pre-check before download. Estimate: **3x** sum of VOD sizes needed (downloads + merged + split parts all on disk simultaneously before cleanup). |
| User deletes group during download | Cancel running download, delete all temp files. |
| App crash / restart | Queue persisted with `mergeGroup` data. Retry resumes from last phase. |
| VODs from different streamers | Allowed. Naming uses first VOD's streamer. |
| Different qualities/resolutions | FFmpeg re-encode fallback handles codec mismatches. |
### Disk Space Calculation
Peak disk usage occurs just before cleanup, when all three file sets coexist:
- Individual downloads: ~1x total VOD size
- Merged file: ~1x total VOD size
- Split parts: ~1x total VOD size
- **Total: ~3x sum of VOD sizes**
The pre-check uses `3x * estimated_total_size` with a minimum of 256 MB.
### Retry Logic
```
On retry of a merge-group item:
status is reset to 'pending', progress to 0
mergeGroup.mergePhase and mergeGroup.downloadedFiles are PRESERVED
processDownloadMergeGroup() checks mergePhase on entry:
if mergePhase === 'downloading':
For each i in items:
if downloadedFiles[i] exists AND file on disk → skip
else → download and store
Then proceed to merging phase
if mergePhase === 'merging':
Check if all downloadedFiles values exist on disk
If any missing → reset mergePhase to 'downloading', restart
If all present → re-attempt merge
Then proceed to splitting phase
if mergePhase === 'splitting':
Check if mergedFile exists on disk
If missing → check downloadedFiles → reset to appropriate phase
If present → re-attempt split
Then proceed to cleanup
Progress is recalculated from phase on first tick of processDownloadMergeGroup():
'downloading' → base = 0% (count already-downloaded files for offset)
'merging' → base = 70%
'splitting' → base = 90%
Note: retryFailedDownloads resets item.progress to 0. There will be a brief
0% flash in the UI between retry-click and processDownloadMergeGroup() starting.
This is a minor UX imperfection (< 1 second) acceptable since the progress
corrects itself immediately when processing begins. Fixing it would require
special-casing merge groups in retryFailedDownloads, adding complexity for
negligible benefit.
```
### Cancel/Remove During Active Merge Group
```
When user removes a merge-group item during processing:
1. Set currentDownloadCancelled = true (kills active streamlink/ffmpeg process)
2. Wait for process to exit
3. Delete all temp files:
- All files in downloadedFiles (if they exist)
- mergedFile (if it exists)
- Any partial output files
4. Remove item from queue
```
---
## 6. Testing Strategy
### Unit Tests
- Duration summation across multiple VODs
- Chronological sorting by ISO timestamp (same day, different times: 16:00 vs 18:00)
- Merge group title generation (2 items, 3+ items)
- Progress weighting calculation (70/20/10)
- Retry logic: phase detection, file existence checks
- FFmpeg split argument construction (verify `-ss` before `-i`)
- `currentDownloadCancelled` reset between VOD downloads
### Integration Tests
- Create merge group via IPC → verify queue structure
- Persist merge group → reload → verify mergeGroup data intact (including `downloadedFiles` as Record)
- FFmpeg concat file generation (correct paths, escaping, forward slashes on Windows)
- `download_mode` override: verify merge group downloads use 'full' mode regardless of config
### E2E Tests (Playwright)
- Checkbox visibility: only on pending items
- Button appears/disappears based on selection count
- Merge group renders with correct title, meta, and phase text
- Localization: all new strings present in EN and DE
### Manual Test
- 2 short VODs → Merge & Split with 5 min parts → verify parts created, temp files deleted, progress correct
---
## 7. Files to Modify
| File | Changes |
|------|---------|
| `src/main.ts` | Add `processDownloadMergeGroup()`, `splitMergedFile()`, IPC handler `create-merge-group`, extend `processQueue()` branching, fix `mergeVideos()` progress formula (pass totalDurationSec), force `download_mode='full'` for merge groups, reset `currentDownloadCancelled` per sub-download |
| `src/preload.ts` | Add `createMergeGroup` to API surface, add `mergeGroup?: MergeGroup` to QueueItem interface |
| `src/renderer-queue.ts` | Add checkbox rendering, selection state, "Merge & Split" button, merge-group display logic, phase-aware progress text |
| `src/renderer-shared.ts` | Add `selectedQueueIds: Set<string>` global state |
| `src/renderer-locale-en.ts` | Add `mergeGroup` string block |
| `src/renderer-locale-de.ts` | Add `mergeGroup` string block |
| `src/renderer-globals.d.ts` | Add `MergeGroup`, `MergeGroupItem` interfaces, extend `QueueItem` with `mergeGroup?` |
| `src/index.html` | Add merge-group button to queue action bar, CSS for checkboxes and merge-group items |
---
## 8. Review Changelog
### Revision 2 (first spec review — 9 issues fixed):
1. **`currentDownloadCancelled` reset** — Added explicit reset to `false` before each VOD download in Phase 1
2. **Windows path escaping** — Added note to normalize backslashes to forward slashes in concat file
3. **`mergeVideos()` progress bug** — Fixed: pass `totalDurationSec` for correct percentage calculation
4. **Retry progress reset** — Defined phase-based progress recalculation on retry resume
5. **`-ss` after `-i` slow seeking** — Fixed: `-ss` before `-i` matching existing `cutVideo()` pattern
6. **`downloadedFiles` indexing** — Changed from `string[]` to `Record<number, string>` (sparse map by VOD index)
7. **Missing `mergeGroup` in preload.ts** — Added to files-to-modify table
8. **Disk space estimate** — Changed from 2.2x to 3x to account for all three file sets coexisting
9. **`download_mode` conflict** — Force `download_mode = 'full'` for merge group downloads
### Revision 3 (second spec review — 5 issues fixed):
10. **`download_mode` override race condition** — Changed approach: call `downloadVODPart()` directly instead of `downloadVOD()`, avoiding global config mutation. Merge-group function replicates necessary setup (tool checks, folder, disk space, filename).
11. **`mergeVideos()` signature change breaks standalone IPC** — Made `totalDurationSec` an optional parameter with `ffprobe` fallback for the standalone Merge Videos tab. Existing IPC surface unchanged.
12. **Retry progress 0% flash** — Acknowledged as minor UX gap (< 1 second). Documenting rather than adding complexity to fix.
13. **`MergeGroup` types not in preload.ts scope** — Added explicit type-sharing strategy: duplicate interfaces in all three files (follows existing `QueueItem`/`CustomClip` pattern).
14. **`Object.values()` implicit key ordering** — Changed to explicit sort: `Object.keys().sort().map()`.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.2.5", "version": "4.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.2.5", "version": "4.3.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "twitch-vod-manager", "name": "twitch-vod-manager",
"version": "4.2.5", "version": "4.3.0",
"description": "Twitch VOD Manager - Download Twitch VODs easily", "description": "Twitch VOD Manager - Download Twitch VODs easily",
"main": "dist/main.js", "main": "dist/main.js",
"author": "xRangerDE", "author": "xRangerDE",
@ -17,7 +17,8 @@
"pack": "npm run build && electron-builder --dir", "pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder", "dist": "npm run build && electron-builder",
"dist:win": "npm run test:e2e:release && electron-builder --win", "dist:win": "npm run test:e2e:release && electron-builder --win",
"release:gitea": "node scripts/release_gitea.mjs" "release:gitea": "node scripts/release_gitea.mjs",
"test:merge-split": "node scripts/smoke-test-merge-split-logic.js"
}, },
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",

View File

@ -0,0 +1,119 @@
function run() {
const failures = [];
const assert = (condition, message) => {
if (!condition) failures.push(message);
};
// ---- Test 1: parseDuration summation ----
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, isEnglish) {
if (items.length === 2) return `Merge: ${items[0].title} + ${items[1].title}`;
return `Merge: ${items[0].title} + ${items.length - 1} ${isEnglish ? 'more' : 'weitere'}`;
}
assert(
makeMergeTitle([{ title: 'A' }, { title: 'B' }], true) === 'Merge: A + B',
'Title 2 items failed'
);
assert(
makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }], false) === 'Merge: A + 2 weitere',
'Title 3 items DE failed'
);
assert(
makeMergeTitle([{ title: 'A' }, { title: 'B' }, { title: 'C' }], true) === 'Merge: A + 2 more',
'Title 3 items EN failed'
);
// ---- Test 5: Progress weighting (70/20/10) ----
const totalSec = 10800; // 180min
const vod1Dur = 3600; // 60min
const vod2Dur = 7200; // 120min
const vod1Weight = vod1Dur / totalSec;
const vod2Weight = vod2Dur / totalSec;
const priorWeight = vod1Weight;
const vodProgress = 50;
const overallProgress = (priorWeight + vod2Weight * (vodProgress / 100)) * 70;
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();

View File

@ -213,6 +213,7 @@
<div class="queue-list" id="queueList"></div> <div class="queue-list" id="queueList"></div>
<div class="queue-actions"> <div class="queue-actions">
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button> <button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
<button class="btn btn-merge-group" id="btnMergeGroup" onclick="createMergeGroupFromSelection()" style="display:none">Merge &amp; Split</button>
<button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button> <button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button> <button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
</div> </div>

View File

@ -60,6 +60,17 @@ type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrit
type UpdateCheckSource = 'startup' | 'interval' | 'manual'; type UpdateCheckSource = 'startup' | 'interval' | 'manual';
type UpdateDownloadSource = 'auto' | 'manual'; type UpdateDownloadSource = 'auto' | 'manual';
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;
}
}
// Ensure directories exist // Ensure directories exist
if (!fs.existsSync(APPDATA_DIR)) { if (!fs.existsSync(APPDATA_DIR)) {
fs.mkdirSync(APPDATA_DIR, { recursive: true }); fs.mkdirSync(APPDATA_DIR, { recursive: true });
@ -153,6 +164,24 @@ interface CustomClip {
filenameTemplate?: string; filenameTemplate?: string;
} }
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;
}
interface QueueItem { interface QueueItem {
id: string; id: string;
title: string; title: string;
@ -170,6 +199,7 @@ interface QueueItem {
totalBytes?: number; totalBytes?: number;
last_error?: string; last_error?: string;
customClip?: CustomClip; customClip?: CustomClip;
mergeGroup?: MergeGroup;
} }
interface DownloadResult { interface DownloadResult {
@ -2368,7 +2398,8 @@ async function cutVideo(
async function mergeVideos( async function mergeVideos(
inputFiles: string[], inputFiles: string[],
outputFile: string, outputFile: string,
onProgress: (percent: number) => void onProgress: (percent: number) => void,
totalDurationSec?: number
): Promise<boolean> { ): Promise<boolean> {
const ffmpegReady = await ensureFfmpegInstalled(); const ffmpegReady = await ensureFfmpegInstalled();
if (!ffmpegReady) { if (!ffmpegReady) {
@ -2378,7 +2409,10 @@ async function mergeVideos(
const ffmpeg = getFFmpegPath(); const ffmpeg = getFFmpegPath();
const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`); const concatFile = path.join(app.getPath('temp'), `concat_${Date.now()}.txt`);
const concatContent = inputFiles.map((filePath) => `file '${filePath.replace(/'/g, "'\\''")}'`).join('\n'); const concatContent = inputFiles.map((filePath) => {
const normalized = filePath.replace(/\\/g, '/');
return `file '${normalized.replace(/'/g, "'\\''")}'`;
}).join('\n');
fs.writeFileSync(concatFile, concatContent); fs.writeFileSync(concatFile, concatContent);
let mergeInputBytes = 0; let mergeInputBytes = 0;
@ -2405,6 +2439,29 @@ async function mergeVideos(
return false; return false;
} }
// 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
}
}
}
const runMergeAttempt = async (copyMode: boolean): Promise<boolean> => { const runMergeAttempt = async (copyMode: boolean): Promise<boolean> => {
const args = [ const args = [
'-f', 'concat', '-f', 'concat',
@ -2437,8 +2494,12 @@ async function mergeVideos(
const match = line.match(/out_time_us=(\d+)/); const match = line.match(/out_time_us=(\d+)/);
if (match) { if (match) {
const currentUs = parseInt(match[1], 10); const currentUs = parseInt(match[1], 10);
if (mergeTotalDurationUs > 0) {
onProgress(Math.min(99, (currentUs / mergeTotalDurationUs) * 100));
} else {
onProgress(Math.min(99, currentUs / 10000000)); onProgress(Math.min(99, currentUs / 10000000));
} }
}
}); });
proc.on('close', (code) => { proc.on('close', (code) => {
@ -2476,6 +2537,74 @@ async function mergeVideos(
} }
} }
// ==========================================
// 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 };
}
// ========================================== // ==========================================
// DOWNLOAD FUNCTIONS // DOWNLOAD FUNCTIONS
// ========================================== // ==========================================
@ -2855,6 +2984,244 @@ async function downloadVOD(
} }
} }
// ==========================================
// 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 ffmpegReady = await ensureFfmpegInstalled();
if (!ffmpegReady) {
return { success: false, error: 'FFmpeg 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 — do NOT rely on Object.values ordering)
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 - 1) / totalParts) * 10; // split = 90-100%
onProgress({
id: item.id,
progress: overallProgress,
speed: '',
eta: '',
status: `${getMergeGroupPhaseText('splitting')} ${currentPart}/${totalParts}...`,
currentPart,
totalParts
});
}
);
if (!splitResult.success) {
// Clean up any partial split files
for (const partFile of splitResult.files) {
try { if (fs.existsSync(partFile)) fs.unlinkSync(partFile); } catch { }
}
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 };
}
async function processQueue(): Promise<void> { async function processQueue(): Promise<void> {
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return; if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
@ -2900,7 +3267,11 @@ async function processQueue(): Promise<void> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts }); appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts });
const result = await downloadVOD(item, (progress) => { const result = item.mergeGroup
? await processDownloadMergeGroup(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress);
})
: await downloadVOD(item, (progress) => {
mainWindow?.webContents.send('download-progress', progress); mainWindow?.webContents.send('download-progress', progress);
}); });
@ -3426,6 +3797,18 @@ ipcMain.handle('remove-from-queue', (_, id: string) => {
appendDebugLog('queue-item-removed-active-cancelled', { id }); appendDebugLog('queue-item-removed-active-cancelled', { id });
} }
// 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 { }
}
}
downloadQueue = downloadQueue.filter(item => item.id !== id); downloadQueue = downloadQueue.filter(item => item.id !== id);
saveQueue(downloadQueue); saveQueue(downloadQueue);
emitQueueUpdated(); emitQueueUpdated();
@ -3475,6 +3858,81 @@ ipcMain.handle('retry-failed-downloads', () => {
return downloadQueue; return downloadQueue;
}); });
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;
});
ipcMain.handle('start-download', async () => { ipcMain.handle('start-download', async () => {
downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item); downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item);

View File

@ -9,6 +9,24 @@ interface CustomClip {
filenameTemplate?: string; filenameTemplate?: string;
} }
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;
}
interface QueueItem { interface QueueItem {
id: string; id: string;
title: string; title: string;
@ -23,6 +41,7 @@ interface QueueItem {
speed?: string; speed?: string;
eta?: string; eta?: string;
customClip?: CustomClip; customClip?: CustomClip;
mergeGroup?: MergeGroup;
} }
interface DownloadProgress { interface DownloadProgress {
@ -104,6 +123,7 @@ contextBridge.exposeInMainWorld('api', {
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds), reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
clearCompleted: () => ipcRenderer.invoke('clear-completed'), clearCompleted: () => ipcRenderer.invoke('clear-completed'),
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'), retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds),
// Download // Download
startDownload: () => ipcRenderer.invoke('start-download'), startDownload: () => ipcRenderer.invoke('start-download'),

View File

@ -37,6 +37,24 @@ interface CustomClip {
filenameTemplate?: string; filenameTemplate?: string;
} }
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;
}
interface QueueItem { interface QueueItem {
id: string; id: string;
title: string; title: string;
@ -55,6 +73,7 @@ interface QueueItem {
progressStatus?: string; progressStatus?: string;
last_error?: string; last_error?: string;
customClip?: CustomClip; customClip?: CustomClip;
mergeGroup?: MergeGroup;
} }
interface DownloadProgress { interface DownloadProgress {
@ -166,6 +185,7 @@ interface ApiBridge {
reorderQueue(orderIds: string[]): Promise<QueueItem[]>; reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
clearCompleted(): Promise<QueueItem[]>; clearCompleted(): Promise<QueueItem[]>;
retryFailedDownloads(): Promise<QueueItem[]>; retryFailedDownloads(): Promise<QueueItem[]>;
createMergeGroup(itemIds: string[]): Promise<QueueItem[]>;
startDownload(): Promise<boolean>; startDownload(): Promise<boolean>;
pauseDownload(): Promise<boolean>; pauseDownload(): Promise<boolean>;
cancelDownload(): Promise<boolean>; cancelDownload(): Promise<boolean>;

View File

@ -195,6 +195,17 @@ const UI_TEXT_DE = {
success: 'Videos erfolgreich zusammengefugt!', success: 'Videos erfolgreich zusammengefugt!',
failed: 'Fehler beim Zusammenfugen der Videos.' failed: 'Fehler beim Zusammenfugen der Videos.'
}, },
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',
},
updates: { updates: {
bannerDefault: 'Neue Version verfugbar!', bannerDefault: 'Neue Version verfugbar!',
latest: 'Du hast die neueste Version!', latest: 'Du hast die neueste Version!',

View File

@ -195,6 +195,17 @@ const UI_TEXT_EN = {
success: 'Videos merged successfully!', success: 'Videos merged successfully!',
failed: 'Failed to merge videos.' failed: 'Failed to merge videos.'
}, },
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',
},
updates: { updates: {
bannerDefault: 'New version available!', bannerDefault: 'New version available!',
latest: 'You are on the latest version!', latest: 'You are on the latest version!',

View File

@ -31,7 +31,8 @@ function getQueueRenderFingerprint(items: QueueItem[]): string {
item.speed || '', item.speed || '',
item.eta || '', item.eta || '',
item.progressStatus || '', item.progressStatus || '',
item.last_error || '' item.last_error || '',
item.mergeGroup?.mergePhase || ''
].join(':')); ].join(':'));
return `${lang}|${pieces.join('|')}`; return `${lang}|${pieces.join('|')}`;
@ -138,6 +139,45 @@ function getQueueMetaText(item: QueueItem): string {
return parts.join(' | '); return parts.join(' | ');
} }
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();
}
function renderQueue(): void { function renderQueue(): void {
if (!Array.isArray(queue)) { if (!Array.isArray(queue)) {
queue = []; queue = [];
@ -172,15 +212,29 @@ function renderQueue(): void {
: (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0); : (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0);
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : ''; const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
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
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
: '';
return ` return `
<div class="queue-item"> <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="status ${item.status}"></div>
<div class="queue-main"> <div class="queue-main">
<div class="queue-title-row"> <div class="queue-title-row">
<div class="title" title="${safeTitle}">${isClip}${safeTitle}</div> <div class="title" title="${safeTitle}">${mergeIcon}${isClip}${safeTitle}</div>
<div class="queue-status-label">${safeStatusLabel}</div> <div class="queue-status-label">${safeStatusLabel}</div>
</div> </div>
<div class="queue-meta">${safeMeta}</div> <div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
<div class="queue-progress-wrap"> <div class="queue-progress-wrap">
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div> <div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
</div> </div>
@ -191,6 +245,7 @@ function renderQueue(): void {
`; `;
}).join(''); }).join('');
updateMergeGroupButton();
lastQueueRenderFingerprint = renderFingerprint; lastQueueRenderFingerprint = renderFingerprint;
} }

View File

@ -24,6 +24,7 @@ let currentStreamer: string | null = null;
let isConnected = false; let isConnected = false;
let downloading = false; let downloading = false;
let queue: QueueItem[] = []; let queue: QueueItem[] = [];
let selectedQueueIds: Set<string> = new Set();
let cutterFile: string | null = null; let cutterFile: string | null = null;
let cutterVideoInfo: VideoInfo | null = null; let cutterVideoInfo: VideoInfo | null = null;

View File

@ -329,6 +329,34 @@ body {
opacity: 1; opacity: 1;
} }
.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;
}
.queue-actions { .queue-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;