From 05a75d0ac56179c433c2373c7c3d5b01b66f6471 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 27 Feb 2026 18:37:32 +0100 Subject: [PATCH] Release v1.4.5 with startup auto-recovery and lag hardening --- package-lock.json | 4 +- package.json | 2 +- src/main/download-manager.ts | 197 ++++++++++++++++++++++++++++++++- tests/download-manager.test.ts | 139 +++++++++++++++++++++++ 4 files changed, 335 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index b6337c0..9b61890 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.4", + "version": "1.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.4", + "version": "1.4.5", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index a283950..edc7488 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.4", + "version": "1.4.5", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index dbebe24..edb229c 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -36,7 +36,7 @@ type ActiveTask = { nonResumableCounted: boolean; }; -const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 120000; +const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 60000; function getDownloadStallTimeoutMs(): number { const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN); @@ -197,6 +197,7 @@ export class DownloadManager extends EventEmitter { this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict }); this.applyOnStartCleanupPolicy(); this.normalizeSessionStatuses(); + this.recoverRetryableItems("startup"); this.recoverPostProcessingOnStartup(); this.resolveExistingQueuedOpaqueFilenames(); this.cleanupExistingExtractedArchives(); @@ -253,7 +254,7 @@ export class DownloadManager extends EventEmitter { return { settings: this.settings, - session: this.getSession(), + session: this.session, summary: this.summary, stats: this.getStats(), speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, @@ -946,6 +947,13 @@ export class DownloadManager extends EventEmitter { if (this.session.running) { return; } + + const recoveredItems = this.recoverRetryableItems("start"); + if (recoveredItems > 0) { + this.persistSoon(); + this.emitState(true); + } + const runItems = Object.values(this.session.items) .filter((item) => { if (item.status !== "queued" && item.status !== "reconnect_wait") { @@ -1193,10 +1201,20 @@ export class DownloadManager extends EventEmitter { if (this.stateEmitTimer) { return; } + const itemCount = Object.keys(this.session.items).length; + const emitDelay = this.session.running + ? itemCount >= 1500 + ? 900 + : itemCount >= 700 + ? 650 + : itemCount >= 250 + ? 420 + : 280 + : 260; this.stateEmitTimer = setTimeout(() => { this.stateEmitTimer = null; this.emit("state", this.getSnapshot()); - }, 260); + }, emitDelay); } private pruneSpeedEvents(now: number): void { @@ -1210,7 +1228,13 @@ export class DownloadManager extends EventEmitter { private recordSpeed(bytes: number): void { const now = nowMs(); - this.speedEvents.push({ at: now, bytes }); + const bucket = now - (now % 120); + const last = this.speedEvents[this.speedEvents.length - 1]; + if (last && last.at === bucket) { + last.bytes += bytes; + } else { + this.speedEvents.push({ at: bucket, bytes }); + } this.speedBytesLastWindow += bytes; this.pruneSpeedEvents(now); } @@ -1599,6 +1623,26 @@ export class DownloadManager extends EventEmitter { } } + const finalTargetPath = String(item.targetPath || "").trim(); + const fileSizeOnDisk = finalTargetPath && fs.existsSync(finalTargetPath) + ? fs.statSync(finalTargetPath).size + : item.downloadedBytes; + const expectsNonEmptyFile = (item.totalBytes || 0) > 0 || isArchiveLikePath(finalTargetPath || item.fileName); + if (expectsNonEmptyFile && fileSizeOnDisk <= 0) { + try { + fs.rmSync(finalTargetPath, { force: true }); + } catch { + // ignore + } + this.releaseTargetPath(item.id); + item.downloadedBytes = 0; + item.progressPercent = 0; + item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null; + item.speedBps = 0; + item.updatedAt = nowMs(); + throw new Error("Leere Datei erkannt (0 B)"); + } + done = true; } item.status = "completed"; @@ -2034,6 +2078,151 @@ export class DownloadManager extends EventEmitter { throw new Error(lastError || "Download fehlgeschlagen"); } + private recoverRetryableItems(trigger: "startup" | "start"): number { + let recovered = 0; + const touchedPackages = new Set(); + + for (const packageId of this.session.packageOrder) { + const pkg = this.session.packages[packageId]; + if (!pkg || pkg.cancelled) { + continue; + } + + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item || item.status === "cancelled") { + continue; + } + + const is416Failure = this.isHttp416Failure(item); + const hasZeroByteArchive = this.hasZeroByteArchiveArtifact(item); + + if (item.status === "failed") { + this.queueItemForRetry(item, { + hardReset: is416Failure || hasZeroByteArchive, + reason: is416Failure + ? "Wartet (Auto-Retry: HTTP 416)" + : hasZeroByteArchive + ? "Wartet (Auto-Retry: 0B-Datei)" + : "Wartet (Auto-Retry)" + }); + recovered += 1; + touchedPackages.add(pkg.id); + continue; + } + + if (item.status === "completed" && hasZeroByteArchive) { + this.queueItemForRetry(item, { + hardReset: true, + reason: "Wartet (Auto-Retry: 0B-Datei)" + }); + recovered += 1; + touchedPackages.add(pkg.id); + } + } + } + + if (recovered > 0) { + for (const packageId of touchedPackages) { + const pkg = this.session.packages[packageId]; + if (!pkg) { + continue; + } + this.refreshPackageStatus(pkg); + } + logger.warn(`Auto-Retry-Recovery (${trigger}): ${recovered} Item(s) wieder in Queue gesetzt`); + } + + return recovered; + } + + private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void { + const targetPath = String(item.targetPath || "").trim(); + if (options.hardReset && targetPath) { + try { + fs.rmSync(targetPath, { force: true }); + } catch { + // ignore + } + this.releaseTargetPath(item.id); + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + } + + item.status = "queued"; + item.speedBps = 0; + item.attempts = 0; + item.lastError = ""; + item.resumable = true; + item.fullStatus = options.reason; + item.updatedAt = nowMs(); + } + + private isHttp416Failure(item: DownloadItem): boolean { + const text = `${item.lastError} ${item.fullStatus}`; + return /(^|\D)416(\D|$)/.test(text); + } + + private hasZeroByteArchiveArtifact(item: DownloadItem): boolean { + const targetPath = String(item.targetPath || "").trim(); + const archiveCandidate = isArchiveLikePath(targetPath || item.fileName); + if (!archiveCandidate) { + return false; + } + + if (targetPath && fs.existsSync(targetPath)) { + try { + return fs.statSync(targetPath).size <= 0; + } catch { + return false; + } + } + + if (item.downloadedBytes <= 0 && item.progressPercent >= 100) { + return true; + } + + return /\b0\s*B\b/i.test(item.fullStatus || ""); + } + + private refreshPackageStatus(pkg: PackageEntry): void { + const items = pkg.itemIds + .map((itemId) => this.session.items[itemId]) + .filter(Boolean) as DownloadItem[]; + if (items.length === 0) { + return; + } + + const hasPending = items.some((item) => ( + item.status === "queued" + || item.status === "reconnect_wait" + || item.status === "validating" + || item.status === "downloading" + || item.status === "paused" + || item.status === "extracting" + || item.status === "integrity_check" + )); + if (hasPending) { + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + return; + } + + const success = items.filter((item) => item.status === "completed").length; + const failed = items.filter((item) => item.status === "failed").length; + const cancelled = items.filter((item) => item.status === "cancelled").length; + + if (failed > 0) { + pkg.status = "failed"; + } else if (cancelled > 0 && success === 0) { + pkg.status = "cancelled"; + } else if (success > 0) { + pkg.status = "completed"; + } + pkg.updatedAt = nowMs(); + } + private getEffectiveSpeedLimitKbps(): number { const schedules = this.settings.bandwidthSchedules; if (schedules.length > 0) { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index e30740e..bd9c6c5 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -1159,6 +1159,145 @@ describe("download manager", () => { expect(snapshot.canStart).toBe(true); }); + it("requeues failed HTTP 416 items automatically on startup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "retry-416-pkg"; + const itemId = "retry-416-item"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "retry-416"); + const targetPath = path.join(outputDir, "broken.part03.rar"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(12 * 1024, 1)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "retry-416", + outputDir, + extractDir: path.join(root, "extract", "retry-416"), + status: "failed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/retry-416", + provider: "megadebrid", + status: "failed", + retries: 4, + speedBps: 0, + downloadedBytes: 12 * 1024, + totalBytes: 8 * 1024, + progressPercent: 100, + fileName: "broken.part03.rar", + targetPath, + resumable: true, + attempts: 3, + lastError: "Error: HTTP 416", + fullStatus: "Fehler: Error: HTTP 416", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.attempts).toBe(0); + expect(item?.downloadedBytes).toBe(0); + expect(item?.progressPercent).toBe(0); + expect(item?.fullStatus).toContain("Auto-Retry"); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + expect(fs.existsSync(targetPath)).toBe(false); + }); + + it("requeues completed zero-byte archive items automatically on startup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "zero-byte-pkg"; + const itemId = "zero-byte-item"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "zero-byte"); + const targetPath = path.join(outputDir, "archive.part01.rar"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(0)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "zero-byte", + outputDir, + extractDir: path.join(root, "extract", "zero-byte"), + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/zero-byte", + provider: "megadebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: null, + progressPercent: 100, + fileName: "archive.part01.rar", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (0 B)", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.downloadedBytes).toBe(0); + expect(item?.progressPercent).toBe(0); + expect(item?.fullStatus).toContain("0B-Datei"); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + expect(fs.existsSync(targetPath)).toBe(false); + }); + it("detects start conflicts when extract output already exists", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);