diff --git a/package-lock.json b/package-lock.json
index 55d84df..bc7247a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "real-debrid-downloader",
- "version": "1.4.28",
+ "version": "1.4.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
- "version": "1.4.28",
+ "version": "1.4.29",
"license": "MIT",
"dependencies": {
"adm-zip": "^0.5.16",
diff --git a/package.json b/package.json
index 0cdae5b..fc24180 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
- "version": "1.4.28",
+ "version": "1.4.29",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
diff --git a/src/main/debrid.ts b/src/main/debrid.ts
index b8f8d79..edd096d 100644
--- a/src/main/debrid.ts
+++ b/src/main/debrid.ts
@@ -5,7 +5,8 @@ import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
const API_TIMEOUT_MS = 30000;
-const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.28";
+const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29";
+const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4";
@@ -28,6 +29,13 @@ interface DebridServiceOptions {
megaWebUnrestrict?: MegaWebUnrestrictor;
}
+function cloneSettings(settings: AppSettings): AppSettings {
+ return {
+ ...settings,
+ bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry }))
+ };
+}
+
type BestDebridRequest = {
url: string;
useAuthHeader: boolean;
@@ -50,6 +58,33 @@ function retryDelay(attempt: number): number {
return Math.min(5000, 400 * 2 ** attempt);
}
+function parseRetryAfterMs(value: string | null): number {
+ const text = String(value || "").trim();
+ if (!text) {
+ return 0;
+ }
+
+ const asSeconds = Number(text);
+ if (Number.isFinite(asSeconds) && asSeconds >= 0) {
+ return Math.min(120000, Math.floor(asSeconds * 1000));
+ }
+
+ const asDate = Date.parse(text);
+ if (Number.isFinite(asDate)) {
+ return Math.min(120000, Math.max(0, asDate - Date.now()));
+ }
+
+ return 0;
+}
+
+function retryDelayForResponse(response: Response, attempt: number): number {
+ if (response.status !== 429) {
+ return retryDelay(attempt);
+ }
+ const fromHeader = parseRetryAfterMs(response.headers.get("retry-after"));
+ return fromHeader > 0 ? fromHeader : retryDelay(attempt);
+}
+
function readHttpStatusFromErrorText(text: string): number {
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
return match ? Number(match[1]) : 0;
@@ -226,7 +261,7 @@ export function normalizeResolvedFilename(value: string): string {
.replace(/^download\s+file\s+/i, "")
.replace(/\s*[-|]\s*rapidgator.*$/i, "")
.trim();
- if (!candidate || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) {
+ if (!candidate || candidate.length > 260 || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) {
return "";
}
return candidate;
@@ -253,10 +288,9 @@ export function extractRapidgatorFilenameFromHtml(html: string): string {
const patterns = [
/]+(?:property=["']og:title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+property=["']og:title["'])/i,
/]+(?:name=["']title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+name=["']title["'])/i,
- /
([^<]+)<\/title>/i,
- /(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*([^<]{1,260})<\/title>/i,
+ /(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]{1,260})\s* {
+ const body = response.body;
+ if (!body) {
+ return "";
+ }
+
+ const reader = body.getReader();
+ const chunks: Buffer[] = [];
+ let readBytes = 0;
+
+ try {
+ while (readBytes < maxBytes) {
+ if (signal?.aborted) {
+ throw new Error("aborted:debrid");
+ }
+
+ const { done, value } = await reader.read();
+ if (done || !value || value.byteLength === 0) {
+ break;
+ }
+
+ const remaining = maxBytes - readBytes;
+ const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value;
+ chunks.push(Buffer.from(slice));
+ readBytes += slice.byteLength;
+ }
+ } finally {
+ try {
+ await reader.cancel();
+ } catch {
+ // ignore
+ }
+ try {
+ reader.releaseLock();
+ } catch {
+ // ignore
+ }
+ }
+
+ return Buffer.concat(chunks).toString("utf8");
+}
+
async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Promise {
if (!isRapidgatorLink(link)) {
return "";
@@ -340,13 +416,14 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
});
if (!response.ok) {
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
- await sleepWithSignal(retryDelay(attempt), signal);
+ await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
return "";
}
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
+ const contentLength = Number(response.headers.get("content-length") || NaN);
if (contentType
&& !contentType.includes("text/html")
&& !contentType.includes("application/xhtml")
@@ -355,8 +432,11 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
&& !contentType.includes("application/xml")) {
return "";
}
+ if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) {
+ return "";
+ }
- const html = await response.text();
+ const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);
const fromHtml = extractRapidgatorFilenameFromHtml(html);
if (fromHtml) {
return fromHtml;
@@ -399,16 +479,22 @@ class MegaDebridClient {
this.megaWebUnrestrict = megaWebUnrestrict;
}
- public async unrestrictLink(link: string): Promise {
+ public async unrestrictLink(link: string, signal?: AbortSignal): Promise {
if (!this.megaWebUnrestrict) {
throw new Error("Mega-Web-Fallback nicht verfügbar");
}
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
+ if (signal?.aborted) {
+ throw new Error("aborted:debrid");
+ }
const web = await this.megaWebUnrestrict(link).catch((error) => {
lastError = compactErrorText(error);
return null;
});
+ if (signal?.aborted) {
+ throw new Error("aborted:debrid");
+ }
if (web?.directUrl) {
web.retriesUsed = attempt - 1;
return web;
@@ -420,7 +506,7 @@ class MegaDebridClient {
lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer";
}
if (attempt < REQUEST_RETRIES) {
- await sleep(retryDelay(attempt));
+ await sleepWithSignal(retryDelay(attempt), signal);
}
}
throw new Error(lastError || "Mega-Web Unrestrict fehlgeschlagen");
@@ -434,13 +520,13 @@ class BestDebridClient {
this.token = token;
}
- public async unrestrictLink(link: string): Promise {
+ public async unrestrictLink(link: string, signal?: AbortSignal): Promise {
const requests = buildBestDebridRequests(link, this.token);
let lastError = "";
for (const request of requests) {
try {
- return await this.tryRequest(request, link);
+ return await this.tryRequest(request, link, signal);
} catch (error) {
lastError = compactErrorText(error);
}
@@ -449,7 +535,7 @@ class BestDebridClient {
throw new Error(lastError || "BestDebrid Unrestrict fehlgeschlagen");
}
- private async tryRequest(request: BestDebridRequest, originalLink: string): Promise {
+ private async tryRequest(request: BestDebridRequest, originalLink: string, signal?: AbortSignal): Promise {
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
try {
@@ -463,7 +549,7 @@ class BestDebridClient {
const response = await fetch(request.url, {
method: "GET",
headers,
- signal: AbortSignal.timeout(API_TIMEOUT_MS)
+ signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const text = await response.text();
const parsed = parseJson(text);
@@ -472,7 +558,7 @@ class BestDebridClient {
if (!response.ok) {
const reason = parseError(response.status, text, payload);
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
- await sleep(retryDelay(attempt));
+ await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
throw new Error(reason);
@@ -480,13 +566,14 @@ class BestDebridClient {
const directUrl = pickString(payload, ["download", "debridLink", "link"]);
if (directUrl) {
+ let parsedDirect: URL;
try {
- const parsedDirect = new URL(directUrl);
- if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") {
- throw new Error("invalid_protocol");
- }
+ parsedDirect = new URL(directUrl);
} catch {
- throw new Error("BestDebrid Antwort enthält ungültige Download-URL");
+ throw new Error("BestDebrid Antwort enthält keine gültige Download-URL");
+ }
+ if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") {
+ throw new Error(`BestDebrid Antwort enthält ungültiges Download-URL-Protokoll (${parsedDirect.protocol})`);
}
const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink);
const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]);
@@ -506,10 +593,13 @@ class BestDebridClient {
throw new Error("BestDebrid Antwort ohne Download-Link");
} catch (error) {
lastError = compactErrorText(error);
+ if (signal?.aborted || /aborted/i.test(lastError)) {
+ break;
+ }
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break;
}
- await sleep(retryDelay(attempt));
+ await sleepWithSignal(retryDelay(attempt), signal);
}
}
throw new Error(String(lastError || "BestDebrid Request fehlgeschlagen").replace(/^Error:\s*/i, ""));
@@ -523,7 +613,7 @@ class AllDebridClient {
this.token = token;
}
- public async getLinkInfos(links: string[]): Promise