Fix retry recovery, extraction status cross-contamination and UI freezes, release v1.4.69

- togglePause: clear retry delays and abort stuck tasks on unpause so
  Pause/Start actually recovers stuck downloads
- Fix retry display showing Number.MAX_SAFE_INTEGER instead of "inf"
  for unrestrict and generic error retries
- Fix extraction status applied to ALL items in package instead of only
  the items belonging to the currently extracting archive
- Make persistNow always async and item-completion stat async to reduce
  UI freezes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-01 20:42:03 +01:00
parent bf2b685e83
commit 4371e53b86
2 changed files with 78 additions and 13 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.68", "version": "1.4.69",
"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

@ -2389,7 +2389,33 @@ export class DownloadManager extends EventEmitter {
if (!this.session.running) { if (!this.session.running) {
return false; return false;
} }
const wasPaused = this.session.paused;
this.session.paused = !this.session.paused; this.session.paused = !this.session.paused;
// When unpausing: clear all retry delays so stuck queued items restart immediately,
// and abort long-stuck validating/downloading tasks so they get retried fresh.
if (wasPaused && !this.session.paused) {
this.retryAfterByItem.clear();
const now = nowMs();
for (const active of this.activeTasks.values()) {
if (active.abortController.signal.aborted) {
continue;
}
const item = this.session.items[active.itemId];
if (!item) {
continue;
}
const stuckSeconds = item.updatedAt > 0 ? (now - item.updatedAt) / 1000 : 0;
const isStuckValidating = item.status === "validating" && stuckSeconds > 30;
const isStuckDownloading = item.status === "downloading" && item.speedBps === 0 && stuckSeconds > 30;
if (isStuckValidating || isStuckDownloading) {
active.abortReason = "stall";
active.abortController.abort("stall");
}
}
}
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
return this.session.paused; return this.session.paused;
@ -2529,11 +2555,7 @@ export class DownloadManager extends EventEmitter {
private persistNow(): void { private persistNow(): void {
this.lastPersistAt = nowMs(); this.lastPersistAt = nowMs();
if (this.session.running) {
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`)); void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
} else {
saveSession(this.storagePaths, this.session);
}
} }
private emitState(force = false): void { private emitState(force = false): void {
@ -3329,9 +3351,15 @@ export class DownloadManager extends EventEmitter {
} }
const finalTargetPath = String(item.targetPath || "").trim(); const finalTargetPath = String(item.targetPath || "").trim();
const fileSizeOnDisk = finalTargetPath && fs.existsSync(finalTargetPath) let fileSizeOnDisk = item.downloadedBytes;
? fs.statSync(finalTargetPath).size if (finalTargetPath) {
: item.downloadedBytes; try {
const stat = await fs.promises.stat(finalTargetPath);
fileSizeOnDisk = stat.size;
} catch {
// file does not exist
}
}
const expectsNonEmptyFile = (item.totalBytes || 0) > 0 || isArchiveLikePath(finalTargetPath || item.fileName); const expectsNonEmptyFile = (item.totalBytes || 0) > 0 || isArchiveLikePath(finalTargetPath || item.fileName);
if (expectsNonEmptyFile && fileSizeOnDisk <= 0) { if (expectsNonEmptyFile && fileSizeOnDisk <= 0) {
try { try {
@ -3491,7 +3519,7 @@ export class DownloadManager extends EventEmitter {
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) { if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1; active.unrestrictRetries += 1;
item.retries += 1; item.retries += 1;
this.queueRetry(item, active, Math.min(8000, 2000 * active.unrestrictRetries), `Unrestrict-Fehler, Retry ${active.unrestrictRetries}/${maxUnrestrictRetries}`); this.queueRetry(item, active, Math.min(8000, 2000 * active.unrestrictRetries), `Unrestrict-Fehler, Retry ${active.unrestrictRetries}/${retryDisplayLimit}`);
item.lastError = errorText; item.lastError = errorText;
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
@ -3501,7 +3529,7 @@ export class DownloadManager extends EventEmitter {
if (active.genericErrorRetries < maxGenericErrorRetries) { if (active.genericErrorRetries < maxGenericErrorRetries) {
active.genericErrorRetries += 1; active.genericErrorRetries += 1;
item.retries += 1; item.retries += 1;
this.queueRetry(item, active, Math.min(1200, 300 * active.genericErrorRetries), `Fehler erkannt, Auto-Retry ${active.genericErrorRetries}/${maxGenericErrorRetries}`); this.queueRetry(item, active, Math.min(1200, 300 * active.genericErrorRetries), `Fehler erkannt, Auto-Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
item.lastError = errorText; item.lastError = errorText;
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
@ -4387,6 +4415,7 @@ export class DownloadManager extends EventEmitter {
// Build set of item targetPaths belonging to ready archives // Build set of item targetPaths belonging to ready archives
const hybridItemPaths = new Set<string>(); const hybridItemPaths = new Set<string>();
const archiveToItems = new Map<string, DownloadItem[]>();
let dirFiles: string[] | undefined; let dirFiles: string[] | undefined;
try { try {
dirFiles = fs.readdirSync(pkg.outputDir, { withFileTypes: true }) dirFiles = fs.readdirSync(pkg.outputDir, { withFileTypes: true })
@ -4395,15 +4424,23 @@ export class DownloadManager extends EventEmitter {
} catch { /* ignore */ } } catch { /* ignore */ }
for (const archiveKey of readyArchives) { for (const archiveKey of readyArchives) {
const parts = collectArchiveCleanupTargets(archiveKey, dirFiles); const parts = collectArchiveCleanupTargets(archiveKey, dirFiles);
const partKeys = new Set<string>();
for (const part of parts) { for (const part of parts) {
hybridItemPaths.add(pathKey(part)); hybridItemPaths.add(pathKey(part));
partKeys.add(pathKey(part));
} }
hybridItemPaths.add(pathKey(archiveKey)); hybridItemPaths.add(pathKey(archiveKey));
partKeys.add(pathKey(archiveKey));
const matched = completedItems.filter((item) => item.targetPath && partKeys.has(pathKey(item.targetPath)));
if (matched.length > 0) {
archiveToItems.set(path.basename(archiveKey).toLowerCase(), matched);
}
} }
const hybridItems = completedItems.filter((item) => const hybridItems = completedItems.filter((item) =>
item.targetPath && hybridItemPaths.has(pathKey(item.targetPath)) item.targetPath && hybridItemPaths.has(pathKey(item.targetPath))
); );
let currentArchiveItems: DownloadItem[] = hybridItems;
const updateExtractingStatus = (text: string): void => { const updateExtractingStatus = (text: string): void => {
const normalized = String(text || ""); const normalized = String(text || "");
if (hybridLastStatusText === normalized) { if (hybridLastStatusText === normalized) {
@ -4411,7 +4448,7 @@ export class DownloadManager extends EventEmitter {
} }
hybridLastStatusText = normalized; hybridLastStatusText = normalized;
const updatedAt = nowMs(); const updatedAt = nowMs();
for (const entry of hybridItems) { for (const entry of currentArchiveItems) {
if (isExtractedLabel(entry.fullStatus)) { if (isExtractedLabel(entry.fullStatus)) {
continue; continue;
} }
@ -4454,6 +4491,10 @@ export class DownloadManager extends EventEmitter {
if (progress.phase === "done") { if (progress.phase === "done") {
return; return;
} }
// Narrow status updates to only items belonging to the current archive
if (progress.archiveName) {
currentArchiveItems = archiveToItems.get(progress.archiveName.toLowerCase()) || hybridItems;
}
const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; const archive = progress.archiveName ? ` · ${progress.archiveName}` : "";
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
? ` · ${Math.floor(progress.elapsedMs / 1000)}s` ? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
@ -4536,6 +4577,26 @@ export class DownloadManager extends EventEmitter {
pkg.status = "extracting"; pkg.status = "extracting";
this.emitState(); this.emitState();
// Build map: archive basename -> items belonging to that archive set
const archiveToItems = new Map<string, DownloadItem[]>();
let dirFiles: string[] | undefined;
try {
dirFiles = fs.readdirSync(pkg.outputDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name);
} catch { /* ignore */ }
const candidates = findArchiveCandidates(pkg.outputDir);
for (const candidate of candidates) {
const parts = collectArchiveCleanupTargets(candidate, dirFiles);
const partKeys = new Set(parts.map((p) => pathKey(p)));
partKeys.add(pathKey(candidate));
const matched = completedItems.filter((item) => item.targetPath && partKeys.has(pathKey(item.targetPath)));
if (matched.length > 0) {
archiveToItems.set(path.basename(candidate).toLowerCase(), matched);
}
}
let currentArchiveItems: DownloadItem[] = completedItems;
const updateExtractingStatus = (text: string): void => { const updateExtractingStatus = (text: string): void => {
const normalized = String(text || ""); const normalized = String(text || "");
if (lastExtractStatusText === normalized) { if (lastExtractStatusText === normalized) {
@ -4543,7 +4604,7 @@ export class DownloadManager extends EventEmitter {
} }
lastExtractStatusText = normalized; lastExtractStatusText = normalized;
const updatedAt = nowMs(); const updatedAt = nowMs();
for (const entry of completedItems) { for (const entry of currentArchiveItems) {
if (isExtractedLabel(entry.fullStatus)) { if (isExtractedLabel(entry.fullStatus)) {
continue; continue;
} }
@ -4607,6 +4668,10 @@ export class DownloadManager extends EventEmitter {
signal: extractAbortController.signal, signal: extractAbortController.signal,
packageId, packageId,
onProgress: (progress) => { onProgress: (progress) => {
// Narrow status updates to only items belonging to the current archive
if (progress.archiveName) {
currentArchiveItems = archiveToItems.get(progress.archiveName.toLowerCase()) || completedItems;
}
const label = progress.phase === "done" const label = progress.phase === "done"
? "Entpacken 100%" ? "Entpacken 100%"
: (() => { : (() => {