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",
|
"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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
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-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 & 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>
|
||||||
|
|||||||
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 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,7 +2494,11 @@ 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);
|
||||||
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
|
// 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,9 +3267,13 @@ 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
|
||||||
mainWindow?.webContents.send('download-progress', progress);
|
? await processDownloadMergeGroup(item, (progress) => {
|
||||||
});
|
mainWindow?.webContents.send('download-progress', progress);
|
||||||
|
})
|
||||||
|
: await downloadVOD(item, (progress) => {
|
||||||
|
mainWindow?.webContents.send('download-progress', progress);
|
||||||
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
finalResult = result;
|
finalResult = result;
|
||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
20
src/renderer-globals.d.ts
vendored
20
src/renderer-globals.d.ts
vendored
@ -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>;
|
||||||
|
|||||||
@ -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!',
|
||||||
|
|||||||
@ -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!',
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user