Compare commits
14 Commits
d1579cb281
...
3af159f8e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3af159f8e7 | ||
|
|
6c082a87ab | ||
|
|
30c94b550e | ||
|
|
ad4e540952 | ||
|
|
c1a72ebd66 | ||
|
|
409c976df0 | ||
|
|
8501bd17f7 | ||
|
|
03f47a7240 | ||
|
|
5a20c1c6a4 | ||
|
|
645d2f147b | ||
|
|
4750af2f97 | ||
|
|
03c6e68da0 | ||
|
|
6aae84cac7 | ||
|
|
1abc87d17d |
1229
docs/superpowers/plans/2026-03-19-vod-merge-split.md
Normal file
1229
docs/superpowers/plans/2026-03-19-vod-merge-split.md
Normal file
File diff suppressed because it is too large
Load Diff
432
docs/superpowers/specs/2026-03-19-vod-merge-split-design.md
Normal file
432
docs/superpowers/specs/2026-03-19-vod-merge-split-design.md
Normal 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
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.2.5",
|
||||
"version": "4.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.2.5",
|
||||
"version": "4.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.2.5",
|
||||
"version": "4.3.0",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "xRangerDE",
|
||||
@ -17,7 +17,8 @@
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"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": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
119
scripts/smoke-test-merge-split-logic.js
Normal file
119
scripts/smoke-test-merge-split-logic.js
Normal 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();
|
||||
@ -213,6 +213,7 @@
|
||||
<div class="queue-list" id="queueList"></div>
|
||||
<div class="queue-actions">
|
||||
<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 & Split</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>
|
||||
</div>
|
||||
|
||||
470
src/main.ts
470
src/main.ts
@ -60,6 +60,17 @@ type RetryErrorClass = 'network' | 'rate_limit' | 'auth' | 'tooling' | 'integrit
|
||||
type UpdateCheckSource = 'startup' | 'interval' | '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
|
||||
if (!fs.existsSync(APPDATA_DIR)) {
|
||||
fs.mkdirSync(APPDATA_DIR, { recursive: true });
|
||||
@ -153,6 +164,24 @@ interface CustomClip {
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -170,6 +199,7 @@ interface QueueItem {
|
||||
totalBytes?: number;
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
@ -2368,7 +2398,8 @@ async function cutVideo(
|
||||
async function mergeVideos(
|
||||
inputFiles: string[],
|
||||
outputFile: string,
|
||||
onProgress: (percent: number) => void
|
||||
onProgress: (percent: number) => void,
|
||||
totalDurationSec?: number
|
||||
): Promise<boolean> {
|
||||
const ffmpegReady = await ensureFfmpegInstalled();
|
||||
if (!ffmpegReady) {
|
||||
@ -2378,7 +2409,10 @@ async function mergeVideos(
|
||||
|
||||
const ffmpeg = getFFmpegPath();
|
||||
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);
|
||||
|
||||
let mergeInputBytes = 0;
|
||||
@ -2405,6 +2439,29 @@ async function mergeVideos(
|
||||
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 args = [
|
||||
'-f', 'concat',
|
||||
@ -2437,7 +2494,11 @@ async function mergeVideos(
|
||||
const match = line.match(/out_time_us=(\d+)/);
|
||||
if (match) {
|
||||
const currentUs = parseInt(match[1], 10);
|
||||
onProgress(Math.min(99, currentUs / 10000000));
|
||||
if (mergeTotalDurationUs > 0) {
|
||||
onProgress(Math.min(99, (currentUs / mergeTotalDurationUs) * 100));
|
||||
} else {
|
||||
onProgress(Math.min(99, currentUs / 10000000));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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
|
||||
// ==========================================
|
||||
@ -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> {
|
||||
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
|
||||
|
||||
@ -2900,9 +3267,13 @@ async function processQueue(): Promise<void> {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts });
|
||||
|
||||
const result = await downloadVOD(item, (progress) => {
|
||||
mainWindow?.webContents.send('download-progress', 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);
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
finalResult = result;
|
||||
@ -3426,6 +3797,18 @@ ipcMain.handle('remove-from-queue', (_, id: string) => {
|
||||
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);
|
||||
saveQueue(downloadQueue);
|
||||
emitQueueUpdated();
|
||||
@ -3475,6 +3858,81 @@ ipcMain.handle('retry-failed-downloads', () => {
|
||||
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 () => {
|
||||
downloadQueue = downloadQueue.map((item) => item.status === 'paused' ? { ...item, status: 'pending' } : item);
|
||||
|
||||
|
||||
@ -9,6 +9,24 @@ interface CustomClip {
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -23,6 +41,7 @@ interface QueueItem {
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
@ -104,6 +123,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
|
||||
clearCompleted: () => ipcRenderer.invoke('clear-completed'),
|
||||
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
|
||||
createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds),
|
||||
|
||||
// Download
|
||||
startDownload: () => ipcRenderer.invoke('start-download'),
|
||||
|
||||
20
src/renderer-globals.d.ts
vendored
20
src/renderer-globals.d.ts
vendored
@ -37,6 +37,24 @@ interface CustomClip {
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -55,6 +73,7 @@ interface QueueItem {
|
||||
progressStatus?: string;
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
@ -166,6 +185,7 @@ interface ApiBridge {
|
||||
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
|
||||
clearCompleted(): Promise<QueueItem[]>;
|
||||
retryFailedDownloads(): Promise<QueueItem[]>;
|
||||
createMergeGroup(itemIds: string[]): Promise<QueueItem[]>;
|
||||
startDownload(): Promise<boolean>;
|
||||
pauseDownload(): Promise<boolean>;
|
||||
cancelDownload(): Promise<boolean>;
|
||||
|
||||
@ -195,6 +195,17 @@ const UI_TEXT_DE = {
|
||||
success: 'Videos erfolgreich zusammengefugt!',
|
||||
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: {
|
||||
bannerDefault: 'Neue Version verfugbar!',
|
||||
latest: 'Du hast die neueste Version!',
|
||||
|
||||
@ -195,6 +195,17 @@ const UI_TEXT_EN = {
|
||||
success: 'Videos merged successfully!',
|
||||
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: {
|
||||
bannerDefault: 'New version available!',
|
||||
latest: 'You are on the latest version!',
|
||||
|
||||
@ -31,7 +31,8 @@ function getQueueRenderFingerprint(items: QueueItem[]): string {
|
||||
item.speed || '',
|
||||
item.eta || '',
|
||||
item.progressStatus || '',
|
||||
item.last_error || ''
|
||||
item.last_error || '',
|
||||
item.mergeGroup?.mergePhase || ''
|
||||
].join(':'));
|
||||
|
||||
return `${lang}|${pieces.join('|')}`;
|
||||
@ -138,6 +139,45 @@ function getQueueMetaText(item: QueueItem): string {
|
||||
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 {
|
||||
if (!Array.isArray(queue)) {
|
||||
queue = [];
|
||||
@ -172,15 +212,29 @@ function renderQueue(): void {
|
||||
: (hasDeterminateProgress ? Math.max(0, Math.min(100, item.progress)) : 0);
|
||||
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 `
|
||||
<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="queue-main">
|
||||
<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>
|
||||
<div class="queue-meta">${safeMeta}</div>
|
||||
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
|
||||
<div class="queue-progress-wrap">
|
||||
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
|
||||
</div>
|
||||
@ -191,6 +245,7 @@ function renderQueue(): void {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
updateMergeGroupButton();
|
||||
lastQueueRenderFingerprint = renderFingerprint;
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ let currentStreamer: string | null = null;
|
||||
let isConnected = false;
|
||||
let downloading = false;
|
||||
let queue: QueueItem[] = [];
|
||||
let selectedQueueIds: Set<string> = new Set();
|
||||
|
||||
let cutterFile: string | null = null;
|
||||
let cutterVideoInfo: VideoInfo | null = null;
|
||||
|
||||
@ -329,6 +329,34 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user