diff --git a/package-lock.json b/package-lock.json index 1ca9132..d7ddb15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.11", + "version": "1.4.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.11", + "version": "1.4.12", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 4f663ae..1295f58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.11", + "version": "1.4.12", "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 516f2f2..93cb5bf 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -36,7 +36,9 @@ type ActiveTask = { nonResumableCounted: boolean; }; -const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 60000; +const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 30000; + +const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000; function getDownloadStallTimeoutMs(): number { const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN); @@ -46,6 +48,14 @@ function getDownloadStallTimeoutMs(): number { return DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS; } +function getDownloadConnectTimeoutMs(): number { + const fromEnv = Number(process.env.RD_CONNECT_TIMEOUT_MS ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 180000) { + return Math.floor(fromEnv); + } + return DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS; +} + type DownloadManagerOptions = { megaWebUnrestrict?: MegaWebUnrestrictor; }; @@ -1996,7 +2006,18 @@ export class DownloadManager extends EventEmitter { } let response: Response; + const connectTimeoutMs = getDownloadConnectTimeoutMs(); + let connectTimer: NodeJS.Timeout | null = null; try { + if (connectTimeoutMs > 0) { + connectTimer = setTimeout(() => { + if (active.abortController.signal.aborted) { + return; + } + active.abortReason = "stall"; + active.abortController.abort("stall"); + }, connectTimeoutMs); + } response = await fetch(directUrl, { method: "GET", headers, @@ -2015,6 +2036,10 @@ export class DownloadManager extends EventEmitter { continue; } throw error; + } finally { + if (connectTimer) { + clearTimeout(connectTimer); + } } if (!response.ok) { @@ -2138,6 +2163,29 @@ export class DownloadManager extends EventEmitter { } const reader = body.getReader(); const stallTimeoutMs = getDownloadStallTimeoutMs(); + let lastDataAt = nowMs(); + let lastIdleEmitAt = 0; + const idlePulseMs = Math.max(1500, Math.min(3500, Math.floor(stallTimeoutMs / 4) || 2000)); + const idleTimer = setInterval(() => { + if (active.abortController.signal.aborted) { + return; + } + const nowTick = nowMs(); + if (nowTick - lastDataAt < idlePulseMs) { + return; + } + if (item.status === "paused") { + return; + } + item.status = "downloading"; + item.speedBps = 0; + item.fullStatus = `Warte auf Daten (${providerLabel(item.provider)})`; + if (nowTick - lastIdleEmitAt >= idlePulseMs) { + item.updatedAt = nowTick; + this.emitState(); + lastIdleEmitAt = nowTick; + } + }, idlePulseMs); const readWithTimeout = async (): Promise> => { if (stallTimeoutMs <= 0) { return reader.read(); @@ -2172,63 +2220,68 @@ export class DownloadManager extends EventEmitter { }); }; - while (true) { - const { done, value } = await readWithTimeout(); - if (done) { - break; - } - const chunk = value; - if (active.abortController.signal.aborted) { - throw new Error(`aborted:${active.abortReason}`); - } - while (this.session.paused && this.session.running && !active.abortController.signal.aborted) { - item.status = "paused"; - item.fullStatus = "Pausiert"; - this.emitState(); - await sleep(120); - } - if (active.abortController.signal.aborted) { - throw new Error(`aborted:${active.abortReason}`); - } - if (this.reconnectActive() && active.resumable) { - active.abortReason = "reconnect"; - active.abortController.abort("reconnect"); - throw new Error("aborted:reconnect"); - } + try { + while (true) { + const { done, value } = await readWithTimeout(); + if (done) { + break; + } + const chunk = value; + lastDataAt = nowMs(); + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + while (this.session.paused && this.session.running && !active.abortController.signal.aborted) { + item.status = "paused"; + item.fullStatus = "Pausiert"; + this.emitState(); + await sleep(120); + } + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + if (this.reconnectActive() && active.resumable) { + active.abortReason = "reconnect"; + active.abortController.abort("reconnect"); + throw new Error("aborted:reconnect"); + } - const buffer = Buffer.from(chunk); - await this.applySpeedLimit(buffer.length, windowBytes, windowStarted); - if (active.abortController.signal.aborted) { - throw new Error(`aborted:${active.abortReason}`); - } - if (!stream.write(buffer)) { - await waitDrain(); - } - written += buffer.length; - windowBytes += buffer.length; - this.session.totalDownloadedBytes += buffer.length; - this.recordSpeed(buffer.length); + const buffer = Buffer.from(chunk); + await this.applySpeedLimit(buffer.length, windowBytes, windowStarted); + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + if (!stream.write(buffer)) { + await waitDrain(); + } + written += buffer.length; + windowBytes += buffer.length; + this.session.totalDownloadedBytes += buffer.length; + this.recordSpeed(buffer.length); - const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1); - const speed = windowBytes / elapsed; - if (elapsed >= 1.2) { - windowStarted = nowMs(); - windowBytes = 0; - } + const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1); + const speed = windowBytes / elapsed; + if (elapsed >= 1.2) { + windowStarted = nowMs(); + windowBytes = 0; + } - item.status = "downloading"; - item.speedBps = Math.max(0, Math.floor(speed)); - item.downloadedBytes = written; - 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)})`; - const nowTick = nowMs(); - const progressChanged = item.progressPercent !== lastProgressPercent; - if (progressChanged || nowTick - lastUiEmitAt >= uiUpdateIntervalMs) { - item.updatedAt = nowTick; - this.emitState(); - lastUiEmitAt = nowTick; - lastProgressPercent = item.progressPercent; + item.status = "downloading"; + item.speedBps = Math.max(0, Math.floor(speed)); + item.downloadedBytes = written; + 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)})`; + const nowTick = nowMs(); + const progressChanged = item.progressPercent !== lastProgressPercent; + if (progressChanged || nowTick - lastUiEmitAt >= uiUpdateIntervalMs) { + item.updatedAt = nowTick; + this.emitState(); + lastUiEmitAt = nowTick; + lastProgressPercent = item.progressPercent; + } } + } finally { + clearInterval(idleTimer); } } finally { await new Promise((resolve, reject) => { diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index fbfee42..c8a8be2 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -418,6 +418,210 @@ describe("download manager", () => { } }, 35000); + it("recovers when direct download connection stalls before first byte", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(220 * 1024, 23); + const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS; + const previousConnectTimeout = process.env.RD_CONNECT_TIMEOUT_MS; + process.env.RD_STALL_TIMEOUT_MS = "2500"; + process.env.RD_CONNECT_TIMEOUT_MS = "1800"; + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/connect-stall") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + if (directCalls === 1) { + setTimeout(() => { + if (res.destroyed || res.writableEnded) { + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }, 5200); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/connect-stall`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "connect-stall.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "connect-stall", links: ["https://dummy/connect-stall"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(directCalls).toBeGreaterThan(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + } finally { + if (previousStallTimeout === undefined) { + delete process.env.RD_STALL_TIMEOUT_MS; + } else { + process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout; + } + if (previousConnectTimeout === undefined) { + delete process.env.RD_CONNECT_TIMEOUT_MS; + } else { + process.env.RD_CONNECT_TIMEOUT_MS = previousConnectTimeout; + } + server.close(); + await once(server, "close"); + } + }, 35000); + + it("recovers when direct download stalls before first response bytes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(180 * 1024, 12); + const previousStallTimeout = process.env.RD_STALL_TIMEOUT_MS; + const previousConnectTimeout = process.env.RD_CONNECT_TIMEOUT_MS; + process.env.RD_STALL_TIMEOUT_MS = "2500"; + process.env.RD_CONNECT_TIMEOUT_MS = "2000"; + let directCalls = 0; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/stall-connect") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + directCalls += 1; + if (directCalls === 1) { + setTimeout(() => { + if (res.writableEnded || res.destroyed || res.headersSent) { + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }, 5000); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/stall-connect`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "stall-connect.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "stall-connect", links: ["https://dummy/stall-connect"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(directCalls).toBeGreaterThan(1); + } finally { + if (previousStallTimeout === undefined) { + delete process.env.RD_STALL_TIMEOUT_MS; + } else { + process.env.RD_STALL_TIMEOUT_MS = previousStallTimeout; + } + if (previousConnectTimeout === undefined) { + delete process.env.RD_CONNECT_TIMEOUT_MS; + } else { + process.env.RD_CONNECT_TIMEOUT_MS = previousConnectTimeout; + } + server.close(); + await once(server, "close"); + } + }, 35000); + it("uses content-disposition filename when provider filename is opaque", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);