release: prepare v2.0.70

Ship authenticated Real-Debrid browser-form generation with isolated lifecycle recovery, restore reliable download sorting and clipboard handling from the post-v2.0.67 fixes, and localize item-log timestamps while preserving machine-readable runtime logs.
This commit is contained in:
Sucukdeluxe
2026-08-24 04:38:27 +02:00
parent 7bd31185b0
commit 06e5bf4340
11 changed files with 1280 additions and 242 deletions
+24
View File
@@ -4,6 +4,30 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
## [Unreleased]
## [2.0.70] - 2026-08-24
### Real-Debrid browser accounts
- Generate unrestricted links through the authenticated Real-Debrid downloader form inside an isolated background browser session instead of routing browser accounts through the API-token path.
- Keep browser sessions separated per account, avoid background login popups, and recover safely from cancellation, page-load stalls, timeouts, renderer crashes, account disablement, deletion, and shutdown.
- Validate generated download URLs against Real-Debrid download hosts and preserve exact filenames and file sizes without accepting foreign or insecure targets.
- Report website login and provider errors clearly, reject multi-file folder links instead of silently returning an incomplete result, and keep API-token accounts unchanged.
### Download table
- Restore header sorting without interfering with column dragging and add sorting for the Service column.
- Sort packages from their currently visible rows and preserve definitive link availability across reset operations.
- Keep active, integrity-checked, and completed downloads visibly online when no separate availability result has been stored yet.
### Clipboard reliability
- Route link names, URLs, package batches, backup keys, diagnostics, error details, and masked account identifiers through the validated Electron clipboard writer.
- Support complete link-package copies up to one MiB and report failed clipboard writes instead of displaying false success messages.
### Item diagnostics
- Format new item-log start, event, and end timestamps as local `DD.MM.YYYY - HH:mm:ss` values while leaving machine-readable application, audit, package, and session logs unchanged.
## [2.0.67] - 2026-08-24
### Extraction stability
+1 -1
View File
@@ -43,7 +43,7 @@ Windows 10 or Windows 11 is required. Release executables are currently unsigned
| Service | Access modes |
| --- | --- |
| Real-Debrid | API token |
| Real-Debrid | API token, browser login |
| AllDebrid | API token, browser login |
| BestDebrid | API token, cookie import |
| Debrid-Link | Multiple API keys |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "multi-debrid-downloader",
"version": "2.0.67",
"version": "2.0.70",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-debrid-downloader",
"version": "2.0.67",
"version": "2.0.70",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "multi-debrid-downloader",
"version": "2.0.67",
"version": "2.0.70",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
+10 -2
View File
@@ -732,8 +732,17 @@ export class AppController {
private pruneRealDebridWebFallbacks(previous: AppSettings, current: AppSettings): void {
const currentIds = new Set(current.realDebridWebAccountIds);
const previouslyDisabled = new Set(previous.realDebridDisabledAccountIds || []);
const currentlyDisabled = new Set(current.realDebridDisabledAccountIds || []);
for (const accountId of previous.realDebridWebAccountIds) {
if (currentIds.has(accountId)) {
if (!previouslyDisabled.has(accountId) && currentlyDisabled.has(accountId)) {
const existing = this.realDebridWebFallbacks.get(accountId);
if (existing) {
this.realDebridWebFallbacks.delete(accountId);
existing.dispose();
}
}
continue;
}
void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => {
@@ -834,8 +843,7 @@ export class AppController {
this.manager.applyDebridAccountStatuses([status]);
const fallback = this.realDebridWebFallbacks.get(accountId);
if (fallback) {
this.realDebridWebFallbacks.delete(accountId);
fallback.dispose();
fallback.closeLoginWindow();
}
}
+14 -5
View File
@@ -1,5 +1,4 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
import crypto from "node:crypto";
@@ -22,6 +21,16 @@ const pendingLinesByItem = new Map<string, string[]>();
const initializedThisProcess = new Set<string>();
let flushTimer: NodeJS.Timeout | null = null;
function itemLogTimestamp(date = new Date()): string {
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = String(date.getFullYear());
const hour = String(date.getHours()).padStart(2, "0");
const minute = String(date.getMinutes()).padStart(2, "0");
const second = String(date.getSeconds()).padStart(2, "0");
return `${day}.${month}.${year} - ${hour}:${minute}:${second}`;
}
function normalizeItemId(itemId: string): string {
const trimmed = String(itemId || "").trim();
if (!trimmed) {
@@ -164,7 +173,7 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
}
if (!initializedThisProcess.has(normalizedItemId)) {
initializedThisProcess.add(normalizedItemId);
const startedAt = logTimestamp();
const startedAt = itemLogTimestamp();
fs.appendFileSync(
logPath,
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
@@ -172,7 +181,7 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
);
fs.appendFileSync(
logPath,
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
`${itemLogTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
packageId: meta.packageId,
packageName: meta.packageName,
fileName: meta.fileName,
@@ -197,7 +206,7 @@ export function logItemEvent(
if (!logPath) {
return;
}
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
const line = `${itemLogTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
appendLine(itemId, line);
}
@@ -221,7 +230,7 @@ export function shutdownItemLogs(): void {
continue;
}
try {
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
fs.appendFileSync(logPath, `=== Item-Log Ende: ${itemLogTimestamp()} ===\n`, "utf8");
} catch {
}
}
+279
View File
@@ -0,0 +1,279 @@
import { UnrestrictedLink } from "./realdebrid";
import { filenameFromUrl, sanitizeFilename } from "./utils";
export type GenerateOutcome =
| { kind: "success"; value: UnrestrictedLink }
| { kind: "login_required" }
| { kind: "error"; status: number; error: string; errorCode: number | null; retryAfterMs: number };
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeStatus(value: unknown): number {
const status = Number(value ?? NaN);
return Number.isInteger(status) && status >= 100 && status <= 599 ? status : 0;
}
function normalizeErrorCode(value: unknown): number | null {
const code = Number(value ?? NaN);
return Number.isInteger(code) && code >= 0 ? code : null;
}
function normalizeErrorText(value: unknown, fallback: string): string {
if (typeof value !== "string") {
return fallback;
}
const normalized = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
return normalized ? normalized.slice(0, 160) : fallback;
}
function normalizeFileSize(value: unknown): number | null {
const size = Number(value ?? NaN);
return Number.isFinite(size) && size > 0 && size <= Number.MAX_SAFE_INTEGER ? Math.floor(size) : null;
}
function normalizeRetryAfterMs(value: unknown): number {
const text = typeof value === "string" ? value.trim() : "";
if (!text) {
return 0;
}
const seconds = Number(text);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.min(120_000, Math.floor(seconds * 1000));
}
const date = Date.parse(text);
return Number.isFinite(date) ? Math.min(120_000, Math.max(0, date - Date.now())) : 0;
}
function isAllowedDownloadUrl(value: string): boolean {
try {
const parsed = new URL(value);
const host = parsed.hostname.toLowerCase();
return parsed.protocol === "https:"
&& !parsed.username
&& !parsed.password
&& (!parsed.port || parsed.port === "443")
&& (host === "download.real-debrid.com" || host.endsWith(".download.real-debrid.com"));
} catch {
return false;
}
}
function normalizeFileName(payload: Record<string, unknown>, directUrl: string, originalLink: string): string {
const supplied = typeof payload.filename === "string" ? payload.filename.trim().slice(0, 1024) : "";
const suppliedBase = supplied.split(/[\\/]/).pop() || "";
const directName = filenameFromUrl(directUrl);
const originalName = filenameFromUrl(originalLink);
const candidate = suppliedBase || (directName !== "download.bin" ? directName : originalName);
return sanitizeFilename(candidate || "download.bin");
}
export function buildRealDebridWebGenerationScript(link: string): string {
const serializedLink = JSON.stringify(String(link || "")).replace(/</g, "\\u003c");
return `(async () => {
const sourceLink = ${serializedLink};
let sourceUrl;
try {
sourceUrl = new URL(sourceLink);
} catch {
return { kind: "page_error", error: "invalid_source_link" };
}
const host = sourceUrl.hostname.toLowerCase().replace(/^www\\./, "");
const pathname = sourceUrl.pathname.toLowerCase();
const isFolderLink =
((host === "mega.nz" || host === "mega.co.nz") && (pathname.startsWith("/folder/") || sourceUrl.hash.startsWith("#F!"))) ||
((host === "rapidgator.net" || host === "rg.to") && pathname.startsWith("/folder/")) ||
(host === "protected.to" && pathname.startsWith("/f-")) ||
(host === "ncrypt.in" && pathname.startsWith("/folder-")) ||
host === "adf.ly" ||
(host === "4shared.com" && (/^\\/(dir|folder)\\//).test(pathname)) ||
(host === "1fichier.com" && pathname.startsWith("/dir/")) ||
(host === "filefactory.com" && (/^\\/(f|folder)\\//).test(pathname)) ||
host === "linksave.in" ||
host === "soundcloud.com" ||
(host === "go4up.com" && pathname.startsWith("/dl/")) ||
((host === "uploaded.to" || host === "uploaded.net" || host === "ul.to" || host === "ul.net") && (/^\\/(folder|f)\\//).test(pathname)) ||
(host === "turbobit.net" && pathname.startsWith("/download/folder/")) ||
(host === "safelinking.net" && pathname.startsWith("/p/")) ||
host === "ed-protect.org" ||
((host === "drive.google.com" || host === "docs.google.com") && pathname.includes("/folders/")) ||
(host === "mediafire.com" && (pathname.startsWith("/folder/") || sourceUrl.searchParams.has("sharekey")));
if (isFolderLink) {
return { kind: "page_error", error: "folder_link_not_supported" };
}
const forbidden = document.querySelector("#forbidden");
const area = document.querySelector("#unrestrictArea");
const form = document.querySelector("#unrestrictArea #debform");
const links = document.querySelector("#unrestrictArea #links");
const password = document.querySelector("#unrestrictArea #password");
const remote = document.querySelector('#unrestrictArea input[name="remoteupload"]');
const showLinks = document.querySelector('#unrestrictArea input[name="showlinks"]');
const container = document.querySelector("#unrestrictArea #links-container");
if (forbidden || !area || !form || !links || !password || !container) {
return { kind: "login_required" };
}
if (typeof area.onsubmit !== "function" || typeof form.requestSubmit !== "function") {
return { kind: "request_error", error: "page_not_ready" };
}
return await new Promise((resolve) => {
let settled = false;
let observer;
let timer;
const finish = (value) => {
if (settled) return;
settled = true;
if (observer) observer.disconnect();
if (timer) clearTimeout(timer);
resolve(value);
};
const inspect = () => {
const anchor = container.querySelector(".link-generated a[href]");
if (anchor) {
finish({
kind: "generated",
download: String(anchor.href || anchor.getAttribute?.("href") || "").slice(0, 4096),
text: String(anchor.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 1200)
});
return;
}
const error = container.querySelector(".link-error");
if (error) {
const rawError = String(error.textContent || "").replace(/\\s+/g, " ").trim();
const sourcePrefix = sourceLink + ":";
const errorText = rawError.startsWith(sourcePrefix)
? rawError.slice(sourcePrefix.length).trim()
: rawError;
finish({
kind: "page_error",
error: errorText.slice(0, 500)
});
}
};
observer = new MutationObserver(inspect);
observer.observe(container, { childList: true, subtree: true, attributes: true });
timer = setTimeout(() => finish({ kind: "request_error", error: "generation_timeout" }), 60_000);
links.value = sourceLink;
password.value = "";
if (remote) remote.checked = false;
if (showLinks) showLinks.checked = false;
container.innerHTML = "";
try {
form.requestSubmit();
inspect();
} catch {
finish({ kind: "request_error", error: "form_submit_failed" });
}
});
})()`;
}
function parseGeneratedText(text: unknown): { fileName: string; fileSize: number | null } {
const normalized = typeof text === "string" ? text.replace(/\s+/g, " ").trim() : "";
const withoutPrefix = normalized.replace(/^[^:]{1,40}:\s*/, "");
const sizeMatch = withoutPrefix.match(/\(([\d.,]+)\s*(B|KB|MB|GB|TB)\)\s*$/i);
let fileSize: number | null = null;
if (sizeMatch) {
const value = Number(sizeMatch[1].replace(",", "."));
const unit = sizeMatch[2].toUpperCase();
const multiplier = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 }[unit] || 1;
if (Number.isFinite(value) && value > 0) {
fileSize = Math.floor(value * multiplier);
}
}
const fileName = (sizeMatch ? withoutPrefix.slice(0, sizeMatch.index).trim() : withoutPrefix).trim();
return { fileName, fileSize };
}
function normalizePageError(value: unknown): { status: number; error: string; errorCode: number | null } {
const error = normalizeErrorText(value, "web_generation_failed");
const lower = error.toLowerCase();
if (lower.includes("folder_link_not_supported")) return { status: 400, error: "folder_link_not_supported", errorCode: null };
if (lower.includes("hoster_unavailable")) return { status: 503, error: "hoster_unavailable", errorCode: 19 };
if (lower.includes("hoster_maintenance")) return { status: 503, error: "hoster_maintenance", errorCode: 17 };
if (lower.includes("file_unavailable")) return { status: 503, error: "file_unavailable", errorCode: 24 };
if (lower.includes("service_unavailable")) return { status: 503, error: "service_unavailable", errorCode: 25 };
if (lower.includes("fair_usage_limit")) return { status: 429, error: "fair_usage_limit", errorCode: 36 };
if (lower.includes("ip_not_allowed")) return { status: 403, error: "ip_not_allowed", errorCode: 22 };
if (lower.includes("traffic_exhausted")) return { status: 403, error: "traffic_exhausted", errorCode: 23 };
if (lower.includes("too_many_requests")) return { status: 429, error: "too_many_requests", errorCode: 34 };
return { status: 0, error, errorCode: null };
}
export function normalizeRealDebridWebGenerationResult(value: unknown, originalLink: string): GenerateOutcome {
const result = asRecord(value);
if (result?.kind === "login_required") {
return { kind: "login_required" };
}
if (result?.kind === "generated") {
const directUrl = typeof result.download === "string" ? result.download.trim() : "";
if (!isAllowedDownloadUrl(directUrl)) {
return { kind: "error", status: 200, error: "invalid_download_url", errorCode: null, retryAfterMs: 0 };
}
const generated = parseGeneratedText(result.text);
const directName = filenameFromUrl(directUrl);
const preferredName = directName && directName !== "download.bin"
? directName
: generated.fileName || filenameFromUrl(originalLink);
return {
kind: "success",
value: {
directUrl,
fileName: sanitizeFilename(preferredName),
fileSize: generated.fileSize,
retriesUsed: 0
}
};
}
if (result?.kind === "page_error") {
const pageError = normalizePageError(result.error);
return { kind: "error", ...pageError, retryAfterMs: 0 };
}
if (result?.kind !== "response") {
return { kind: "error", status: 0, error: "invalid_response", errorCode: null, retryAfterMs: 0 };
}
const status = normalizeStatus(result.status);
const payload = asRecord(result.payload);
const errorCode = normalizeErrorCode(payload?.error_code);
const retryAfterMs = normalizeRetryAfterMs(result.retryAfter);
if (status === 401 || status === 403 || errorCode === 8) {
return { kind: "login_required" };
}
if (!payload) {
return { kind: "error", status, error: "invalid_response", errorCode: null, retryAfterMs };
}
const errorText = normalizeErrorText(payload.error, status >= 400 ? `http_${status}` : "");
if (status < 200 || status >= 300 || errorText) {
return {
kind: "error",
status,
error: errorText || "web_generation_failed",
errorCode,
retryAfterMs
};
}
const directUrl = typeof payload.download === "string"
? payload.download.trim().slice(0, 4096)
: typeof payload.link === "string"
? payload.link.trim().slice(0, 4096)
: "";
if (!isAllowedDownloadUrl(directUrl)) {
return { kind: "error", status, error: "invalid_download_url", errorCode: null, retryAfterMs };
}
return {
kind: "success",
value: {
directUrl,
fileName: normalizeFileName(payload, directUrl, originalLink),
fileSize: normalizeFileSize(payload.filesize),
retriesUsed: 0
}
};
}
+187 -68
View File
@@ -1,21 +1,18 @@
import { BrowserWindow, session } from "electron";
import { UnrestrictedLink } from "./realdebrid";
import { filenameFromUrl, sleep } from "./utils";
import { RealDebridApiError, UnrestrictedLink } from "./realdebrid";
import { sleep } from "./utils";
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
import { applyRemoteLoginSecurity, createRemoteLoginWebPreferences, REALDEBRID_LOGIN_HOSTS } from "./browser-security";
import { buildRealDebridWebGenerationScript, normalizeRealDebridWebGenerationResult } from "./realdebrid-web-page";
const RD_BASE_URL = "https://real-debrid.com";
const RD_LOGIN_URL = RD_BASE_URL;
const RD_DOWNLOADER_URL = `${RD_BASE_URL}/downloader`;
const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`;
const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`;
const RD_USER_API = `${API_BASE_URL}/user`;
const RD_PARTITION_PATTERN = /^persist:realdebrid-web(?:-rdw_[A-Za-z0-9_-]{1,96})?$/;
const RD_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";
type GenerateOutcome =
| { kind: "success"; value: UnrestrictedLink }
| { kind: "login_required" };
export interface RealDebridLoginState {
valid: boolean;
username: string;
@@ -76,6 +73,42 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
});
}
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return promise;
}
if (signal.aborted) {
throw abortError();
}
return new Promise<T>((resolve, reject) => {
let settled = false;
const onAbort = (): void => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then((value) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
});
});
}
function parseJson(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text) as unknown;
@@ -88,11 +121,6 @@ function parseJson(text: string): Record<string, unknown> | null {
}
}
function looksLikeHtmlResponse(text: string): boolean {
const trimmed = text.trim();
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
}
export function extractPrivateTokenFromHtml(html: string): string | null {
const normalized = String(html || "");
if (!normalized.trim()) {
@@ -125,6 +153,14 @@ export class RealDebridWebFallback {
private loginWindowPartition = "";
private generatorWindow: BrowserWindow | null = null;
private generatorWindowPartition = "";
private generatorGeneration = 0;
private lifecycleAbortController = new AbortController();
private cachedToken = "";
private cachedTokenAt = 0;
@@ -159,8 +195,12 @@ export class RealDebridWebFallback {
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
this.throwIfDisposed();
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
return this.runExclusive(async () => {
const overallSignal = AbortSignal.any([
withTimeoutSignal(signal, 10 * 60 * 1000),
this.lifecycleAbortController.signal
]);
try {
return await this.runExclusive(async () => {
throwIfAborted(overallSignal);
if (!String(link || "").trim()) {
return null;
@@ -172,6 +212,10 @@ export class RealDebridWebFallback {
}
throw new Error("Real-Debrid Web-Login erforderlich");
}, overallSignal);
} catch (error) {
this.throwIfDisposed();
throw error;
}
}
public async openLoginWindow(): Promise<void> {
@@ -185,6 +229,10 @@ export class RealDebridWebFallback {
void this.primeTokenFromWindow(window);
}
public closeLoginWindow(): void {
this.disposeLoginWindow();
}
public async probeLoginState(signal?: AbortSignal): Promise<RealDebridLoginState> {
this.throwIfDisposed();
let token: string | null = null;
@@ -242,7 +290,9 @@ export class RealDebridWebFallback {
public async clearSessions(): Promise<void> {
this.disposed = true;
this.lifecycleAbortController.abort("clear-sessions");
this.disposeLoginWindow();
this.disposeGeneratorWindow();
this.cachedToken = "";
this.cachedTokenAt = 0;
for (const partition of [this.persistentPartition, this.transientPartition]) {
@@ -262,7 +312,9 @@ export class RealDebridWebFallback {
public dispose(): void {
this.disposed = true;
this.lifecycleAbortController.abort("dispose");
this.disposeLoginWindow();
this.disposeGeneratorWindow();
this.cachedToken = "";
this.cachedTokenAt = 0;
}
@@ -288,6 +340,16 @@ export class RealDebridWebFallback {
}
}
private disposeGeneratorWindow(): void {
this.generatorGeneration += 1;
const current = this.generatorWindow;
this.generatorWindow = null;
this.generatorWindowPartition = "";
if (current && !current.isDestroyed()) {
current.destroy();
}
}
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
@@ -301,7 +363,7 @@ export class RealDebridWebFallback {
};
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
return run;
return raceWithAbort(run, signal);
}
private async ensureLoginWindow(): Promise<BrowserWindow> {
@@ -365,6 +427,71 @@ export class RealDebridWebFallback {
return window;
}
private async ensureGeneratorWindow(signal?: AbortSignal): Promise<BrowserWindow> {
this.throwIfDisposed();
throwIfAborted(signal);
const partition = this.getPartition();
const existing = this.generatorWindow;
if (existing && !existing.isDestroyed() && this.generatorWindowPartition === partition) {
return existing;
}
if (existing && !existing.isDestroyed()) {
existing.destroy();
}
const window = new BrowserWindow({
width: 900,
height: 700,
show: false,
skipTaskbar: true,
autoHideMenuBar: true,
title: "Real-Debrid Web-Generator",
webPreferences: {
...createRemoteLoginWebPreferences(partition),
backgroundThrottling: false
}
});
applyRemoteLoginSecurity(window, {
providerHosts: REALDEBRID_LOGIN_HOSTS,
externalHosts: []
});
window.setMenuBarVisibility(false);
window.webContents.setUserAgent(RD_USER_AGENT);
window.webContents.on("render-process-gone", () => {
if (this.generatorWindow === window) {
this.disposeGeneratorWindow();
}
});
window.on("closed", () => {
if (this.generatorWindow === window) {
this.generatorWindow = null;
this.generatorWindowPartition = "";
}
});
this.generatorWindow = window;
this.generatorWindowPartition = partition;
const generation = this.generatorGeneration;
const onAbort = (): void => {
if (this.generatorWindow === window && generation === this.generatorGeneration) {
this.disposeGeneratorWindow();
}
};
signal?.addEventListener("abort", onAbort, { once: true });
try {
await raceWithAbort(window.loadURL(RD_DOWNLOADER_URL), signal);
this.throwIfDisposed();
throwIfAborted(signal);
} catch (error) {
if (this.generatorWindow === window) {
this.disposeGeneratorWindow();
}
throw error;
} finally {
signal?.removeEventListener("abort", onAbort);
}
return window;
}
private rememberToken(token: string, generation = this.lifecycleGeneration): string | null {
if (this.disposed || generation !== this.lifecycleGeneration) {
return null;
@@ -503,75 +630,67 @@ export class RealDebridWebFallback {
return null;
}
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
throwIfAborted(signal);
const token = await this.extractApiToken(signal);
if (!token) {
return { kind: "login_required" };
}
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
this.throwIfDisposed();
throwIfAborted(signal);
try {
const body = new URLSearchParams({ link });
const response = await fetch(RD_UNRESTRICT_API, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": RD_USER_AGENT
},
body,
signal: withTimeoutSignal(signal, 30_000)
});
const text = await response.text();
if (response.status === 401 || response.status === 403) {
this.cachedToken = "";
this.cachedTokenAt = 0;
return { kind: "login_required" };
const window = await this.ensureGeneratorWindow(signal);
const generation = this.generatorGeneration;
const onAbort = (): void => {
if (this.generatorWindow === window && generation === this.generatorGeneration) {
this.disposeGeneratorWindow();
}
if (!response.ok) {
if ((response.status === 429 || response.status >= 500) && attempt < REQUEST_RETRIES) {
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
continue;
};
signal?.addEventListener("abort", onAbort, { once: true });
let rawResult: unknown;
try {
rawResult = await window.webContents.executeJavaScript(buildRealDebridWebGenerationScript(link), true);
} finally {
signal?.removeEventListener("abort", onAbort);
}
throw new Error(`Real-Debrid Web HTTP ${response.status}: ${text.slice(0, 200)}`);
this.throwIfDisposed();
if (generation !== this.generatorGeneration || this.generatorWindow !== window) {
throw new Error("Real-Debrid Web-Generator wurde neu gestartet");
}
if (looksLikeHtmlResponse(text)) {
throw new Error("Real-Debrid Web lieferte HTML statt JSON");
}
const payload = parseJson(text.trim());
if (!payload) {
throw new Error("Ungültige JSON-Antwort von Real-Debrid Web");
}
const directUrl = String(payload.download || payload.link || "").trim();
if (!directUrl) {
throw new Error("Real-Debrid Web: Antwort ohne Download-URL");
}
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
const fileSizeRaw = Number(payload.filesize ?? NaN);
const outcome = normalizeRealDebridWebGenerationResult(rawResult, link);
if (outcome.kind === "success") {
return {
kind: "success",
value: {
directUrl,
fileName,
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
...outcome.value,
retriesUsed: attempt - 1
}
};
}
if (outcome.kind === "login_required") {
this.cachedToken = "";
this.cachedTokenAt = 0;
this.disposeGeneratorWindow();
return outcome;
}
if (outcome.status === 0) {
this.disposeGeneratorWindow();
}
if ((outcome.status === 429 || outcome.status >= 500 || outcome.status === 0) && attempt < REQUEST_RETRIES) {
const delayMs = outcome.status === 429 && outcome.retryAfterMs > 0
? outcome.retryAfterMs
: Math.min(5000, 400 * 2 ** attempt);
await sleepWithSignal(delayMs, signal);
continue;
}
throw new RealDebridApiError(
outcome.status,
outcome.error,
outcome.errorCode,
`Real-Debrid Web HTTP ${outcome.status || 0}: ${outcome.error}${outcome.errorCode == null ? "" : ` (${outcome.errorCode})`}`
);
} catch (error) {
this.throwIfDisposed();
if (signal?.aborted) {
throw abortError();
}
if (attempt >= REQUEST_RETRIES) {
if (error instanceof RealDebridApiError || attempt >= REQUEST_RETRIES) {
throw error;
}
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
+23
View File
@@ -33,6 +33,9 @@ describe("item-log", () => {
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Item-Log Start");
expect(content).toContain("episode.part2.rar");
expect(content).toMatch(/^=== Item-Log Start: \d{2}\.\d{2}\.\d{4} - \d{2}:\d{2}:\d{2} \|/m);
expect(content).toMatch(/^\d{2}\.\d{2}\.\d{4} - \d{2}:\d{2}:\d{2} \[INFO\] Item-Kontext initialisiert/m);
expect(content).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
it("writes detail events into the item log", async () => {
@@ -62,6 +65,26 @@ describe("item-log", () => {
expect(content).toContain("Entpack-Fehler");
expect(content).toContain("archive=episode.part2.rar");
expect(content).toContain("code=missing_parts");
expect(content).toMatch(/^\d{2}\.\d{2}\.\d{4} - \d{2}:\d{2}:\d{2} \[ERROR\] Entpack-Fehler/m);
});
it("writes the localized timestamp in the item log end marker", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir);
initItemLogs(baseDir);
const logPath = ensureItemLog({
itemId: "item-end",
packageId: "pkg-end",
packageName: "Ende Paket",
fileName: "episode.rar",
targetPath: "C:\\downloads\\Ende Paket\\episode.rar"
});
shutdownItemLogs();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toMatch(/^=== Item-Log Ende: \d{2}\.\d{2}\.\d{4} - \d{2}:\d{2}:\d{2} ===$/m);
});
it("keeps traversal-like item ids inside the item log directory", () => {
+356
View File
@@ -0,0 +1,356 @@
import { describe, expect, it } from "vitest";
import {
buildRealDebridWebGenerationScript,
normalizeRealDebridWebGenerationResult
} from "../src/main/realdebrid-web-page";
type PageHarnessOptions = {
forbidden?: boolean;
form?: boolean;
pageReady?: boolean;
outcome?: "generated" | "error";
download?: string;
text?: string;
error?: string;
};
async function executePageScript(link: string, options: PageHarnessOptions = {}) {
let observerCallback: (() => void) | null = null;
let submitted = 0;
let outcomeReady = false;
const links = { value: "" };
const password = { value: "retained-password" };
const remote = { checked: true };
const showLinks = { checked: true };
const anchor = {
href: options.download || "https://20-4.download.real-debrid.com/d/example/archive.rar",
textContent: options.text || "DOWNLOAD: archive.rar (500MB)",
getAttribute: () => options.download || "https://20-4.download.real-debrid.com/d/example/archive.rar"
};
const error = { textContent: options.error || "hoster_unavailable" };
const container = {
innerHTML: "",
querySelector(selector: string) {
if (!outcomeReady) return null;
if (selector === ".link-generated a[href]" && (options.outcome || "generated") === "generated") return anchor;
if (selector === ".link-error" && options.outcome === "error") return error;
return null;
}
};
const form = {
requestSubmit() {
submitted += 1;
outcomeReady = true;
observerCallback?.();
}
};
const area = { onsubmit: options.pageReady === false ? null : () => undefined };
const pageDocument = {
querySelector(selector: string) {
if (selector === "#forbidden") return options.forbidden ? {} : null;
if (selector === "#unrestrictArea") return options.form === false ? null : area;
if (selector === "#unrestrictArea #debform") return options.form === false ? null : form;
if (selector === "#unrestrictArea #links") return options.form === false ? null : links;
if (selector === "#unrestrictArea #password") return options.form === false ? null : password;
if (selector === '#unrestrictArea input[name="remoteupload"]') return remote;
if (selector === '#unrestrictArea input[name="showlinks"]') return showLinks;
if (selector === "#unrestrictArea #links-container") return options.form === false ? null : container;
return null;
}
};
class MutationObserverMock {
public constructor(callback: () => void) {
observerCallback = callback;
}
public observe(): void {}
public disconnect(): void {}
}
const script = buildRealDebridWebGenerationScript(link);
const run = new Function(
"window",
"document",
"MutationObserver",
`return ${script};`
) as (
pageWindow: Record<string, unknown>,
pageDocument: Record<string, unknown>,
mutationObserver: typeof MutationObserverMock
) => Promise<unknown>;
const result = await run({}, pageDocument, MutationObserverMock);
return { result, submitted, links, password, remote, showLinks };
}
describe("realdebrid-web-page", () => {
it("submits the real downloader form and does not reimplement its network request", () => {
const script = buildRealDebridWebGenerationScript("https://rapidgator.net/file/form-flow");
expect(script).toContain("#unrestrictArea #debform");
expect(script).toContain("#unrestrictArea #links");
expect(script).toContain("#unrestrictArea #password");
expect(script).toContain("#links-container");
expect(script).toContain("requestSubmit()");
expect(script).not.toContain("fetch(");
expect(script).not.toContain("api.real-debrid.com");
expect(script).not.toContain("app.real-debrid.com");
});
it("normalizes the generated anchor returned by the real website DOM", () => {
const result = normalizeRealDebridWebGenerationResult({
kind: "generated",
download: "https://20-4.download.real-debrid.com/d/MSLNTUMB364GU/AHS.720.BDS01E01.part1.rar",
text: "DOWNLOAD: AHS.720.BDS01E01.part1.rar (500MB)"
}, "https://rapidgator.net/file/source");
expect(result).toEqual({
kind: "success",
value: {
directUrl: "https://20-4.download.real-debrid.com/d/MSLNTUMB364GU/AHS.720.BDS01E01.part1.rar",
fileName: "AHS.720.BDS01E01.part1.rar",
fileSize: 500 * 1024 * 1024,
retriesUsed: 0
}
});
});
it.each([
"https://rapidgator.net/folder/abc123",
"https://1fichier.com/dir/example",
"https://mega.nz/folder/example#key",
"https://protected.to/f-example",
"https://ncrypt.in/folder-example",
"https://adf.ly/example",
"https://4shared.com/folder/example",
"https://filefactory.com/f/example",
"https://linksave.in/example",
"https://soundcloud.com/example/set",
"https://go4up.com/dl/example",
"https://uploaded.to/f/example",
"https://turbobit.net/download/folder/example",
"https://safelinking.net/p/example",
"https://ed-protect.org/example",
"https://drive.google.com/drive/folders/example",
"https://mediafire.com/?sharekey=example",
"https://mediafire.com/folder/example"
])("rejects multi-file folder links instead of silently returning only their first child: %s", async (link) => {
const state = await executePageScript(link);
expect(state.submitted).toBe(0);
expect(state.result).toEqual({ kind: "page_error", error: "folder_link_not_supported" });
});
it("prefers the validated direct URL filename over the website label quality suffix", () => {
const result = normalizeRealDebridWebGenerationResult({
kind: "generated",
download: "https://20-4.download.real-debrid.com/d/example/movie.mkv",
text: "DOWNLOAD: movie.mkv (720p) (1.2GB)"
}, "https://rapidgator.net/file/source");
expect(result).toEqual({
kind: "success",
value: {
directUrl: "https://20-4.download.real-debrid.com/d/example/movie.mkv",
fileName: "movie.mkv",
fileSize: Math.floor(1.2 * 1024 ** 3),
retriesUsed: 0
}
});
});
it("submits the exact source link through the real form and returns only the generated anchor", async () => {
const originalLink = "https://rapidgator.net/file/a'b</script>";
const state = await executePageScript(originalLink, {
outcome: "generated",
download: "https://20-4.download.real-debrid.com/d/example/archive.rar",
text: "DOWNLOAD: archive.rar (500MB)"
});
expect(state.submitted).toBe(1);
expect(state.links.value).toBe(originalLink);
expect(state.password.value).toBe("");
expect(state.remote.checked).toBe(false);
expect(state.showLinks.checked).toBe(false);
expect(state.result).toEqual({
kind: "generated",
download: "https://20-4.download.real-debrid.com/d/example/archive.rar",
text: "DOWNLOAD: archive.rar (500MB)"
});
});
it.each([
{ name: "forbidden marker", options: { forbidden: true } },
{ name: "missing downloader form", options: { form: false } },
{ name: "website script not ready", options: { pageReady: false } }
])("does not submit the website form for $name", async ({ options }) => {
const { result, submitted } = await executePageScript("https://rapidgator.net/file/login", options);
expect(submitted).toBe(0);
if (options.pageReady === false) {
expect(result).toEqual({ kind: "request_error", error: "page_not_ready" });
} else {
expect(result).toEqual({ kind: "login_required" });
}
});
it("normalizes an allowed Real-Debrid download result", () => {
const result = normalizeRealDebridWebGenerationResult({
kind: "response",
status: 200,
payload: {
download: "https://20-4.download.real-debrid.com/d/MSLNTUMB364GU/AHS.720.BDS01E01.part1.rar",
filename: "AHS.720.BDS01E01.part1.rar",
filesize: 524_288_000
}
}, "https://rapidgator.net/file/source");
expect(result).toEqual({
kind: "success",
value: {
directUrl: "https://20-4.download.real-debrid.com/d/MSLNTUMB364GU/AHS.720.BDS01E01.part1.rar",
fileName: "AHS.720.BDS01E01.part1.rar",
fileSize: 524_288_000,
retriesUsed: 0
}
});
});
it("accepts the exact download host and derives a safe filename while treating unclear size as unknown", () => {
const result = normalizeRealDebridWebGenerationResult({
kind: "response",
status: 200,
payload: {
link: "https://download.real-debrid.com/d/example/encoded%20file.bin",
filename: "",
filesize: "unknown"
}
}, "https://1fichier.com/?source");
expect(result).toEqual({
kind: "success",
value: {
directUrl: "https://download.real-debrid.com/d/example/encoded%20file.bin",
fileName: "encoded file.bin",
fileSize: null,
retriesUsed: 0
}
});
});
it.each([
{ value: { kind: "login_required" }, label: "page marker" },
{ value: { kind: "response", status: 401, payload: {} }, label: "HTTP 401" },
{ value: { kind: "response", status: 403, payload: {} }, label: "HTTP 403" },
{
value: { kind: "response", status: 400, payload: { error: "bad_token", error_code: 8 } },
label: "token error"
}
])("normalizes $label as login_required", ({ value }) => {
expect(normalizeRealDebridWebGenerationResult(value, "https://hoster.example/file"))
.toEqual({ kind: "login_required" });
});
it("preserves a bounded typed provider error", () => {
const result = normalizeRealDebridWebGenerationResult({
kind: "response",
status: 503,
payload: {
error: "hoster_unavailable",
error_code: 19,
response_body: "must not escape"
}
}, "https://rapidgator.net/file/unavailable");
expect(result).toEqual({
kind: "error",
status: 503,
error: "hoster_unavailable",
errorCode: 19,
retryAfterMs: 0
});
});
it("maps the real website error node to the matching provider error", async () => {
const { result } = await executePageScript("https://rapidgator.net/file/unavailable", {
outcome: "error",
error: "https://rapidgator.net/file/unavailable: hoster_unavailable"
});
expect(normalizeRealDebridWebGenerationResult(result, "https://rapidgator.net/file/unavailable"))
.toEqual({
kind: "error",
status: 503,
error: "hoster_unavailable",
errorCode: 19,
retryAfterMs: 0
});
});
it("strips a long source URL before classifying the trailing website error", async () => {
const link = `https://rapidgator.net/file/traffic_exhausted-${"x".repeat(700)}`;
const { result } = await executePageScript(link, {
outcome: "error",
error: `${link}: hoster_unavailable`
});
expect(normalizeRealDebridWebGenerationResult(result, link)).toEqual({
kind: "error",
status: 503,
error: "hoster_unavailable",
errorCode: 19,
retryAfterMs: 0
});
});
it("normalizes a bounded Retry-After value when a typed page response provides one", () => {
const result = {
kind: "response",
status: 429,
retryAfter: "12",
payload: {
error: "too_many_requests",
error_code: 34
}
};
expect(normalizeRealDebridWebGenerationResult(result, "https://rapidgator.net/file/rate-limit"))
.toEqual({
kind: "error",
status: 429,
error: "too_many_requests",
errorCode: 34,
retryAfterMs: 12_000
});
});
it.each([
"http://20-4.download.real-debrid.com/d/file.bin",
"javascript:alert(1)",
"https://real-debrid.com.evil.test/d/file.bin",
"https://download.real-debrid.com.evil.test/d/file.bin",
"https://evil.test/d/file.bin"
])("rejects hostile or foreign download URL %s", (download) => {
const result = normalizeRealDebridWebGenerationResult({
kind: "response",
status: 200,
payload: { download, filename: "file.bin", filesize: 1 }
}, "https://hoster.example/file");
expect(result).toEqual({
kind: "error",
status: 200,
error: "invalid_download_url",
errorCode: null,
retryAfterMs: 0
});
});
it("returns a typed parser error for malformed page results", () => {
expect(normalizeRealDebridWebGenerationResult({ payload: "<html>private</html>" }, "https://hoster.example/file"))
.toEqual({
kind: "error",
status: 0,
error: "invalid_response",
errorCode: null,
retryAfterMs: 0
});
});
});
+283 -63
View File
@@ -5,7 +5,7 @@ const {
mockClearStorageData,
mockClearCache,
mockFromPartition,
mockBrowserWindow,
mockBrowserWindows,
mockBrowserWindowCtor,
mockExecuteJavaScript,
mockLoadURL,
@@ -18,48 +18,55 @@ const {
const clearStorageData = vi.fn();
const clearCache = vi.fn();
const fromPartition = vi.fn();
const executeJavaScript = vi.fn();
const loadURL = vi.fn(async () => {});
const executeJavaScript = vi.fn(async (..._args: unknown[]): Promise<unknown> => undefined);
const loadURL = vi.fn(async (_url: string) => {});
const show = vi.fn();
const focus = vi.fn();
const setWindowOpenHandler = vi.fn();
const setPermissionRequestHandler = vi.fn();
const setWindowOpenHandler = vi.fn((_handler: unknown) => undefined);
const setPermissionRequestHandler = vi.fn((_handler: unknown) => undefined);
const browserWindows: any[] = [];
const BrowserWindowCtor = vi.fn((_options: unknown) => {
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
let destroyed = false;
const browserWindow = {
const browserWindow: any = {
isDestroyed: vi.fn(() => destroyed),
isMinimized: vi.fn(() => false),
restore: vi.fn(),
show,
focus,
show: vi.fn(() => show()),
focus: vi.fn(() => focus()),
close: vi.fn(() => {
windowEvents.close?.();
destroyed = true;
windowEvents.closed?.();
}),
destroy: vi.fn(() => {
destroyed = true;
windowEvents.closed?.();
}),
setMenuBarVisibility: vi.fn(),
loadURL,
loadURL: vi.fn((url: string) => loadURL(url)),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
windowEvents[event] = handler;
return browserWindow;
}),
webContents: {
setUserAgent: vi.fn(),
setWindowOpenHandler,
setWindowOpenHandler: vi.fn((handler: unknown) => setWindowOpenHandler(handler)),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
webContentsEvents[event] = handler;
}),
executeJavaScript,
emit: vi.fn((event: string, ...args: unknown[]) => {
webContentsEvents[event]?.(...args);
}),
executeJavaScript: vi.fn((...args: unknown[]) => executeJavaScript(...args)),
session: {
setPermissionRequestHandler
setPermissionRequestHandler: vi.fn((handler: unknown) => setPermissionRequestHandler(handler))
}
}
};
const BrowserWindowCtor = vi.fn((_options: unknown) => {
destroyed = false;
browserWindows.push(browserWindow);
return browserWindow;
});
@@ -68,7 +75,7 @@ const {
mockClearStorageData: clearStorageData,
mockClearCache: clearCache,
mockFromPartition: fromPartition,
mockBrowserWindow: browserWindow,
mockBrowserWindows: browserWindows,
mockBrowserWindowCtor: BrowserWindowCtor,
mockExecuteJavaScript: executeJavaScript,
mockLoadURL: loadURL,
@@ -101,6 +108,7 @@ describe("realdebrid-web", () => {
};
beforeEach(() => {
mockBrowserWindows.length = 0;
mockFromPartition.mockReturnValue(mockSession);
mockExecuteJavaScript.mockReset();
mockLoadURL.mockClear();
@@ -123,86 +131,275 @@ describe("realdebrid-web", () => {
.toBe("ghi789");
});
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
it("uses an invisible authenticated website worker when the normal API host rejects the same link", async () => {
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
download: "https://cdn.real-debrid.example/file.bin",
filename: "file.bin",
filesize: 12345
}), { status: 200 }));
error: "hoster_unavailable",
error_code: 19
}), { status: 503 }));
vi.stubGlobal("fetch", apiFetch);
mockExecuteJavaScript.mockResolvedValue("token-from-window");
mockExecuteJavaScript.mockResolvedValue({
kind: "generated",
download: "https://20-4.download.real-debrid.com/d/example/file.bin",
text: "DOWNLOAD: file.bin (12345B)"
});
const fallback = new RealDebridWebFallback("persist:realdebrid-web", () => true);
await fallback.openLoginWindow();
const result = await fallback.unrestrict("https://rapidgator.net/file/abc");
expect(result).toEqual({
directUrl: "https://cdn.real-debrid.example/file.bin",
directUrl: "https://20-4.download.real-debrid.com/d/example/file.bin",
fileName: "file.bin",
fileSize: 12345,
retriesUsed: 0
});
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
show: false,
skipTaskbar: true,
webPreferences: {
partition: "persist:realdebrid-web",
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false
allowRunningInsecureContent: false,
backgroundThrottling: false
}
}));
expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1);
expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1);
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com");
expect(mockShow).toHaveBeenCalled();
expect(mockFocus).toHaveBeenCalled();
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com/downloader");
expect(mockShow).not.toHaveBeenCalled();
expect(mockFocus).not.toHaveBeenCalled();
expect(mockSessionFetch).not.toHaveBeenCalled();
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
expect(apiFetch).not.toHaveBeenCalled();
expect(mockExecuteJavaScript).toHaveBeenCalled();
});
it("keeps the visible login window and hidden generator as separate account-isolated windows", async () => {
mockExecuteJavaScript
.mockResolvedValueOnce("window-token")
.mockResolvedValueOnce({
kind: "generated",
download: "https://20-4.download.real-debrid.com/d/example/separate.bin",
text: "DOWNLOAD: separate.bin (7B)"
});
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_separate", () => true);
await fallback.openLoginWindow();
const result = await fallback.unrestrict("https://rapidgator.net/file/separate");
expect(result?.fileName).toBe("separate.bin");
expect(mockBrowserWindows).toHaveLength(2);
expect(mockBrowserWindows[0]).not.toBe(mockBrowserWindows[1]);
expect(mockBrowserWindowCtor.mock.calls.map((call) => (call[0] as any).webPreferences.partition))
.toEqual(["persist:realdebrid-web-rdw_separate", "persist:realdebrid-web-rdw_separate"]);
expect(mockBrowserWindows[0]?.show).toHaveBeenCalledTimes(1);
expect(mockBrowserWindows[1]?.show).not.toHaveBeenCalled();
});
it("surfaces a website hoster error without opening a visible login window", async () => {
mockExecuteJavaScript.mockResolvedValue({
kind: "page_error",
error: "https://rapidgator.net/file/limited: hoster_unavailable"
});
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_hoster_error", () => true);
const error = await fallback.unrestrict("https://rapidgator.net/file/limited").then(() => null, (value) => value);
expect(error).toMatchObject({
name: "RealDebridApiError",
status: 503,
apiError: "hoster_unavailable",
apiErrorCode: 19
});
expect(mockShow).not.toHaveBeenCalled();
expect(mockFocus).not.toHaveBeenCalled();
});
it("reports an expired website session without opening the login window from a download", async () => {
mockExecuteJavaScript.mockResolvedValue({ kind: "login_required" });
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_logged_out", () => true);
await expect(fallback.unrestrict("https://rapidgator.net/file/logged-out"))
.rejects.toThrow("Login erforderlich");
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ show: false }));
expect(mockShow).not.toHaveBeenCalled();
expect(mockFocus).not.toHaveBeenCalled();
});
it("destroys a timed-out website worker before retrying the same link", async () => {
mockExecuteJavaScript
.mockResolvedValueOnce({ kind: "request_error", error: "generation_timeout" })
.mockResolvedValueOnce({
kind: "generated",
download: "https://20-4.download.real-debrid.com/d/example/retry.bin",
text: "DOWNLOAD: retry.bin (64B)"
});
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_timeout_retry", () => true);
const result = await fallback.unrestrict("https://rapidgator.net/file/timeout-retry");
expect(result?.fileName).toBe("retry.bin");
expect(mockBrowserWindows).toHaveLength(2);
expect(mockBrowserWindows[0]?.destroy).toHaveBeenCalledTimes(1);
expect(mockBrowserWindows[1]?.destroy).not.toHaveBeenCalled();
});
it("replaces a hidden worker after its renderer process crashes", async () => {
mockExecuteJavaScript
.mockResolvedValueOnce({
kind: "generated",
download: "https://20-4.download.real-debrid.com/d/example/before-crash.bin",
text: "DOWNLOAD: before-crash.bin (32B)"
})
.mockResolvedValueOnce({
kind: "generated",
download: "https://20-4.download.real-debrid.com/d/example/after-crash.bin",
text: "DOWNLOAD: after-crash.bin (48B)"
});
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_renderer_crash", () => true);
await fallback.unrestrict("https://rapidgator.net/file/before-crash");
mockBrowserWindows[0]?.webContents.emit("render-process-gone", {}, { reason: "crashed" });
const recovered = await fallback.unrestrict("https://rapidgator.net/file/after-crash");
expect(mockBrowserWindows[0]?.destroy).toHaveBeenCalledTimes(1);
expect(mockBrowserWindows).toHaveLength(2);
expect(recovered?.fileName).toBe("after-crash.bin");
});
it("aborts a running website generation promptly and rebuilds its hidden worker for the next job", async () => {
let finishFirst: (value: unknown) => void = () => {};
const firstPageResult = new Promise<unknown>((resolve) => {
finishFirst = resolve;
});
mockExecuteJavaScript
.mockReturnValueOnce(firstPageResult)
.mockResolvedValueOnce({
kind: "generated",
download: "https://20-4.download.real-debrid.com/d/example/next.bin",
text: "DOWNLOAD: next.bin (42B)"
});
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_abort", () => true);
const controller = new AbortController();
const running = fallback.unrestrict("https://rapidgator.net/file/first", controller.signal);
await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1));
controller.abort("test-stop");
const promptResult = await Promise.race([
running.then(() => "resolved", (error) => String(error)),
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 120))
]);
expect(promptResult).toMatch(/aborted:realdebrid-web/i);
expect(mockBrowserWindows[0]?.destroy).toHaveBeenCalled();
finishFirst({ kind: "login_required" });
await running.catch(() => undefined);
const next = await fallback.unrestrict("https://rapidgator.net/file/next");
expect(next).toEqual({
directUrl: "https://20-4.download.real-debrid.com/d/example/next.bin",
fileName: "next.bin",
fileSize: 42,
retriesUsed: 0
});
expect(mockBrowserWindowCtor.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("destroys the hidden worker when a download is stopped during downloader page load", async () => {
let finishLoad: () => void = () => {};
const pageLoad = new Promise<void>((resolve) => {
finishLoad = resolve;
});
mockLoadURL.mockReturnValueOnce(pageLoad);
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_load_abort", () => true);
const controller = new AbortController();
const running = fallback.unrestrict("https://rapidgator.net/file/load-abort", controller.signal);
await vi.waitFor(() => expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com/downloader"));
controller.abort("test-stop-during-load");
const promptResult = await Promise.race([
running.then(() => "resolved", (error) => String(error)),
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 120))
]);
expect(promptResult).toMatch(/aborted:realdebrid-web/i);
expect(mockBrowserWindows[0]?.destroy).toHaveBeenCalledTimes(1);
finishLoad();
});
it("never recreates a hidden worker after the account fallback was disposed", async () => {
let failGeneration: (error: Error) => void = () => {};
const pageResult = new Promise<unknown>((_resolve, reject) => {
failGeneration = reject;
});
mockExecuteJavaScript.mockReturnValue(pageResult);
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_disposed", () => true);
const running = fallback.unrestrict("https://rapidgator.net/file/disposed");
await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1));
fallback.dispose();
failGeneration(new Error("execution context destroyed"));
await expect(running).rejects.toThrow("Sitzung wurde geschlossen");
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
});
it("interrupts a running website retry wait when the account fallback is disposed", async () => {
mockExecuteJavaScript.mockResolvedValue({
kind: "page_error",
error: "https://rapidgator.net/file/retry-dispose: too_many_requests"
});
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_retry_dispose", () => true);
const running = fallback.unrestrict("https://rapidgator.net/file/retry-dispose");
await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1));
fallback.dispose();
const promptResult = await Promise.race([
running.then(() => "resolved", (error) => String(error)),
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 120))
]);
expect(promptResult).toMatch(/Sitzung wurde geschlossen/i);
expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1);
});
it("never opens a login window from a background unrestrict request", async () => {
mockSessionFetch.mockImplementation(async () => new Response("<html>login</html>", { status: 200 }));
mockExecuteJavaScript.mockResolvedValue({ kind: "login_required" });
const first = new RealDebridWebFallback("persist:realdebrid-web-rdw_background_first", () => true);
const second = new RealDebridWebFallback("persist:realdebrid-web-rdw_background_second", () => true);
const firstController = new AbortController();
const secondController = new AbortController();
const abortTimer = setTimeout(() => {
firstController.abort();
secondController.abort();
}, 50);
try {
await Promise.all([
expect(first.unrestrict("https://rapidgator.net/file/background-first", firstController.signal))
expect(first.unrestrict("https://rapidgator.net/file/background-first"))
.rejects.toThrow("Login erforderlich"),
expect(second.unrestrict("https://rapidgator.net/file/background-second", secondController.signal))
expect(second.unrestrict("https://rapidgator.net/file/background-second"))
.rejects.toThrow("Login erforderlich")
]);
} finally {
clearTimeout(abortTimer);
}
expect(mockBrowserWindowCtor).not.toHaveBeenCalled();
expect(mockBrowserWindowCtor.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mockBrowserWindowCtor.mock.calls.every((call) => (call[0] as any).show === false)).toBe(true);
expect(mockShow).not.toHaveBeenCalled();
expect(mockFocus).not.toHaveBeenCalled();
});
it("does not open a login window for an authenticated account with a fair-use error", async () => {
mockSessionFetch.mockResolvedValue(new Response("<input name=\"private_token\" value=\"session-token\">", { status: 200 }));
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({
error: "fair_usage_limit",
error_code: 36
}), { status: 440 })));
mockExecuteJavaScript.mockResolvedValue({
kind: "page_error",
error: "https://rapidgator.net/file/limited: fair_usage_limit"
});
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_limited", () => true);
await expect(fallback.unrestrict("https://rapidgator.net/file/limited"))
.rejects.toThrow("Real-Debrid Web HTTP 440");
.rejects.toThrow("Real-Debrid Web HTTP 429");
expect(mockBrowserWindowCtor).not.toHaveBeenCalled();
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ show: false }));
expect(mockShow).not.toHaveBeenCalled();
expect(mockFocus).not.toHaveBeenCalled();
});
it("checks the logged-in browser account without exposing its token", async () => {
@@ -237,16 +434,17 @@ describe("realdebrid-web", () => {
});
it("does not reopen a login window from downloads after the user closed it", async () => {
mockExecuteJavaScript.mockResolvedValue("");
mockSessionFetch.mockImplementation(async () => new Response("<html>login</html>", { status: 200 }));
mockExecuteJavaScript.mockResolvedValueOnce("").mockResolvedValue({ kind: "login_required" });
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_dismissed", () => true);
await fallback.openLoginWindow();
mockBrowserWindow.close();
mockBrowserWindows[0]?.close();
await expect(fallback.unrestrict("https://rapidgator.net/file/first")).rejects.toThrow("Login erforderlich");
await expect(fallback.unrestrict("https://rapidgator.net/file/second")).rejects.toThrow("Login erforderlich");
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(3);
expect(mockShow).toHaveBeenCalledTimes(1);
expect(mockFocus).toHaveBeenCalledTimes(1);
});
it("allows an explicitly requested login after the user closed the previous window", async () => {
@@ -254,7 +452,7 @@ describe("realdebrid-web", () => {
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_reopen", () => true);
await fallback.openLoginWindow();
mockBrowserWindow.close();
mockBrowserWindows[0]?.close();
await fallback.openLoginWindow();
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2);
@@ -315,6 +513,7 @@ describe("realdebrid-web", () => {
premiumUntilMs: Date.parse("2030-01-02T03:04:05.000Z"),
message: "Premium aktiv"
}),
closeLoginWindow: vi.fn(),
dispose: vi.fn()
};
controller.settings = defaultSettings();
@@ -339,6 +538,9 @@ describe("realdebrid-web", () => {
expect(applyStatuses).toHaveBeenCalledWith([
expect.objectContaining({ accountId: "rdw_reserved", valid: true, username: "fixture-user" })
]);
expect(controller.realDebridWebFallbacks.get("rdw_reserved")).toBe(fallback);
expect(fallback.closeLoginWindow).toHaveBeenCalledTimes(1);
expect(fallback.dispose).not.toHaveBeenCalled();
});
it("ignores a successful probe that completes after its account was deleted", async () => {
@@ -373,6 +575,24 @@ describe("realdebrid-web", () => {
expect(controller.settings.realDebridWebAccountIds).toEqual([]);
});
it("disposes the hidden worker when a configured web account is disabled without clearing its partition", () => {
const controller = Object.create(AppController.prototype) as any;
const fallback = { dispose: vi.fn(), clearSessions: vi.fn() };
const previous = defaultSettings();
previous.realDebridWebAccountIds = ["rdw_disabled"];
const current = {
...previous,
realDebridDisabledAccountIds: ["rdw_disabled"]
};
controller.realDebridWebFallbacks = new Map([["rdw_disabled", fallback]]);
controller.pruneRealDebridWebFallbacks(previous, current);
expect(fallback.dispose).toHaveBeenCalledTimes(1);
expect(fallback.clearSessions).not.toHaveBeenCalled();
expect(controller.realDebridWebFallbacks.has("rdw_disabled")).toBe(false);
});
it("clears cold account partitions without retaining a fallback instance", async () => {
const controller = Object.create(AppController.prototype) as any;
controller.settings = defaultSettings();
@@ -402,7 +622,7 @@ describe("realdebrid-web", () => {
expect(controller.pendingRealDebridWebAccountIds.size).toBe(0);
expect(controller.realDebridWebFallbacks.size).toBe(0);
expect(mockBrowserWindow.close).toHaveBeenCalled();
expect(mockBrowserWindows[0]?.close).toHaveBeenCalled();
expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_failed");
expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_failed");
});
@@ -418,7 +638,7 @@ describe("realdebrid-web", () => {
controller.audit = vi.fn();
await controller.openRealDebridLoginWindow({ accountId: "rdw_closed", create: true, dailyLimitBytes: 123_456 });
mockBrowserWindow.close();
mockBrowserWindows[0]?.close();
await vi.waitFor(() => expect(controller.realDebridWebFallbacks.size).toBe(0));
expect(controller.pendingRealDebridWebAccountIds.size).toBe(0);
@@ -471,7 +691,7 @@ describe("realdebrid-web", () => {
await controller.openRealDebridLoginWindow({ accountId: "rdw_close_auth", create: true, dailyLimitBytes: 123_456 });
await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1));
mockBrowserWindow.close();
mockBrowserWindows[0]?.close();
resolveClosingToken("close-time-token");
await vi.waitFor(() => expect(controller.settings.realDebridWebAccountIds).toEqual(["rdw_close_auth"]));