feat: support parallel downloads (up to 2 simultaneous)

Add parallel_downloads config option (1 or 2) with Settings UI dropdown.
Refactor processQueue to run concurrent download slots using Promise.race,
extracting per-item logic into processOneQueueItem. Add per-item process
tracking via activeDownloads Map and cancelledItemIds Set so cancel/pause
correctly terminates all active downloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-03-20 09:54:20 +01:00
parent 63aafae85d
commit 54d04d4f73
7 changed files with 214 additions and 120 deletions

View File

@ -436,6 +436,13 @@
<label id="partMinutesLabel">Teil-Lange (Minuten)</label>
<input type="number" id="partMinutes" value="120" min="10" max="480">
</div>
<div class="form-group">
<label id="parallelDownloadsLabel">Parallele Downloads</label>
<select id="parallelDownloads">
<option value="1" id="parallelDownloads1">1 (Standard)</option>
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
</select>
</div>
<div class="form-group">
<label id="performanceModeLabel">Performance-Profil</label>
<select id="performanceMode">

View File

@ -97,6 +97,7 @@ interface Config {
prevent_duplicate_downloads: boolean;
persist_queue_on_restart: boolean;
metadata_cache_minutes: number;
parallel_downloads: number;
}
interface RuntimeMetrics {
@ -207,7 +208,8 @@ const defaultConfig: Config = {
performance_mode: DEFAULT_PERFORMANCE_MODE,
prevent_duplicate_downloads: true,
persist_queue_on_restart: true,
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES
metadata_cache_minutes: DEFAULT_METADATA_CACHE_MINUTES,
parallel_downloads: 1
};
function normalizeFilenameTemplate(template: string | undefined, fallback: string): string {
@ -379,6 +381,9 @@ let pauseRequested = false;
let activeQueueItemId: string | null = null;
let downloadStartTime = 0;
let downloadedBytes = 0;
// Per-item tracking for parallel downloads
const activeDownloads = new Map<string, { process: ChildProcess | null; cancelled: boolean; startTime: number; bytes: number }>();
const cancelledItemIds = new Set<string>();
const userIdLoginCache = new Map<string, string>();
const loginToUserIdCache = new Map<string, CacheEntry<string>>();
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
@ -2595,7 +2600,11 @@ function downloadVODPart(
const proc = spawn(streamlinkCmd.command, args, { windowsHide: true });
currentProcess = proc;
downloadStartTime = Date.now();
// Register in per-item tracking map for parallel downloads
const itemTracking = { process: proc, cancelled: false, startTime: Date.now(), bytes: 0 };
activeDownloads.set(itemId, itemTracking);
downloadStartTime = itemTracking.startTime;
downloadedBytes = 0;
let lastBytes = 0;
let lastTime = Date.now();
@ -2606,6 +2615,7 @@ function downloadVODPart(
try {
const stats = fs.statSync(filename);
downloadedBytes = stats.size;
itemTracking.bytes = stats.size;
const now = Date.now();
const timeDiff = (now - lastTime) / 1000;
@ -2624,7 +2634,8 @@ function downloadVODPart(
let etaStr = '';
if (speed > 0 && downloadedBytes > 0) {
const elapsedSec = (Date.now() - downloadStartTime) / 1000;
const itemStartTime = itemTracking.startTime;
const elapsedSec = (Date.now() - itemStartTime) / 1000;
if (elapsedSec > 3) {
const avgSpeed = downloadedBytes / elapsedSec;
if (expectedDurationSeconds && expectedDurationSeconds > 0) {
@ -2684,8 +2695,10 @@ function downloadVODPart(
proc.on('close', async (code) => {
clearInterval(progressInterval);
currentProcess = null;
activeDownloads.delete(itemId);
if (currentDownloadCancelled) {
if (currentDownloadCancelled || cancelledItemIds.has(itemId)) {
cancelledItemIds.delete(itemId);
appendDebugLog('download-part-cancelled', { itemId, filename });
resolve({ success: false, error: 'Download wurde abgebrochen.' });
return;
@ -2727,6 +2740,7 @@ function downloadVODPart(
clearInterval(progressInterval);
console.error('Process error:', err);
currentProcess = null;
activeDownloads.delete(itemId);
const rawError = String(err);
const errorMessage = rawError.includes('ENOENT')
? 'Streamlink nicht gefunden. Installiere Streamlink oder Python+streamlink (py -3 -m pip install streamlink).'
@ -2856,7 +2870,7 @@ async function downloadVOD(
const downloadedFiles: string[] = [];
for (let i = 0; i < numParts; i++) {
if (currentDownloadCancelled) break;
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) break;
const partNum = clip.startPart + i;
const startOffset = clip.startSec + (i * partDuration);
@ -2918,7 +2932,7 @@ async function downloadVOD(
const downloadedFiles: string[] = [];
for (let i = 0; i < numParts; i++) {
if (currentDownloadCancelled) break;
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) break;
const startSec = i * partDuration;
const endSec = Math.min((i + 1) * partDuration, totalDuration);
@ -2998,7 +3012,7 @@ async function processDownloadMergeGroup(
}
for (let i = 0; i < mg.items.length; i++) {
if (currentDownloadCancelled) {
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
return { success: false, error: 'Download wurde abgebrochen.' };
}
@ -3008,7 +3022,8 @@ async function processDownloadMergeGroup(
continue;
}
currentDownloadCancelled = false; // Reset stale cancel state
// Reset stale per-item cancel state (global cancel already checked above)
cancelledItemIds.delete(item.id);
mg.currentItemIndex = i;
mg.mergePhase = 'downloading';
saveQueue(downloadQueue);
@ -3109,7 +3124,7 @@ async function processDownloadMergeGroup(
saveQueue(downloadQueue);
emitQueueUpdated();
if (currentDownloadCancelled) {
if (currentDownloadCancelled || cancelledItemIds.has(item.id)) {
return { success: false, error: 'Download wurde abgebrochen.' };
}
@ -3195,137 +3210,165 @@ async function processDownloadMergeGroup(
return { success: true };
}
async function processOneQueueItem(item: QueueItem): Promise<void> {
appendDebugLog('queue-item-start', {
itemId: item.id,
title: item.title,
url: item.url,
smartScore: config.smart_queue_scheduler ? getQueuePriorityScore(item) : 0
});
runtimeMetrics.downloadsStarted += 1;
runtimeMetrics.activeItemId = item.id;
runtimeMetrics.activeItemTitle = item.title;
activeQueueItemId = item.id;
cancelledItemIds.delete(item.id);
item.status = 'downloading';
saveQueue(downloadQueue);
emitQueueUpdated();
item.last_error = '';
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
const maxAttempts = getRetryAttemptLimit();
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts });
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;
break;
}
finalResult = result;
if (!isDownloading || currentDownloadCancelled || cancelledItemIds.has(item.id) || pauseRequested) {
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
break;
}
const errorClass = classifyDownloadError(result.error || '');
runtimeMetrics.lastErrorClass = errorClass;
if (errorClass === 'tooling' || errorClass === 'validation') {
appendDebugLog('queue-item-no-retry', {
itemId: item.id,
errorClass,
error: result.error || 'unknown'
});
break;
}
if (attempt < maxAttempts) {
const retryDelaySeconds = getRetryDelaySeconds(errorClass, attempt);
runtimeMetrics.retriesScheduled += 1;
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
item.last_error = `Versuch ${attempt}/${maxAttempts} fehlgeschlagen (${errorClass}): ${result.error || 'Unbekannter Fehler'}`;
mainWindow?.webContents.send('download-progress', {
id: item.id,
progress: -1,
speed: '',
eta: '',
status: `Neuer Versuch in ${retryDelaySeconds}s (${errorClass})...`,
currentPart: item.currentPart,
totalParts: item.totalParts
} as DownloadProgress);
saveQueue(downloadQueue);
emitQueueUpdated();
await sleep(retryDelaySeconds * 1000);
} else {
runtimeMetrics.retriesExhausted += 1;
}
}
if (!hasQueueItemId(item.id)) {
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
activeDownloads.delete(item.id);
cancelledItemIds.delete(item.id);
return;
}
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
item.progress = finalResult.success ? 100 : item.progress;
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download');
if (finalResult.success) {
runtimeMetrics.downloadsCompleted += 1;
} else if (!wasPaused) {
runtimeMetrics.downloadsFailed += 1;
}
activeDownloads.delete(item.id);
cancelledItemIds.delete(item.id);
appendDebugLog('queue-item-finished', {
itemId: item.id,
status: item.status,
error: item.last_error
});
saveQueue(downloadQueue);
emitQueueUpdated();
}
async function processQueue(): Promise<void> {
if (isDownloading || !downloadQueue.some((item) => item.status === 'pending')) return;
appendDebugLog('queue-start', {
items: downloadQueue.length,
smartScheduler: config.smart_queue_scheduler,
performanceMode: config.performance_mode
performanceMode: config.performance_mode,
parallelDownloads: config.parallel_downloads || 1
});
isDownloading = true;
pauseRequested = false;
currentDownloadCancelled = false;
cancelledItemIds.clear();
mainWindow?.webContents.send('download-started');
emitQueueUpdated();
const maxSlots = Math.min(Math.max(1, config.parallel_downloads || 1), 2);
const activePromises = new Map<string, Promise<void>>();
while (isDownloading && !pauseRequested) {
const item = pickNextPendingQueueItem();
if (!item) {
break;
}
appendDebugLog('queue-item-start', {
itemId: item.id,
title: item.title,
url: item.url,
smartScore: config.smart_queue_scheduler ? getQueuePriorityScore(item) : 0
});
runtimeMetrics.downloadsStarted += 1;
runtimeMetrics.activeItemId = item.id;
runtimeMetrics.activeItemTitle = item.title;
activeQueueItemId = item.id;
currentDownloadCancelled = false;
item.status = 'downloading';
saveQueue(downloadQueue);
emitQueueUpdated();
item.last_error = '';
let finalResult: DownloadResult = { success: false, error: 'Unbekannter Fehler beim Download' };
const maxAttempts = getRetryAttemptLimit();
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
appendDebugLog('queue-item-attempt', { itemId: item.id, attempt, max: maxAttempts });
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;
break;
}
finalResult = result;
if (!isDownloading || currentDownloadCancelled || pauseRequested) {
finalResult = { success: false, error: pauseRequested ? 'Download wurde pausiert.' : 'Download wurde abgebrochen.' };
break;
}
const errorClass = classifyDownloadError(result.error || '');
runtimeMetrics.lastErrorClass = errorClass;
if (errorClass === 'tooling' || errorClass === 'validation') {
appendDebugLog('queue-item-no-retry', {
itemId: item.id,
errorClass,
error: result.error || 'unknown'
});
break;
}
if (attempt < maxAttempts) {
const retryDelaySeconds = getRetryDelaySeconds(errorClass, attempt);
runtimeMetrics.retriesScheduled += 1;
runtimeMetrics.lastRetryDelaySeconds = retryDelaySeconds;
item.last_error = `Versuch ${attempt}/${maxAttempts} fehlgeschlagen (${errorClass}): ${result.error || 'Unbekannter Fehler'}`;
mainWindow?.webContents.send('download-progress', {
id: item.id,
progress: -1,
speed: '',
eta: '',
status: `Neuer Versuch in ${retryDelaySeconds}s (${errorClass})...`,
currentPart: item.currentPart,
totalParts: item.totalParts
} as DownloadProgress);
saveQueue(downloadQueue);
emitQueueUpdated();
await sleep(retryDelaySeconds * 1000);
} else {
runtimeMetrics.retriesExhausted += 1;
// Clean up finished promises
for (const [id] of activePromises) {
const queueItem = downloadQueue.find(i => i.id === id);
if (!queueItem || queueItem.status !== 'downloading') {
activePromises.delete(id);
}
}
if (!hasQueueItemId(item.id)) {
appendDebugLog('queue-item-finished-removed', { itemId: item.id });
runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null;
activeQueueItemId = null;
continue;
// Fill available slots
while (activePromises.size < maxSlots && !pauseRequested) {
const item = pickNextPendingQueueItem();
if (!item) break;
const itemPromise = processOneQueueItem(item);
activePromises.set(item.id, itemPromise);
}
const wasPaused = pauseRequested || (finalResult.error || '').includes('pausiert');
item.status = finalResult.success ? 'completed' : (wasPaused ? 'paused' : 'error');
item.progress = finalResult.success ? 100 : item.progress;
item.last_error = finalResult.success || wasPaused ? '' : (finalResult.error || 'Unbekannter Fehler beim Download');
if (activePromises.size === 0) break;
if (finalResult.success) {
runtimeMetrics.downloadsCompleted += 1;
} else if (!wasPaused) {
runtimeMetrics.downloadsFailed += 1;
}
// Wait for any one download to finish before re-checking
await Promise.race([...activePromises.values()]);
}
runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null;
activeQueueItemId = null;
appendDebugLog('queue-item-finished', {
itemId: item.id,
status: item.status,
error: item.last_error
});
saveQueue(downloadQueue);
emitQueueUpdated();
// Wait for all remaining active downloads to complete
if (activePromises.size > 0) {
await Promise.allSettled([...activePromises.values()]);
}
isDownloading = false;
@ -3333,6 +3376,8 @@ async function processQueue(): Promise<void> {
runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null;
activeQueueItemId = null;
activeDownloads.clear();
cancelledItemIds.clear();
saveQueue(downloadQueue);
emitQueueUpdated();
@ -3774,13 +3819,21 @@ ipcMain.handle('add-to-queue', (_, item: Omit<QueueItem, 'id' | 'status' | 'prog
});
ipcMain.handle('remove-from-queue', (_, id: string) => {
const wasActiveItem = activeQueueItemId === id;
const wasActiveItem = activeQueueItemId === id || activeDownloads.has(id);
if (wasActiveItem) {
// Cancel via per-item tracking
cancelledItemIds.add(id);
const tracking = activeDownloads.get(id);
if (tracking?.process) {
tracking.process.kill();
}
// Also set global for backwards compat
currentDownloadCancelled = true;
if (currentProcess) {
currentProcess.kill();
}
activeDownloads.delete(id);
activeQueueItemId = null;
runtimeMetrics.activeItemId = null;
runtimeMetrics.activeItemTitle = null;
@ -3948,6 +4001,13 @@ ipcMain.handle('pause-download', () => {
pauseRequested = true;
currentDownloadCancelled = true;
// Kill all active download processes
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) {
currentProcess.kill();
}
@ -3958,6 +4018,13 @@ ipcMain.handle('cancel-download', () => {
isDownloading = false;
pauseRequested = false;
currentDownloadCancelled = true;
// Kill all active download processes
for (const [id, tracking] of activeDownloads) {
cancelledItemIds.add(id);
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) {
currentProcess.kill();
}
@ -4190,6 +4257,12 @@ app.on('window-all-closed', () => {
stopDebugLogFlushTimer(true);
stopAutoUpdatePolling();
// Kill all active download processes
for (const [, tracking] of activeDownloads) {
if (tracking.process) {
tracking.process.kill();
}
}
if (currentProcess) {
currentProcess.kill();
}

View File

@ -15,6 +15,7 @@ interface AppConfig {
prevent_duplicate_downloads?: boolean;
persist_queue_on_restart?: boolean;
metadata_cache_minutes?: number;
parallel_downloads?: number;
[key: string]: unknown;
}

View File

@ -41,6 +41,9 @@ const UI_TEXT_DE = {
modeFull: 'Ganzes VOD',
modeParts: 'In Teile splitten',
partMinutesLabel: 'Teil-Lange (Minuten)',
parallelDownloadsLabel: 'Parallele Downloads',
parallelDownloads1: '1 (Standard)',
parallelDownloads2: '2 (Parallel)',
performanceModeLabel: 'Performance-Profil',
performanceModeStability: 'Max Stabilitat',
performanceModeBalanced: 'Ausgewogen',

View File

@ -41,6 +41,9 @@ const UI_TEXT_EN = {
modeFull: 'Full VOD',
modeParts: 'Split into parts',
partMinutesLabel: 'Part Length (Minutes)',
parallelDownloadsLabel: 'Parallel Downloads',
parallelDownloads1: '1 (Default)',
parallelDownloads2: '2 (Parallel)',
performanceModeLabel: 'Performance Profile',
performanceModeStability: 'Max Stability',
performanceModeBalanced: 'Balanced',

View File

@ -315,6 +315,7 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
return {
download_mode: byId<HTMLSelectElement>('downloadMode').value as 'parts' | 'full',
part_minutes: parseInt(byId<HTMLInputElement>('partMinutes').value, 10) || 120,
parallel_downloads: parseInt(byId<HTMLSelectElement>('parallelDownloads').value, 10) || 1,
performance_mode: byId<HTMLSelectElement>('performanceMode').value as 'stability' | 'balanced' | 'speed',
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
@ -356,6 +357,7 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
effective.client_secret ?? '',
effective.download_mode ?? 'full',
effective.part_minutes ?? 120,
effective.parallel_downloads ?? 1,
effective.performance_mode ?? 'balanced',
effective.smart_queue_scheduler !== false,
effective.prevent_duplicate_downloads !== false,
@ -372,6 +374,7 @@ function syncSettingsFormFromConfig(): void {
byId<HTMLInputElement>('clientSecret').value = config.client_secret ?? '';
byId<HTMLSelectElement>('downloadMode').value = (config.download_mode as 'parts' | 'full') ?? 'full';
byId<HTMLInputElement>('partMinutes').value = String((config.part_minutes as number) || 120);
byId<HTMLSelectElement>('parallelDownloads').value = String((config.parallel_downloads as number) || 1);
byId<HTMLSelectElement>('performanceMode').value = (config.performance_mode as string) || 'balanced';
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
@ -482,6 +485,7 @@ function initSettingsAutoSave(): void {
const immediateSaveIds = [
'downloadMode',
'parallelDownloads',
'performanceMode',
'smartSchedulerToggle',
'duplicatePreventionToggle',

View File

@ -83,6 +83,9 @@ function applyLanguageToStaticUI(): void {
setText('modeFullText', UI_TEXT.static.modeFull);
setText('modePartsText', UI_TEXT.static.modeParts);
setText('partMinutesLabel', UI_TEXT.static.partMinutesLabel);
setText('parallelDownloadsLabel', UI_TEXT.static.parallelDownloadsLabel);
setText('parallelDownloads1', UI_TEXT.static.parallelDownloads1);
setText('parallelDownloads2', UI_TEXT.static.parallelDownloads2);
setText('performanceModeLabel', UI_TEXT.static.performanceModeLabel);
setText('performanceModeStability', UI_TEXT.static.performanceModeStability);
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);