Release v1.4.21 with download engine performance optimizations

- Cache itemCount as class property instead of O(n) Object.keys().length on every emit/persist/UI update
- Eliminate redundant iteration: hasQueuedItems now delegates to findNextQueuedItem
- Remove expensive cloneSession from getSnapshot (IPC serialization handles the copy)
- Increase speed events compaction threshold (50 → 200) to reduce array reallocations
- Time-based UI emit throttling instead of per-percent progress checks
- Avoid Array.from allocation in global stall watchdog
- Optimize markQueuedAsReconnectWait to iterate only run items instead of all items
- Cache pathKey computation in claimTargetPath loop (avoid path.resolve per iteration)
- Use packageOrder.length instead of Object.keys(packages).length in getStats

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-02-28 06:49:55 +01:00
parent 1ba635a793
commit d7162592e0
2 changed files with 51 additions and 40 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.20", "version": "1.4.21",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -333,6 +333,8 @@ export class DownloadManager extends EventEmitter {
private runCompletedPackages = new Set<string>(); private runCompletedPackages = new Set<string>();
private itemCount = 0;
private lastSchedulerHeartbeatAt = 0; private lastSchedulerHeartbeatAt = 0;
private lastReconnectMarkAt = 0; private lastReconnectMarkAt = 0;
@ -347,6 +349,7 @@ export class DownloadManager extends EventEmitter {
super(); super();
this.settings = settings; this.settings = settings;
this.session = cloneSession(session); this.session = cloneSession(session);
this.itemCount = Object.keys(this.session.items).length;
this.storagePaths = storagePaths; this.storagePaths = storagePaths;
this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict }); this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict });
this.applyOnStartCleanupPolicy(); this.applyOnStartCleanupPolicy();
@ -415,7 +418,7 @@ export class DownloadManager extends EventEmitter {
return { return {
settings: this.settings, settings: this.settings,
session: cloneSession(this.session), session: this.session,
summary: this.summary, summary: this.summary,
stats: this.getStats(now), stats: this.getStats(now),
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
@ -429,7 +432,7 @@ export class DownloadManager extends EventEmitter {
} }
public getStats(now = nowMs()): DownloadStats { public getStats(now = nowMs()): DownloadStats {
const itemCount = Object.keys(this.session.items).length; const itemCount = this.itemCount;
if (this.statsCache && this.session.running && itemCount >= 500 && now - this.statsCacheAt < 1500) { if (this.statsCache && this.session.running && itemCount >= 500 && now - this.statsCacheAt < 1500) {
return this.statsCache; return this.statsCache;
} }
@ -459,7 +462,7 @@ export class DownloadManager extends EventEmitter {
const stats = { const stats = {
totalDownloaded, totalDownloaded,
totalFiles, totalFiles,
totalPackages: Object.keys(this.session.packages).length, totalPackages: this.session.packageOrder.length,
sessionStartedAt: this.session.runStartedAt sessionStartedAt: this.session.runStartedAt
}; };
this.statsCache = stats; this.statsCache = stats;
@ -507,6 +510,7 @@ export class DownloadManager extends EventEmitter {
} }
} }
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
this.releaseTargetPath(itemId); this.releaseTargetPath(itemId);
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
@ -612,6 +616,7 @@ export class DownloadManager extends EventEmitter {
this.session.packageOrder = []; this.session.packageOrder = [];
this.session.packages = {}; this.session.packages = {};
this.session.items = {}; this.session.items = {};
this.itemCount = 0;
this.session.summaryText = ""; this.session.summaryText = "";
this.runItemIds.clear(); this.runItemIds.clear();
this.runPackageIds.clear(); this.runPackageIds.clear();
@ -679,6 +684,7 @@ export class DownloadManager extends EventEmitter {
}; };
packageEntry.itemIds.push(itemId); packageEntry.itemIds.push(itemId);
this.session.items[itemId] = item; this.session.items[itemId] = item;
this.itemCount += 1;
if (this.session.running) { if (this.session.running) {
this.runItemIds.add(itemId); this.runItemIds.add(itemId);
this.runPackageIds.add(packageId); this.runPackageIds.add(packageId);
@ -762,6 +768,7 @@ export class DownloadManager extends EventEmitter {
} }
this.releaseTargetPath(itemId); this.releaseTargetPath(itemId);
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
} }
delete this.session.packages[packageId]; delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
@ -1468,6 +1475,7 @@ export class DownloadManager extends EventEmitter {
} }
if (item.status === "completed") { if (item.status === "completed") {
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
return false; return false;
} }
return true; return true;
@ -1484,7 +1492,7 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
const itemCount = Object.keys(this.session.items).length; const itemCount = this.itemCount;
const minGapMs = this.session.running const minGapMs = this.session.running
? itemCount >= 1500 ? itemCount >= 1500
? 3000 ? 3000
@ -1524,7 +1532,7 @@ export class DownloadManager extends EventEmitter {
if (this.stateEmitTimer) { if (this.stateEmitTimer) {
return; return;
} }
const itemCount = Object.keys(this.session.items).length; const itemCount = this.itemCount;
const emitDelay = this.session.running const emitDelay = this.session.running
? itemCount >= 1500 ? itemCount >= 1500
? 1200 ? 1200
@ -1548,7 +1556,7 @@ export class DownloadManager extends EventEmitter {
this.speedBytesLastWindow = Math.max(0, this.speedBytesLastWindow - this.speedEvents[this.speedEventsHead].bytes); this.speedBytesLastWindow = Math.max(0, this.speedBytesLastWindow - this.speedEvents[this.speedEventsHead].bytes);
this.speedEventsHead += 1; this.speedEventsHead += 1;
} }
if (this.speedEventsHead > 50) { if (this.speedEventsHead > 200) {
this.speedEvents = this.speedEvents.slice(this.speedEventsHead); this.speedEvents = this.speedEvents.slice(this.speedEventsHead);
this.speedEventsHead = 0; this.speedEventsHead = 0;
} }
@ -1590,12 +1598,19 @@ export class DownloadManager extends EventEmitter {
} }
const parsed = path.parse(preferredPath); const parsed = path.parse(preferredPath);
const preferredKey = pathKey(preferredPath);
const baseDirKey = process.platform === "win32" ? parsed.dir.toLowerCase() : parsed.dir;
const baseNameKey = process.platform === "win32" ? parsed.name.toLowerCase() : parsed.name;
const baseExtKey = process.platform === "win32" ? parsed.ext.toLowerCase() : parsed.ext;
const sep = path.sep;
const maxIndex = 10000; const maxIndex = 10000;
for (let index = 0; index <= maxIndex; index += 1) { for (let index = 0; index <= maxIndex; index += 1) {
const candidate = index === 0 const candidate = index === 0
? preferredPath ? preferredPath
: path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`); : path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`);
const key = pathKey(candidate); const key = index === 0
? preferredKey
: `${baseDirKey}${sep}${baseNameKey} (${index})${baseExtKey}`;
const owner = this.reservedTargetPaths.get(key); const owner = this.reservedTargetPaths.get(key);
const existsOnDisk = fs.existsSync(candidate); const existsOnDisk = fs.existsSync(candidate);
const allowExistingCandidate = allowExistingFile && index === 0; const allowExistingCandidate = allowExistingFile && index === 0;
@ -1787,6 +1802,7 @@ export class DownloadManager extends EventEmitter {
private removePackageFromSession(packageId: string, itemIds: string[]): void { private removePackageFromSession(packageId: string, itemIds: string[]): void {
for (const itemId of itemIds) { for (const itemId of itemIds) {
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
} }
delete this.session.packages[packageId]; delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
@ -1887,26 +1903,32 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
const stalled = Array.from(this.activeTasks.values()).filter((active) => { let stalledCount = 0;
for (const active of this.activeTasks.values()) {
if (active.abortController.signal.aborted) { if (active.abortController.signal.aborted) {
return false; continue;
} }
const item = this.session.items[active.itemId]; const item = this.session.items[active.itemId];
return Boolean(item && item.status === "downloading"); if (item && item.status === "downloading") {
}); stalledCount += 1;
if (stalled.length === 0) { }
}
if (stalledCount === 0) {
this.lastGlobalProgressAt = now; this.lastGlobalProgressAt = now;
return; return;
} }
logger.warn(`Globaler Download-Stall erkannt (${Math.floor((now - this.lastGlobalProgressAt) / 1000)}s ohne Fortschritt), ${stalled.length} Task(s) neu starten`); logger.warn(`Globaler Download-Stall erkannt (${Math.floor((now - this.lastGlobalProgressAt) / 1000)}s ohne Fortschritt), ${stalledCount} Task(s) neu starten`);
for (const active of stalled) { for (const active of this.activeTasks.values()) {
if (active.abortController.signal.aborted) { if (active.abortController.signal.aborted) {
continue; continue;
} }
const item = this.session.items[active.itemId];
if (item && item.status === "downloading") {
active.abortReason = "stall"; active.abortReason = "stall";
active.abortController.abort("stall"); active.abortController.abort("stall");
} }
}
this.lastGlobalProgressAt = now; this.lastGlobalProgressAt = now;
} }
@ -1943,14 +1965,20 @@ export class DownloadManager extends EventEmitter {
private markQueuedAsReconnectWait(): boolean { private markQueuedAsReconnectWait(): boolean {
let changed = false; let changed = false;
for (const item of Object.values(this.session.items)) { const waitText = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
const itemIds = this.runItemIds.size > 0 ? this.runItemIds : Object.keys(this.session.items);
for (const itemId of itemIds) {
const item = this.session.items[itemId];
if (!item) {
continue;
}
const pkg = this.session.packages[item.packageId]; const pkg = this.session.packages[item.packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) { if (!pkg || pkg.cancelled || !pkg.enabled) {
continue; continue;
} }
if (item.status === "queued") { if (item.status === "queued") {
item.status = "reconnect_wait"; item.status = "reconnect_wait";
item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`; item.fullStatus = waitText;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
changed = true; changed = true;
} }
@ -1981,22 +2009,7 @@ export class DownloadManager extends EventEmitter {
} }
private hasQueuedItems(): boolean { private hasQueuedItems(): boolean {
for (const packageId of this.session.packageOrder) { return this.findNextQueuedItem() !== null;
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item) {
continue;
}
if (item.status === "queued" || item.status === "reconnect_wait") {
return true;
}
}
}
return false;
} }
private countQueuedItems(): number { private countQueuedItems(): number {
@ -2487,7 +2500,7 @@ export class DownloadManager extends EventEmitter {
let written = writeMode === "a" ? existingBytes : 0; let written = writeMode === "a" ? existingBytes : 0;
let windowBytes = 0; let windowBytes = 0;
let windowStarted = nowMs(); let windowStarted = nowMs();
const itemCount = Object.keys(this.session.items).length; const itemCount = this.itemCount;
const uiUpdateIntervalMs = itemCount >= 1500 const uiUpdateIntervalMs = itemCount >= 1500
? 650 ? 650
: itemCount >= 700 : itemCount >= 700
@ -2496,7 +2509,6 @@ export class DownloadManager extends EventEmitter {
? 280 ? 280
: 170; : 170;
let lastUiEmitAt = 0; let lastUiEmitAt = 0;
let lastProgressPercent = item.progressPercent;
const stallTimeoutMs = getDownloadStallTimeoutMs(); const stallTimeoutMs = getDownloadStallTimeoutMs();
const drainTimeoutMs = Math.max(4000, Math.min(45000, stallTimeoutMs > 0 ? stallTimeoutMs : 15000)); const drainTimeoutMs = Math.max(4000, Math.min(45000, stallTimeoutMs > 0 ? stallTimeoutMs : 15000));
@ -2678,12 +2690,10 @@ export class DownloadManager extends EventEmitter {
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0; item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0;
item.fullStatus = `Download läuft (${providerLabel(item.provider)})`; item.fullStatus = `Download läuft (${providerLabel(item.provider)})`;
const nowTick = nowMs(); const nowTick = nowMs();
const progressChanged = item.progressPercent !== lastProgressPercent; if (nowTick - lastUiEmitAt >= uiUpdateIntervalMs) {
if (progressChanged || nowTick - lastUiEmitAt >= uiUpdateIntervalMs) {
item.updatedAt = nowTick; item.updatedAt = nowTick;
this.emitState(); this.emitState();
lastUiEmitAt = nowTick; lastUiEmitAt = nowTick;
lastProgressPercent = item.progressPercent;
} }
} }
} finally { } finally {
@ -3132,6 +3142,7 @@ export class DownloadManager extends EventEmitter {
if (policy === "immediate") { if (policy === "immediate") {
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
if (pkg.itemIds.length === 0) { if (pkg.itemIds.length === 0) {
this.removePackageFromSession(packageId, []); this.removePackageFromSession(packageId, []);
} }