Release v1.4.16 with crash prevention and hang protection

- Add 30s fetch timeouts to ALL API calls (Real-Debrid, BestDebrid, AllDebrid, Mega-Web)
- Fix race condition in concurrent worker indexing (runWithConcurrency)
- Guard JSON.parse in RealDebrid response with try-catch
- Add try-catch to fs.mkdirSync in download pipeline (handles permission denied)
- Convert MD5/SHA1 hashing to streaming (prevents OOM on large files)
- Add error handling for hash manifest file reading
- Prevent infinite hangs on unresponsive API endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-02-27 21:43:40 +01:00
parent 147269849d
commit ea6301d326
7 changed files with 58 additions and 21 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.15", "version": "1.4.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.15", "version": "1.4.16",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

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

@ -1,8 +1,11 @@
import { AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types"; import { AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types";
import { REQUEST_RETRIES } from "./constants"; import { REQUEST_RETRIES } from "./constants";
import { logger } from "./logger";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
const API_TIMEOUT_MS = 30000;
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4"; const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4";
@ -216,11 +219,19 @@ async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (i
} }
const size = Math.max(1, Math.min(concurrency, items.length)); const size = Math.max(1, Math.min(concurrency, items.length));
let index = 0; let index = 0;
const next = (): T | undefined => {
if (index >= items.length) {
return undefined;
}
const item = items[index];
index += 1;
return item;
};
const runners = Array.from({ length: size }, async () => { const runners = Array.from({ length: size }, async () => {
while (index < items.length) { let current = next();
const current = items[index]; while (current !== undefined) {
index += 1;
await worker(current); await worker(current);
current = next();
} }
}); });
await Promise.all(runners); await Promise.all(runners);
@ -243,7 +254,8 @@ async function resolveRapidgatorFilename(link: string): Promise<string> {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,de;q=0.8" "Accept-Language": "en-US,en;q=0.9,de;q=0.8"
} },
signal: AbortSignal.timeout(API_TIMEOUT_MS)
}); });
if (!response.ok) { if (!response.ok) {
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) { if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
@ -348,7 +360,8 @@ class BestDebridClient {
const response = await fetch(request.url, { const response = await fetch(request.url, {
method: "GET", method: "GET",
headers headers,
signal: AbortSignal.timeout(API_TIMEOUT_MS)
}); });
const text = await response.text(); const text = await response.text();
const parsed = parseJson(text); const parsed = parseJson(text);
@ -432,7 +445,8 @@ class AllDebridClient {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.15" "User-Agent": "RD-Node-Downloader/1.1.15"
}, },
body body,
signal: AbortSignal.timeout(API_TIMEOUT_MS)
}); });
const text = await response.text(); const text = await response.text();
@ -484,7 +498,8 @@ class AllDebridClient {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.12" "User-Agent": "RD-Node-Downloader/1.1.12"
}, },
body: new URLSearchParams({ link }) body: new URLSearchParams({ link }),
signal: AbortSignal.timeout(API_TIMEOUT_MS)
}); });
const text = await response.text(); const text = await response.text();
const payload = asRecord(parseJson(text)); const payload = asRecord(parseJson(text));

View File

@ -1851,7 +1851,11 @@ export class DownloadManager extends EventEmitter {
item.provider = unrestricted.provider; item.provider = unrestricted.provider;
item.retries += unrestricted.retriesUsed; item.retries += unrestricted.retriesUsed;
item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
fs.mkdirSync(pkg.outputDir, { recursive: true }); try {
fs.mkdirSync(pkg.outputDir, { recursive: true });
} catch (mkdirError) {
throw new Error(`Zielordner kann nicht erstellt werden: ${compactErrorText(mkdirError)}`);
}
const existingTargetPath = String(item.targetPath || "").trim(); const existingTargetPath = String(item.targetPath || "").trim();
const canReuseExistingTarget = existingTargetPath const canReuseExistingTarget = existingTargetPath
&& isPathInsideDir(existingTargetPath, pkg.outputDir) && isPathInsideDir(existingTargetPath, pkg.outputDir)

View File

@ -50,7 +50,12 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
continue; continue;
} }
const filePath = path.join(packageDir, entry.name); const filePath = path.join(packageDir, entry.name);
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); let lines: string[];
try {
lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
} catch {
continue;
}
for (const line of lines) { for (const line of lines) {
const parsed = parseHashLine(line); const parsed = parseHashLine(line);
if (!parsed) { if (!parsed) {
@ -93,9 +98,12 @@ async function hashFile(filePath: string, algorithm: "crc32" | "md5" | "sha1"):
} }
const hash = crypto.createHash(algorithm); const hash = crypto.createHash(algorithm);
const data = fs.readFileSync(filePath); const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
hash.update(data); return await new Promise<string>((resolve, reject) => {
return hash.digest("hex").toLowerCase(); stream.on("data", (chunk: string | Buffer) => hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex").toLowerCase()));
});
} }
export async function validateFileAgainstManifest(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }> { export async function validateFileAgainstManifest(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }> {

View File

@ -155,7 +155,8 @@ export class MegaWebFallback {
password, password,
remember: "on" remember: "on"
}), }),
redirect: "manual" redirect: "manual",
signal: AbortSignal.timeout(30000)
}); });
const cookie = parseSetCookieFromHeaders(response.headers); const cookie = parseSetCookieFromHeaders(response.headers);
@ -169,7 +170,8 @@ export class MegaWebFallback {
"User-Agent": "Mozilla/5.0", "User-Agent": "Mozilla/5.0",
Cookie: cookie, Cookie: cookie,
Referer: DEBRID_REFERER Referer: DEBRID_REFERER
} },
signal: AbortSignal.timeout(30000)
}); });
const verifyHtml = await verify.text(); const verifyHtml = await verify.text();
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml); const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
@ -194,7 +196,8 @@ export class MegaWebFallback {
links: link, links: link,
password: "", password: "",
showLinks: "1" showLinks: "1"
}) }),
signal: AbortSignal.timeout(30000)
}); });
const html = await page.text(); const html = await page.text();
@ -215,7 +218,8 @@ export class MegaWebFallback {
body: new URLSearchParams({ body: new URLSearchParams({
code, code,
autodl: "0" autodl: "0"
}) }),
signal: AbortSignal.timeout(15000)
}); });
const text = (await res.text()).trim(); const text = (await res.text()).trim();

View File

@ -40,7 +40,8 @@ export class RealDebridClient {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "RD-Node-Downloader/1.1.12" "User-Agent": "RD-Node-Downloader/1.1.12"
}, },
body body,
signal: AbortSignal.timeout(30000)
}); });
const text = await response.text(); const text = await response.text();
@ -53,7 +54,12 @@ export class RealDebridClient {
throw new Error(parsed); throw new Error(parsed);
} }
const payload = JSON.parse(text) as Record<string, unknown>; let payload: Record<string, unknown>;
try {
payload = JSON.parse(text) as Record<string, unknown>;
} catch {
throw new Error(`Ungültige JSON-Antwort: ${text.slice(0, 120)}`);
}
const directUrl = String(payload.download || payload.link || "").trim(); const directUrl = String(payload.download || payload.link || "").trim();
if (!directUrl) { if (!directUrl) {
throw new Error("Unrestrict ohne Download-URL"); throw new Error("Unrestrict ohne Download-URL");