Release v1.4.29 with downloader and API safety hardening
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-02-28 21:31:42 +01:00
parent 84d8f37ba6
commit eda9754d30
8 changed files with 366 additions and 123 deletions

4
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 = [
/<meta[^>]+(?:property=["']og:title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+property=["']og:title["'])/i,
/<meta[^>]+(?:name=["']title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+name=["']title["'])/i,
/<title>([^<]+)<\/title>/i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*</i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]+)/i,
/download\s+file\s+([^<\r\n]+)/i
/<title>([^<]{1,260})<\/title>/i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]{1,260})\s*</i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]{1,260})/i
];
for (const pattern of patterns) {
@ -314,6 +348,48 @@ function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number):
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
}
async function readResponseTextLimited(response: Response, maxBytes: number, signal?: AbortSignal): Promise<string> {
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<string> {
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<UnrestrictedLink> {
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
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<UnrestrictedLink> {
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
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<UnrestrictedLink> {
private async tryRequest(request: BestDebridRequest, originalLink: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
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<Map<string, string>> {
public async getLinkInfos(links: string[], signal?: AbortSignal): Promise<Map<string, string>> {
const result = new Map<string, string>();
const canonicalToInput = new Map<string, string>();
const uniqueLinks: string[] = [];
@ -542,6 +632,9 @@ class AllDebridClient {
}
for (let index = 0; index < uniqueLinks.length; index += 32) {
if (signal?.aborted) {
throw new Error("aborted:debrid");
}
const chunk = uniqueLinks.slice(index, index + 32);
const body = new URLSearchParams();
for (const link of chunk) {
@ -562,7 +655,7 @@ class AllDebridClient {
"User-Agent": DEBRID_USER_AGENT
},
body,
signal: AbortSignal.timeout(API_TIMEOUT_MS)
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
text = await response.text();
@ -570,7 +663,7 @@ class AllDebridClient {
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);
@ -594,10 +687,13 @@ class AllDebridClient {
break;
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || /aborted/i.test(errorText)) {
throw error;
}
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
throw error;
}
await sleep(retryDelay(attempt));
await sleepWithSignal(retryDelay(attempt), signal);
}
}
@ -607,6 +703,11 @@ class AllDebridClient {
const data = asRecord(payload?.data);
const infos = Array.isArray(data?.infos) ? data.infos : [];
const hasAnyLinkedInfo = infos.some((entry) => {
const info = asRecord(entry);
return Boolean(pickString(info, ["link"]));
});
const allowPositionalFallback = infos.length === chunk.length && !hasAnyLinkedInfo;
for (let i = 0; i < infos.length; i += 1) {
const info = asRecord(infos[i]);
if (!info) {
@ -621,6 +722,8 @@ class AllDebridClient {
const byResponse = canonicalToInput.get(canonicalLink(responseLink));
const byIndex = chunk.length === 1
? chunk[0]
: allowPositionalFallback
? chunk[i]
: "";
const original = byResponse || byIndex;
if (!original) {
@ -633,7 +736,7 @@ class AllDebridClient {
return result;
}
public async unrestrictLink(link: string): Promise<UnrestrictedLink> {
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
try {
@ -645,7 +748,7 @@ class AllDebridClient {
"User-Agent": DEBRID_USER_AGENT
},
body: new URLSearchParams({ link }),
signal: AbortSignal.timeout(API_TIMEOUT_MS)
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const text = await response.text();
const payload = asRecord(parseJson(text));
@ -653,7 +756,7 @@ class AllDebridClient {
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);
@ -687,10 +790,13 @@ class AllDebridClient {
};
} 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);
}
}
@ -704,12 +810,12 @@ export class DebridService {
private options: DebridServiceOptions;
public constructor(settings: AppSettings, options: DebridServiceOptions = {}) {
this.settings = settings;
this.settings = cloneSettings(settings);
this.options = options;
}
public setSettings(next: AppSettings): void {
this.settings = next;
this.settings = cloneSettings(next);
}
public async resolveFilenames(
@ -717,7 +823,7 @@ export class DebridService {
onResolved?: (link: string, fileName: string) => void,
signal?: AbortSignal
): Promise<Map<string, string>> {
const settings = { ...this.settings };
const settings = cloneSettings(this.settings);
const allDebridClient = new AllDebridClient(settings.allDebridToken);
const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link)));
if (unresolved.length === 0) {
@ -740,7 +846,7 @@ export class DebridService {
const token = settings.allDebridToken.trim();
if (token) {
try {
const infos = await allDebridClient.getLinkInfos(unresolved);
const infos = await allDebridClient.getLinkInfos(unresolved, signal);
for (const [link, fileName] of infos.entries()) {
reportResolved(link, fileName);
}
@ -755,21 +861,11 @@ export class DebridService {
reportResolved(link, fromPage);
});
const stillUnresolved = unresolved.filter((link) => !clean.has(link) && !isRapidgatorLink(link));
await runWithConcurrency(stillUnresolved, 4, async (link) => {
try {
const unrestricted = await this.unrestrictLink(link, signal, settings);
reportResolved(link, unrestricted.fileName || "");
} catch {
// ignore final fallback errors
}
});
return clean;
}
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
const settings = settingsSnapshot ? { ...settingsSnapshot } : { ...this.settings };
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
const order = toProviderOrder(
settings.providerPrimary,
settings.providerSecondary,
@ -855,11 +951,11 @@ export class DebridService {
return new RealDebridClient(settings.token).unrestrictLink(link, signal);
}
if (provider === "megadebrid") {
return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link);
return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link, signal);
}
if (provider === "alldebrid") {
return new AllDebridClient(settings.allDebridToken).unrestrictLink(link);
return new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal);
}
return new BestDebridClient(settings.bestToken).unrestrictLink(link);
return new BestDebridClient(settings.bestToken).unrestrictLink(link, signal);
}
}

View File

@ -153,7 +153,7 @@ function parseContentDispositionFilename(contentDisposition: string | null): str
function isArchiveLikePath(filePath: string): boolean {
const lower = path.basename(filePath).toLowerCase();
return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower);
return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|z\d{1,3}|7z(?:\.\d+)?)$/i.test(lower);
}
function isFetchFailure(errorText: string): boolean {
@ -543,6 +543,7 @@ export class DownloadManager extends EventEmitter {
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
this.retryAfterByItem.delete(itemId);
this.dropItemContribution(itemId);
if (!hasActiveTask) {
this.releaseTargetPath(itemId);
}
@ -742,7 +743,7 @@ export class DownloadManager extends EventEmitter {
totalBytes: null,
progressPercent: 0,
fileName,
targetPath: path.join(outputDir, fileName),
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
@ -750,6 +751,7 @@ export class DownloadManager extends EventEmitter {
createdAt: nowMs(),
updatedAt: nowMs()
};
this.assignItemTargetPath(item, path.join(outputDir, fileName));
packageEntry.itemIds.push(itemId);
this.session.items[itemId] = item;
this.itemCount += 1;
@ -901,7 +903,7 @@ export class DownloadManager extends EventEmitter {
item.lastError = "";
item.fullStatus = "Wartet";
item.updatedAt = nowMs();
item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)));
this.assignItemTargetPath(item, path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url))));
this.runOutcomes.delete(itemId);
this.itemContributedBytes.delete(itemId);
this.retryAfterByItem.delete(itemId);
@ -974,7 +976,7 @@ export class DownloadManager extends EventEmitter {
continue;
}
item.fileName = normalized;
item.targetPath = path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, normalized);
this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, normalized));
item.updatedAt = nowMs();
changed = true;
changedForLink = true;
@ -1551,11 +1553,6 @@ export class DownloadManager extends EventEmitter {
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";
@ -1716,18 +1713,30 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.set(itemId, status);
}
private dropItemContribution(itemId: string): void {
const contributed = this.itemContributedBytes.get(itemId) || 0;
if (contributed > 0) {
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - contributed);
}
this.itemContributedBytes.delete(itemId);
}
private claimTargetPath(itemId: string, preferredPath: string, allowExistingFile = false): string {
const preferredKey = pathKey(preferredPath);
const existingClaim = this.claimedTargetPathByItem.get(itemId);
if (existingClaim) {
const owner = this.reservedTargetPaths.get(pathKey(existingClaim));
const existingKey = pathKey(existingClaim);
const owner = this.reservedTargetPaths.get(existingKey);
if (owner === itemId) {
if (existingKey === preferredKey) {
return existingClaim;
}
this.reservedTargetPaths.delete(existingKey);
}
this.claimedTargetPathByItem.delete(itemId);
}
const parsed = path.parse(preferredPath);
const preferredKey = pathKey(preferredPath);
const maxIndex = 10000;
for (let index = 0; index <= maxIndex; index += 1) {
const candidate = index === 0
@ -1764,6 +1773,19 @@ export class DownloadManager extends EventEmitter {
this.claimedTargetPathByItem.delete(itemId);
}
private assignItemTargetPath(item: DownloadItem, targetPath: string): string {
const rawTargetPath = String(targetPath || "").trim();
if (!rawTargetPath) {
this.releaseTargetPath(item.id);
item.targetPath = "";
return "";
}
const normalizedTargetPath = path.resolve(rawTargetPath);
const claimed = this.claimTargetPath(item.id, normalizedTargetPath);
item.targetPath = claimed;
return claimed;
}
private abortPostProcessing(reason: string): void {
for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) {
if (!controller.signal.aborted) {
@ -1943,6 +1965,8 @@ export class DownloadManager extends EventEmitter {
this.packagePostProcessTasks.delete(packageId);
for (const itemId of itemIds) {
this.retryAfterByItem.delete(itemId);
this.releaseTargetPath(itemId);
this.dropItemContribution(itemId);
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
}
@ -2215,6 +2239,8 @@ export class DownloadManager extends EventEmitter {
item.attempts = 0;
active.abortController = new AbortController();
active.abortReason = "none";
// Caller returns immediately after this; startItem().finally releases the active slot,
// so the retry backoff never blocks a worker.
this.retryAfterByItem.set(item.id, nowMs() + waitMs);
}
@ -2306,6 +2332,12 @@ export class DownloadManager extends EventEmitter {
let done = false;
while (!done && item.attempts < maxAttempts) {
item.attempts += 1;
if (item.status !== "downloading") {
item.status = "downloading";
item.fullStatus = `Download läuft (${providerLabel(item.provider)})`;
item.updatedAt = nowMs();
this.emitState();
}
const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes);
active.resumable = result.resumable;
if (!active.resumable && !active.nonResumableCounted) {
@ -2416,6 +2448,7 @@ export class DownloadManager extends EventEmitter {
item.downloadedBytes = 0;
item.progressPercent = 0;
item.totalBytes = null;
this.dropItemContribution(item.id);
} else if (reason === "stop") {
item.status = "cancelled";
item.fullStatus = "Gestoppt";
@ -2424,6 +2457,7 @@ export class DownloadManager extends EventEmitter {
item.downloadedBytes = 0;
item.progressPercent = 0;
item.totalBytes = null;
this.dropItemContribution(item.id);
}
} else if (reason === "shutdown") {
item.status = "queued";
@ -2466,6 +2500,7 @@ export class DownloadManager extends EventEmitter {
item.downloadedBytes = 0;
item.totalBytes = null;
item.progressPercent = 0;
this.dropItemContribution(item.id);
item.status = "failed";
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
@ -2997,6 +3032,11 @@ export class DownloadManager extends EventEmitter {
}
if (item.status === "completed" && hasZeroByteArchive) {
const maxCompletedZeroByteAutoRetries = Math.max(2, REQUEST_RETRIES);
if (item.retries >= maxCompletedZeroByteAutoRetries) {
continue;
}
item.retries += 1;
this.queueItemForRetry(item, {
hardReset: true,
reason: "Wartet (Auto-Retry: 0B-Datei)"
@ -3033,6 +3073,7 @@ export class DownloadManager extends EventEmitter {
item.downloadedBytes = 0;
item.totalBytes = null;
item.progressPercent = 0;
this.dropItemContribution(item.id);
}
item.status = "queued";
@ -3327,12 +3368,12 @@ export class DownloadManager extends EventEmitter {
if (/\.zip\.001$/i.test(entryPointName)) {
const stem = entryPointName.replace(/\.zip\.001$/i, "").toLowerCase();
const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped}\\.zip(\\.\\d{3})?$`, "i").test(fileName);
return new RegExp(`^${escaped}\\.zip(\\.\\d+)?$`, "i").test(fileName);
}
if (/\.7z\.001$/i.test(entryPointName)) {
const stem = entryPointName.replace(/\.7z\.001$/i, "").toLowerCase();
const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped}\\.7z(\\.\\d{3})?$`, "i").test(fileName);
return new RegExp(`^${escaped}\\.7z(\\.\\d+)?$`, "i").test(fileName);
}
return false;
}
@ -3651,7 +3692,8 @@ export class DownloadManager extends EventEmitter {
}
private applyPackageDoneCleanup(packageId: string): void {
if (this.settings.completedCleanupPolicy !== "package_done") {
const policy = this.settings.completedCleanupPolicy;
if (policy !== "package_done" && policy !== "immediate") {
return;
}
@ -3660,6 +3702,13 @@ export class DownloadManager extends EventEmitter {
return;
}
if (policy === "immediate") {
for (const itemId of [...pkg.itemIds]) {
this.applyCompletedCleanupPolicy(packageId, itemId);
}
return;
}
const allCompleted = pkg.itemIds.every((itemId) => {
const item = this.session.items[itemId];
return !item || item.status === "completed";
@ -3691,6 +3740,8 @@ export class DownloadManager extends EventEmitter {
}
}
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
this.releaseTargetPath(itemId);
this.dropItemContribution(itemId);
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
this.retryAfterByItem.delete(itemId);

View File

@ -6,7 +6,9 @@ export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackag
for (const pkg of packages) {
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
const list = grouped.get(name) ?? [];
list.push(...pkg.links);
for (const link of pkg.links) {
list.push(link);
}
grouped.set(name, list);
}
return Array.from(grouped.entries()).map(([name, links]) => ({

View File

@ -1,7 +1,7 @@
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
import { compactErrorText, sleep } from "./utils";
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.28";
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29";
export interface UnrestrictedLink {
fileName: string;
@ -18,6 +18,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;
@ -89,7 +116,7 @@ export class RealDebridClient {
if (!response.ok) {
const parsed = parseErrorBody(response.status, text, contentType);
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
await sleep(retryDelay(attempt));
await sleep(retryDelayForResponse(response, attempt));
continue;
}
throw new Error(parsed);

View File

@ -123,6 +123,7 @@ export function App(): ReactElement {
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
const [settingsDirty, setSettingsDirty] = useState(false);
const settingsDirtyRef = useRef(false);
const settingsDraftRevisionRef = useRef(0);
const latestStateRef = useRef<UiSnapshot | null>(null);
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -334,17 +335,22 @@ export function App(): ReactElement {
return;
}
setCollapsedPackages((prev) => {
let changed = false;
const next: Record<string, boolean> = { ...prev };
const defaultCollapsed = totalPackageCount >= 24;
for (const packageId of snapshot.session.packageOrder) {
next[packageId] = prev[packageId] ?? defaultCollapsed;
if (!(packageId in prev)) {
next[packageId] = defaultCollapsed;
changed = true;
}
}
for (const packageId of Object.keys(next)) {
if (!snapshot.session.packages[packageId]) {
delete next[packageId];
changed = true;
}
}
return next;
return changed ? next : prev;
});
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
@ -472,10 +478,13 @@ export function App(): ReactElement {
};
const persistDraftSettings = async (): Promise<AppSettings> => {
const revisionAtStart = settingsDraftRevisionRef.current;
const result = await window.rd.updateSettings(normalizedSettingsDraft);
if (settingsDraftRevisionRef.current === revisionAtStart) {
setSettingsDraft(result);
settingsDirtyRef.current = false;
setSettingsDirty(false);
}
return result;
};
@ -663,6 +672,7 @@ export function App(): ReactElement {
};
input.onchange = async () => {
window.removeEventListener("focus", onWindowFocus);
const file = input.files?.[0];
if (!file) {
releasePickerBusy();
@ -683,22 +693,26 @@ export function App(): ReactElement {
};
const setBool = (key: keyof AppSettings, value: boolean): void => {
settingsDraftRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
};
const setText = (key: keyof AppSettings, value: string): void => {
settingsDraftRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
};
const setNum = (key: keyof AppSettings, value: number): void => {
settingsDraftRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
};
const setSpeedLimitMbps = (value: number): void => {
const mbps = Number.isFinite(value) ? Math.max(0, value) : 0;
settingsDraftRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
@ -747,8 +761,10 @@ export function App(): ReactElement {
[order[idx], order[target]] = [order[target], order[idx]];
setDownloadsSortDescending(false);
packageOrderRef.current = order;
void window.rd.reorderPackages(order);
}, []);
void window.rd.reorderPackages(order).catch((error) => {
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
});
}, [showToast]);
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
const currentOrder = packageOrderRef.current;
@ -760,8 +776,10 @@ export function App(): ReactElement {
}
setDownloadsSortDescending(false);
packageOrderRef.current = nextOrder;
void window.rd.reorderPackages(nextOrder);
}, []);
void window.rd.reorderPackages(nextOrder).catch((error) => {
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
});
}, [showToast]);
const addCollectorTab = (): void => {
const id = `tab-${nextCollectorId++}`;
@ -810,8 +828,54 @@ export function App(): ReactElement {
draggedPackageIdRef.current = null;
}, []);
const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => {
setEditingPackageId(packageId);
setEditingName(packageName);
}, []);
const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => {
setEditingPackageId(null);
const normalized = nextName.trim();
if (normalized && normalized !== currentName.trim()) {
void window.rd.renamePackage(packageId, normalized).catch((error) => {
showToast(`Umbenennen fehlgeschlagen: ${String(error)}`, 2400);
});
}
}, [showToast]);
const onPackageToggleCollapse = useCallback((packageId: string): void => {
setCollapsedPackages((prev) => ({ ...prev, [packageId]: !(prev[packageId] ?? false) }));
}, []);
const onPackageCancel = useCallback((packageId: string): void => {
void window.rd.cancelPackage(packageId).catch((error) => {
showToast(`Paket-Löschung fehlgeschlagen: ${String(error)}`, 2400);
});
}, [showToast]);
const onPackageMoveUp = useCallback((packageId: string): void => {
movePackage(packageId, "up");
}, [movePackage]);
const onPackageMoveDown = useCallback((packageId: string): void => {
movePackage(packageId, "down");
}, [movePackage]);
const onPackageToggle = useCallback((packageId: string): void => {
void window.rd.togglePackage(packageId).catch((error) => {
showToast(`Paket-Umschalten fehlgeschlagen: ${String(error)}`, 2400);
});
}, [showToast]);
const onPackageRemoveItem = useCallback((itemId: string): void => {
void window.rd.removeItem(itemId).catch((error) => {
showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 2400);
});
}, [showToast]);
const schedules = settingsDraft.bandwidthSchedules ?? [];
const addSchedule = (): void => {
settingsDraftRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({
@ -820,6 +884,7 @@ export function App(): ReactElement {
}));
};
const removeSchedule = (idx: number): void => {
settingsDraftRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({
@ -828,6 +893,7 @@ export function App(): ReactElement {
}));
};
const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => {
settingsDraftRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
setSettingsDraft((prev) => ({
@ -1038,25 +1104,17 @@ export function App(): ReactElement {
isEditing={editingPackageId === pkg.id}
editingName={editingName}
collapsed={collapsedPackages[pkg.id] ?? false}
onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }}
onFinishEdit={(name) => {
setEditingPackageId(null);
const nextName = name.trim();
if (nextName && nextName !== pkg.name.trim()) {
void window.rd.renamePackage(pkg.id, nextName);
}
}}
onStartEdit={onPackageStartEdit}
onFinishEdit={onPackageFinishEdit}
onEditChange={setEditingName}
onToggleCollapse={() => {
setCollapsedPackages((prev) => ({ ...prev, [pkg.id]: !(prev[pkg.id] ?? false) }));
}}
onCancel={() => { void window.rd.cancelPackage(pkg.id); }}
onMoveUp={() => movePackage(pkg.id, "up")}
onMoveDown={() => movePackage(pkg.id, "down")}
onToggle={() => { void window.rd.togglePackage(pkg.id); }}
onRemoveItem={(itemId) => { void window.rd.removeItem(itemId); }}
onDragStart={() => onPackageDragStart(pkg.id)}
onDrop={() => onPackageDrop(pkg.id)}
onToggleCollapse={onPackageToggleCollapse}
onCancel={onPackageCancel}
onMoveUp={onPackageMoveUp}
onMoveDown={onPackageMoveDown}
onToggle={onPackageToggle}
onRemoveItem={onPackageRemoveItem}
onDragStart={onPackageDragStart}
onDrop={onPackageDrop}
onDragEnd={onPackageDragEnd}
/>
))}
@ -1309,17 +1367,17 @@ interface PackageCardProps {
isEditing: boolean;
editingName: string;
collapsed: boolean;
onStartEdit: () => void;
onFinishEdit: (name: string) => void;
onStartEdit: (packageId: string, packageName: string) => void;
onFinishEdit: (packageId: string, currentName: string, nextName: string) => void;
onEditChange: (name: string) => void;
onToggleCollapse: () => void;
onCancel: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onToggle: () => void;
onToggleCollapse: (packageId: string) => void;
onCancel: (packageId: string) => void;
onMoveUp: (packageId: string) => void;
onMoveDown: (packageId: string) => void;
onToggle: (packageId: string) => void;
onRemoveItem: (itemId: string) => void;
onDragStart: () => void;
onDrop: () => void;
onDragStart: (packageId: string) => void;
onDrop: (packageId: string) => void;
onDragEnd: () => void;
}
@ -1331,27 +1389,27 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
const progress = Math.floor((done / total) * 100);
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter") { onFinishEdit(editingName); }
if (e.key === "Escape") { onFinishEdit(pkg.name); }
if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); }
if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); }
};
return (
<article
className={`package-card${pkg.enabled ? "" : " disabled-pkg"}`}
draggable
onDragStart={(event) => { event.stopPropagation(); onDragStart(); }}
onDragStart={(event) => { event.stopPropagation(); onDragStart(pkg.id); }}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(pkg.id); }}
onDragEnd={(event) => { event.stopPropagation(); onDragEnd(); }}
>
<header>
<div className="pkg-info">
<div className="pkg-name-row">
<input type="checkbox" checked={pkg.enabled} onChange={onToggle} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} />
<input type="checkbox" checked={pkg.enabled} onChange={() => onToggle(pkg.id)} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} />
{isEditing ? (
<input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(editingName)} onKeyDown={onKeyDown} autoFocus />
<input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(pkg.id, pkg.name, editingName)} onKeyDown={onKeyDown} autoFocus />
) : (
<h4 onDoubleClick={onStartEdit} title="Doppelklick zum Umbenennen">{pkg.name}</h4>
<h4 onDoubleClick={() => onStartEdit(pkg.id, pkg.name)} title="Doppelklick zum Umbenennen">{pkg.name}</h4>
)}
</div>
<span>{done}/{total} fertig {failed > 0 && `· ${failed} Fehler `}{cancelled > 0 && `· ${cancelled} abgebrochen `}
@ -1359,11 +1417,11 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
</span>
</div>
<div className="pkg-actions">
<button className="btn" onClick={onToggleCollapse}>{collapsed ? "Ausklappen" : "Einklappen"}</button>
<button className="btn" disabled={isFirst} onClick={onMoveUp} title="Nach oben">&#9650;</button>
<button className="btn" disabled={isLast} onClick={onMoveDown} title="Nach unten">&#9660;</button>
<button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={onToggle}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button>
<button className="btn danger" onClick={onCancel}>Paket löschen</button>
<button className="btn" onClick={() => onToggleCollapse(pkg.id)}>{collapsed ? "Ausklappen" : "Einklappen"}</button>
<button className="btn" disabled={isFirst} onClick={() => onMoveUp(pkg.id)} title="Nach oben">&#9650;</button>
<button className="btn" disabled={isLast} onClick={() => onMoveDown(pkg.id)} title="Nach unten">&#9660;</button>
<button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={() => onToggle(pkg.id)}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button>
<button className="btn danger" onClick={() => onCancel(pkg.id)}>Paket löschen</button>
</div>
</header>
<div className="progress"><div style={{ width: `${progress}%` }} /></div>

View File

@ -442,7 +442,7 @@ describe("debrid service", () => {
expect(resolved.get(link)).toBe("Bulletproof.S01E01.German.DL.DD20.Synced.720p.AmazonHD.h264-GDR.part01.rar");
});
it("falls back to provider unrestrict for unresolved filename scan", async () => {
it("does not unrestrict non-rapidgator links during filename scan", async () => {
const settings = {
...defaultSettings(),
token: "rd-token",
@ -455,6 +455,7 @@ describe("debrid service", () => {
const linkFromPage = "https://rapidgator.net/file/11111111111111111111111111111111";
const linkFromProvider = "https://hoster.example/file/22222222222222222222222222222222";
let unrestrictCalls = 0;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
@ -467,6 +468,7 @@ describe("debrid service", () => {
}
if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) {
unrestrictCalls += 1;
const body = init?.body;
const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || "");
const linkValue = new URLSearchParams(bodyText).get("link") || "";
@ -492,10 +494,10 @@ describe("debrid service", () => {
});
expect(resolved.get(linkFromPage)).toBe("from-page.part1.rar");
expect(resolved.get(linkFromProvider)).toBe("from-provider.part2.rar");
expect(resolved.has(linkFromProvider)).toBe(false);
expect(unrestrictCalls).toBe(0);
expect(events).toEqual(expect.arrayContaining([
{ link: linkFromPage, fileName: "from-page.part1.rar" },
{ link: linkFromProvider, fileName: "from-provider.part2.rar" }
{ link: linkFromPage, fileName: "from-page.part1.rar" }
]));
});
@ -533,7 +535,7 @@ describe("debrid service", () => {
expect(unrestrictCalls).toBe(0);
});
it("does not map AllDebrid filename infos by index when response link is missing", async () => {
it("maps AllDebrid filename infos by index when response link is missing", async () => {
const settings = {
...defaultSettings(),
token: "",
@ -572,7 +574,9 @@ describe("debrid service", () => {
const service = new DebridService(settings);
const resolved = await service.resolveFilenames([linkA, linkB]);
expect(resolved.size).toBe(0);
expect(resolved.get(linkA)).toBe("wrong-a.mkv");
expect(resolved.get(linkB)).toBe("wrong-b.mkv");
expect(resolved.size).toBe(2);
});
it("retries AllDebrid filename infos after transient server error", async () => {
@ -738,6 +742,11 @@ describe("extractRapidgatorFilenameFromHtml", () => {
expect(extractRapidgatorFilenameFromHtml("")).toBe("");
});
it("ignores broad body text that is not a labeled filename", () => {
const html = "<html><body>Please download file now from mirror.mkv</body></html>";
expect(extractRapidgatorFilenameFromHtml(html)).toBe("");
});
it("extracts from File name label in page body", () => {
const html = '<html><body>File name: <b>Show.S02E03.720p.part01.rar</b></body></html>';
expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S02E03.720p.part01.rar");