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:
@@ -4,6 +4,30 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [2.0.67] - 2026-08-24
|
||||||
|
|
||||||
### Extraction stability
|
### Extraction stability
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Windows 10 or Windows 11 is required. Release executables are currently unsigned
|
|||||||
|
|
||||||
| Service | Access modes |
|
| Service | Access modes |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Real-Debrid | API token |
|
| Real-Debrid | API token, browser login |
|
||||||
| AllDebrid | API token, browser login |
|
| AllDebrid | API token, browser login |
|
||||||
| BestDebrid | API token, cookie import |
|
| BestDebrid | API token, cookie import |
|
||||||
| Debrid-Link | Multiple API keys |
|
| Debrid-Link | Multiple API keys |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-debrid-downloader",
|
"name": "multi-debrid-downloader",
|
||||||
"version": "2.0.67",
|
"version": "2.0.70",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "multi-debrid-downloader",
|
"name": "multi-debrid-downloader",
|
||||||
"version": "2.0.67",
|
"version": "2.0.70",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-debrid-downloader",
|
"name": "multi-debrid-downloader",
|
||||||
"version": "2.0.67",
|
"version": "2.0.70",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@@ -732,8 +732,17 @@ export class AppController {
|
|||||||
|
|
||||||
private pruneRealDebridWebFallbacks(previous: AppSettings, current: AppSettings): void {
|
private pruneRealDebridWebFallbacks(previous: AppSettings, current: AppSettings): void {
|
||||||
const currentIds = new Set(current.realDebridWebAccountIds);
|
const currentIds = new Set(current.realDebridWebAccountIds);
|
||||||
|
const previouslyDisabled = new Set(previous.realDebridDisabledAccountIds || []);
|
||||||
|
const currentlyDisabled = new Set(current.realDebridDisabledAccountIds || []);
|
||||||
for (const accountId of previous.realDebridWebAccountIds) {
|
for (const accountId of previous.realDebridWebAccountIds) {
|
||||||
if (currentIds.has(accountId)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => {
|
void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => {
|
||||||
@@ -834,8 +843,7 @@ export class AppController {
|
|||||||
this.manager.applyDebridAccountStatuses([status]);
|
this.manager.applyDebridAccountStatuses([status]);
|
||||||
const fallback = this.realDebridWebFallbacks.get(accountId);
|
const fallback = this.realDebridWebFallbacks.get(accountId);
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
this.realDebridWebFallbacks.delete(accountId);
|
fallback.closeLoginWindow();
|
||||||
fallback.dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-5
@@ -1,5 +1,4 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
@@ -22,6 +21,16 @@ const pendingLinesByItem = new Map<string, string[]>();
|
|||||||
const initializedThisProcess = new Set<string>();
|
const initializedThisProcess = new Set<string>();
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
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 {
|
function normalizeItemId(itemId: string): string {
|
||||||
const trimmed = String(itemId || "").trim();
|
const trimmed = String(itemId || "").trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -164,7 +173,7 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
|
|||||||
}
|
}
|
||||||
if (!initializedThisProcess.has(normalizedItemId)) {
|
if (!initializedThisProcess.has(normalizedItemId)) {
|
||||||
initializedThisProcess.add(normalizedItemId);
|
initializedThisProcess.add(normalizedItemId);
|
||||||
const startedAt = logTimestamp();
|
const startedAt = itemLogTimestamp();
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
logPath,
|
||||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
`=== 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(
|
fs.appendFileSync(
|
||||||
logPath,
|
logPath,
|
||||||
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
|
`${itemLogTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
|
||||||
packageId: meta.packageId,
|
packageId: meta.packageId,
|
||||||
packageName: meta.packageName,
|
packageName: meta.packageName,
|
||||||
fileName: meta.fileName,
|
fileName: meta.fileName,
|
||||||
@@ -197,7 +206,7 @@ export function logItemEvent(
|
|||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
const line = `${itemLogTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||||
appendLine(itemId, line);
|
appendLine(itemId, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +230,7 @@ export function shutdownItemLogs(): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(logPath, `=== Item-Log Ende: ${itemLogTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+204
-85
@@ -1,21 +1,18 @@
|
|||||||
import { BrowserWindow, session } from "electron";
|
import { BrowserWindow, session } from "electron";
|
||||||
import { UnrestrictedLink } from "./realdebrid";
|
import { RealDebridApiError, UnrestrictedLink } from "./realdebrid";
|
||||||
import { filenameFromUrl, sleep } from "./utils";
|
import { sleep } from "./utils";
|
||||||
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
||||||
import { applyRemoteLoginSecurity, createRemoteLoginWebPreferences, REALDEBRID_LOGIN_HOSTS } from "./browser-security";
|
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_BASE_URL = "https://real-debrid.com";
|
||||||
const RD_LOGIN_URL = RD_BASE_URL;
|
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_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_USER_API = `${API_BASE_URL}/user`;
|
||||||
const RD_PARTITION_PATTERN = /^persist:realdebrid-web(?:-rdw_[A-Za-z0-9_-]{1,96})?$/;
|
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";
|
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 {
|
export interface RealDebridLoginState {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
username: string;
|
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 {
|
function parseJson(text: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text) as unknown;
|
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 {
|
export function extractPrivateTokenFromHtml(html: string): string | null {
|
||||||
const normalized = String(html || "");
|
const normalized = String(html || "");
|
||||||
if (!normalized.trim()) {
|
if (!normalized.trim()) {
|
||||||
@@ -125,6 +153,14 @@ export class RealDebridWebFallback {
|
|||||||
|
|
||||||
private loginWindowPartition = "";
|
private loginWindowPartition = "";
|
||||||
|
|
||||||
|
private generatorWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
private generatorWindowPartition = "";
|
||||||
|
|
||||||
|
private generatorGeneration = 0;
|
||||||
|
|
||||||
|
private lifecycleAbortController = new AbortController();
|
||||||
|
|
||||||
private cachedToken = "";
|
private cachedToken = "";
|
||||||
|
|
||||||
private cachedTokenAt = 0;
|
private cachedTokenAt = 0;
|
||||||
@@ -159,19 +195,27 @@ export class RealDebridWebFallback {
|
|||||||
|
|
||||||
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||||
this.throwIfDisposed();
|
this.throwIfDisposed();
|
||||||
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
|
const overallSignal = AbortSignal.any([
|
||||||
return this.runExclusive(async () => {
|
withTimeoutSignal(signal, 10 * 60 * 1000),
|
||||||
throwIfAborted(overallSignal);
|
this.lifecycleAbortController.signal
|
||||||
if (!String(link || "").trim()) {
|
]);
|
||||||
return null;
|
try {
|
||||||
}
|
return await this.runExclusive(async () => {
|
||||||
|
throwIfAborted(overallSignal);
|
||||||
|
if (!String(link || "").trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const initial = await this.generate(link, overallSignal);
|
const initial = await this.generate(link, overallSignal);
|
||||||
if (initial.kind === "success") {
|
if (initial.kind === "success") {
|
||||||
return initial.value;
|
return initial.value;
|
||||||
}
|
}
|
||||||
throw new Error("Real-Debrid Web-Login erforderlich");
|
throw new Error("Real-Debrid Web-Login erforderlich");
|
||||||
}, overallSignal);
|
}, overallSignal);
|
||||||
|
} catch (error) {
|
||||||
|
this.throwIfDisposed();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async openLoginWindow(): Promise<void> {
|
public async openLoginWindow(): Promise<void> {
|
||||||
@@ -185,6 +229,10 @@ export class RealDebridWebFallback {
|
|||||||
void this.primeTokenFromWindow(window);
|
void this.primeTokenFromWindow(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public closeLoginWindow(): void {
|
||||||
|
this.disposeLoginWindow();
|
||||||
|
}
|
||||||
|
|
||||||
public async probeLoginState(signal?: AbortSignal): Promise<RealDebridLoginState> {
|
public async probeLoginState(signal?: AbortSignal): Promise<RealDebridLoginState> {
|
||||||
this.throwIfDisposed();
|
this.throwIfDisposed();
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
@@ -242,7 +290,9 @@ export class RealDebridWebFallback {
|
|||||||
|
|
||||||
public async clearSessions(): Promise<void> {
|
public async clearSessions(): Promise<void> {
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
|
this.lifecycleAbortController.abort("clear-sessions");
|
||||||
this.disposeLoginWindow();
|
this.disposeLoginWindow();
|
||||||
|
this.disposeGeneratorWindow();
|
||||||
this.cachedToken = "";
|
this.cachedToken = "";
|
||||||
this.cachedTokenAt = 0;
|
this.cachedTokenAt = 0;
|
||||||
for (const partition of [this.persistentPartition, this.transientPartition]) {
|
for (const partition of [this.persistentPartition, this.transientPartition]) {
|
||||||
@@ -262,7 +312,9 @@ export class RealDebridWebFallback {
|
|||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
|
this.lifecycleAbortController.abort("dispose");
|
||||||
this.disposeLoginWindow();
|
this.disposeLoginWindow();
|
||||||
|
this.disposeGeneratorWindow();
|
||||||
this.cachedToken = "";
|
this.cachedToken = "";
|
||||||
this.cachedTokenAt = 0;
|
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> {
|
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
const queuedAt = Date.now();
|
const queuedAt = Date.now();
|
||||||
const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
|
const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
|
||||||
@@ -301,7 +363,7 @@ export class RealDebridWebFallback {
|
|||||||
};
|
};
|
||||||
const run = this.queue.then(guardedJob, guardedJob);
|
const run = this.queue.then(guardedJob, guardedJob);
|
||||||
this.queue = run.then(() => undefined, () => undefined);
|
this.queue = run.then(() => undefined, () => undefined);
|
||||||
return run;
|
return raceWithAbort(run, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||||
@@ -365,6 +427,71 @@ export class RealDebridWebFallback {
|
|||||||
return window;
|
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 {
|
private rememberToken(token: string, generation = this.lifecycleGeneration): string | null {
|
||||||
if (this.disposed || generation !== this.lifecycleGeneration) {
|
if (this.disposed || generation !== this.lifecycleGeneration) {
|
||||||
return null;
|
return null;
|
||||||
@@ -503,75 +630,67 @@ export class RealDebridWebFallback {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
|
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
|
||||||
throwIfAborted(signal);
|
|
||||||
|
|
||||||
const token = await this.extractApiToken(signal);
|
|
||||||
if (!token) {
|
|
||||||
return { kind: "login_required" };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
|
this.throwIfDisposed();
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
try {
|
try {
|
||||||
const body = new URLSearchParams({ link });
|
const window = await this.ensureGeneratorWindow(signal);
|
||||||
const response = await fetch(RD_UNRESTRICT_API, {
|
const generation = this.generatorGeneration;
|
||||||
method: "POST",
|
const onAbort = (): void => {
|
||||||
headers: {
|
if (this.generatorWindow === window && generation === this.generatorGeneration) {
|
||||||
Authorization: `Bearer ${token}`,
|
this.disposeGeneratorWindow();
|
||||||
"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" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if ((response.status === 429 || response.status >= 500) && attempt < REQUEST_RETRIES) {
|
|
||||||
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw new Error(`Real-Debrid Web HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
return {
|
|
||||||
kind: "success",
|
|
||||||
value: {
|
|
||||||
directUrl,
|
|
||||||
fileName,
|
|
||||||
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
|
||||||
retriesUsed: attempt - 1
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
let rawResult: unknown;
|
||||||
|
try {
|
||||||
|
rawResult = await window.webContents.executeJavaScript(buildRealDebridWebGenerationScript(link), true);
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
this.throwIfDisposed();
|
||||||
|
if (generation !== this.generatorGeneration || this.generatorWindow !== window) {
|
||||||
|
throw new Error("Real-Debrid Web-Generator wurde neu gestartet");
|
||||||
|
}
|
||||||
|
const outcome = normalizeRealDebridWebGenerationResult(rawResult, link);
|
||||||
|
if (outcome.kind === "success") {
|
||||||
|
return {
|
||||||
|
kind: "success",
|
||||||
|
value: {
|
||||||
|
...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) {
|
} catch (error) {
|
||||||
|
this.throwIfDisposed();
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw abortError();
|
throw abortError();
|
||||||
}
|
}
|
||||||
if (attempt >= REQUEST_RETRIES) {
|
if (error instanceof RealDebridApiError || attempt >= REQUEST_RETRIES) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal);
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ describe("item-log", () => {
|
|||||||
const content = fs.readFileSync(logPath!, "utf8");
|
const content = fs.readFileSync(logPath!, "utf8");
|
||||||
expect(content).toContain("Item-Log Start");
|
expect(content).toContain("Item-Log Start");
|
||||||
expect(content).toContain("episode.part2.rar");
|
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 () => {
|
it("writes detail events into the item log", async () => {
|
||||||
@@ -62,6 +65,26 @@ describe("item-log", () => {
|
|||||||
expect(content).toContain("Entpack-Fehler");
|
expect(content).toContain("Entpack-Fehler");
|
||||||
expect(content).toContain("archive=episode.part2.rar");
|
expect(content).toContain("archive=episode.part2.rar");
|
||||||
expect(content).toContain("code=missing_parts");
|
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", () => {
|
it("keeps traversal-like item ids inside the item log directory", () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+310
-90
@@ -5,7 +5,7 @@ const {
|
|||||||
mockClearStorageData,
|
mockClearStorageData,
|
||||||
mockClearCache,
|
mockClearCache,
|
||||||
mockFromPartition,
|
mockFromPartition,
|
||||||
mockBrowserWindow,
|
mockBrowserWindows,
|
||||||
mockBrowserWindowCtor,
|
mockBrowserWindowCtor,
|
||||||
mockExecuteJavaScript,
|
mockExecuteJavaScript,
|
||||||
mockLoadURL,
|
mockLoadURL,
|
||||||
@@ -18,48 +18,55 @@ const {
|
|||||||
const clearStorageData = vi.fn();
|
const clearStorageData = vi.fn();
|
||||||
const clearCache = vi.fn();
|
const clearCache = vi.fn();
|
||||||
const fromPartition = vi.fn();
|
const fromPartition = vi.fn();
|
||||||
const executeJavaScript = vi.fn();
|
const executeJavaScript = vi.fn(async (..._args: unknown[]): Promise<unknown> => undefined);
|
||||||
const loadURL = vi.fn(async () => {});
|
const loadURL = vi.fn(async (_url: string) => {});
|
||||||
const show = vi.fn();
|
const show = vi.fn();
|
||||||
const focus = vi.fn();
|
const focus = vi.fn();
|
||||||
const setWindowOpenHandler = vi.fn();
|
const setWindowOpenHandler = vi.fn((_handler: unknown) => undefined);
|
||||||
const setPermissionRequestHandler = vi.fn();
|
const setPermissionRequestHandler = vi.fn((_handler: unknown) => undefined);
|
||||||
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
|
const browserWindows: any[] = [];
|
||||||
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
|
||||||
let destroyed = false;
|
|
||||||
|
|
||||||
const browserWindow = {
|
|
||||||
isDestroyed: vi.fn(() => destroyed),
|
|
||||||
isMinimized: vi.fn(() => false),
|
|
||||||
restore: vi.fn(),
|
|
||||||
show,
|
|
||||||
focus,
|
|
||||||
close: vi.fn(() => {
|
|
||||||
windowEvents.close?.();
|
|
||||||
destroyed = true;
|
|
||||||
windowEvents.closed?.();
|
|
||||||
}),
|
|
||||||
setMenuBarVisibility: vi.fn(),
|
|
||||||
loadURL,
|
|
||||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
||||||
windowEvents[event] = handler;
|
|
||||||
return browserWindow;
|
|
||||||
}),
|
|
||||||
webContents: {
|
|
||||||
setUserAgent: vi.fn(),
|
|
||||||
setWindowOpenHandler,
|
|
||||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
||||||
webContentsEvents[event] = handler;
|
|
||||||
}),
|
|
||||||
executeJavaScript,
|
|
||||||
session: {
|
|
||||||
setPermissionRequestHandler
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const BrowserWindowCtor = vi.fn((_options: unknown) => {
|
const BrowserWindowCtor = vi.fn((_options: unknown) => {
|
||||||
destroyed = false;
|
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||||
|
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||||
|
let destroyed = false;
|
||||||
|
const browserWindow: any = {
|
||||||
|
isDestroyed: vi.fn(() => destroyed),
|
||||||
|
isMinimized: vi.fn(() => false),
|
||||||
|
restore: vi.fn(),
|
||||||
|
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: 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: vi.fn((handler: unknown) => setWindowOpenHandler(handler)),
|
||||||
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
webContentsEvents[event] = handler;
|
||||||
|
}),
|
||||||
|
emit: vi.fn((event: string, ...args: unknown[]) => {
|
||||||
|
webContentsEvents[event]?.(...args);
|
||||||
|
}),
|
||||||
|
executeJavaScript: vi.fn((...args: unknown[]) => executeJavaScript(...args)),
|
||||||
|
session: {
|
||||||
|
setPermissionRequestHandler: vi.fn((handler: unknown) => setPermissionRequestHandler(handler))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
browserWindows.push(browserWindow);
|
||||||
return browserWindow;
|
return browserWindow;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +75,7 @@ const {
|
|||||||
mockClearStorageData: clearStorageData,
|
mockClearStorageData: clearStorageData,
|
||||||
mockClearCache: clearCache,
|
mockClearCache: clearCache,
|
||||||
mockFromPartition: fromPartition,
|
mockFromPartition: fromPartition,
|
||||||
mockBrowserWindow: browserWindow,
|
mockBrowserWindows: browserWindows,
|
||||||
mockBrowserWindowCtor: BrowserWindowCtor,
|
mockBrowserWindowCtor: BrowserWindowCtor,
|
||||||
mockExecuteJavaScript: executeJavaScript,
|
mockExecuteJavaScript: executeJavaScript,
|
||||||
mockLoadURL: loadURL,
|
mockLoadURL: loadURL,
|
||||||
@@ -101,6 +108,7 @@ describe("realdebrid-web", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockBrowserWindows.length = 0;
|
||||||
mockFromPartition.mockReturnValue(mockSession);
|
mockFromPartition.mockReturnValue(mockSession);
|
||||||
mockExecuteJavaScript.mockReset();
|
mockExecuteJavaScript.mockReset();
|
||||||
mockLoadURL.mockClear();
|
mockLoadURL.mockClear();
|
||||||
@@ -123,86 +131,275 @@ describe("realdebrid-web", () => {
|
|||||||
.toBe("ghi789");
|
.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({
|
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
download: "https://cdn.real-debrid.example/file.bin",
|
error: "hoster_unavailable",
|
||||||
filename: "file.bin",
|
error_code: 19
|
||||||
filesize: 12345
|
}), { status: 503 }));
|
||||||
}), { status: 200 }));
|
|
||||||
vi.stubGlobal("fetch", apiFetch);
|
vi.stubGlobal("fetch", apiFetch);
|
||||||
|
mockExecuteJavaScript.mockResolvedValue({
|
||||||
mockExecuteJavaScript.mockResolvedValue("token-from-window");
|
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);
|
const fallback = new RealDebridWebFallback("persist:realdebrid-web", () => true);
|
||||||
await fallback.openLoginWindow();
|
|
||||||
|
|
||||||
const result = await fallback.unrestrict("https://rapidgator.net/file/abc");
|
const result = await fallback.unrestrict("https://rapidgator.net/file/abc");
|
||||||
|
|
||||||
expect(result).toEqual({
|
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",
|
fileName: "file.bin",
|
||||||
fileSize: 12345,
|
fileSize: 12345,
|
||||||
retriesUsed: 0
|
retriesUsed: 0
|
||||||
});
|
});
|
||||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
|
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
|
||||||
expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
|
expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
|
||||||
|
show: false,
|
||||||
|
skipTaskbar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
partition: "persist:realdebrid-web",
|
partition: "persist:realdebrid-web",
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
allowRunningInsecureContent: false
|
allowRunningInsecureContent: false,
|
||||||
|
backgroundThrottling: false
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1);
|
expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1);
|
||||||
expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1);
|
expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1);
|
||||||
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com");
|
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com/downloader");
|
||||||
expect(mockShow).toHaveBeenCalled();
|
expect(mockShow).not.toHaveBeenCalled();
|
||||||
expect(mockFocus).toHaveBeenCalled();
|
expect(mockFocus).not.toHaveBeenCalled();
|
||||||
expect(mockSessionFetch).not.toHaveBeenCalled();
|
expect(mockSessionFetch).not.toHaveBeenCalled();
|
||||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
|
expect(mockExecuteJavaScript).toHaveBeenCalled();
|
||||||
expect(mockBrowserWindow.webContents.executeJavaScript).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 () => {
|
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 first = new RealDebridWebFallback("persist:realdebrid-web-rdw_background_first", () => true);
|
||||||
const second = new RealDebridWebFallback("persist:realdebrid-web-rdw_background_second", () => true);
|
const second = new RealDebridWebFallback("persist:realdebrid-web-rdw_background_second", () => true);
|
||||||
|
|
||||||
const firstController = new AbortController();
|
await Promise.all([
|
||||||
const secondController = new AbortController();
|
expect(first.unrestrict("https://rapidgator.net/file/background-first"))
|
||||||
const abortTimer = setTimeout(() => {
|
.rejects.toThrow("Login erforderlich"),
|
||||||
firstController.abort();
|
expect(second.unrestrict("https://rapidgator.net/file/background-second"))
|
||||||
secondController.abort();
|
.rejects.toThrow("Login erforderlich")
|
||||||
}, 50);
|
]);
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
expect(first.unrestrict("https://rapidgator.net/file/background-first", firstController.signal))
|
|
||||||
.rejects.toThrow("Login erforderlich"),
|
|
||||||
expect(second.unrestrict("https://rapidgator.net/file/background-second", secondController.signal))
|
|
||||||
.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 () => {
|
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 }));
|
mockExecuteJavaScript.mockResolvedValue({
|
||||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({
|
kind: "page_error",
|
||||||
error: "fair_usage_limit",
|
error: "https://rapidgator.net/file/limited: fair_usage_limit"
|
||||||
error_code: 36
|
});
|
||||||
}), { status: 440 })));
|
|
||||||
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_limited", () => true);
|
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_limited", () => true);
|
||||||
|
|
||||||
await expect(fallback.unrestrict("https://rapidgator.net/file/limited"))
|
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 () => {
|
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 () => {
|
it("does not reopen a login window from downloads after the user closed it", async () => {
|
||||||
mockExecuteJavaScript.mockResolvedValue("");
|
mockExecuteJavaScript.mockResolvedValueOnce("").mockResolvedValue({ kind: "login_required" });
|
||||||
mockSessionFetch.mockImplementation(async () => new Response("<html>login</html>", { status: 200 }));
|
|
||||||
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_dismissed", () => true);
|
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_dismissed", () => true);
|
||||||
|
|
||||||
await fallback.openLoginWindow();
|
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/first")).rejects.toThrow("Login erforderlich");
|
||||||
await expect(fallback.unrestrict("https://rapidgator.net/file/second")).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 () => {
|
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);
|
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_reopen", () => true);
|
||||||
|
|
||||||
await fallback.openLoginWindow();
|
await fallback.openLoginWindow();
|
||||||
mockBrowserWindow.close();
|
mockBrowserWindows[0]?.close();
|
||||||
await fallback.openLoginWindow();
|
await fallback.openLoginWindow();
|
||||||
|
|
||||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2);
|
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2);
|
||||||
@@ -315,6 +513,7 @@ describe("realdebrid-web", () => {
|
|||||||
premiumUntilMs: Date.parse("2030-01-02T03:04:05.000Z"),
|
premiumUntilMs: Date.parse("2030-01-02T03:04:05.000Z"),
|
||||||
message: "Premium aktiv"
|
message: "Premium aktiv"
|
||||||
}),
|
}),
|
||||||
|
closeLoginWindow: vi.fn(),
|
||||||
dispose: vi.fn()
|
dispose: vi.fn()
|
||||||
};
|
};
|
||||||
controller.settings = defaultSettings();
|
controller.settings = defaultSettings();
|
||||||
@@ -339,6 +538,9 @@ describe("realdebrid-web", () => {
|
|||||||
expect(applyStatuses).toHaveBeenCalledWith([
|
expect(applyStatuses).toHaveBeenCalledWith([
|
||||||
expect.objectContaining({ accountId: "rdw_reserved", valid: true, username: "fixture-user" })
|
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 () => {
|
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([]);
|
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 () => {
|
it("clears cold account partitions without retaining a fallback instance", async () => {
|
||||||
const controller = Object.create(AppController.prototype) as any;
|
const controller = Object.create(AppController.prototype) as any;
|
||||||
controller.settings = defaultSettings();
|
controller.settings = defaultSettings();
|
||||||
@@ -402,7 +622,7 @@ describe("realdebrid-web", () => {
|
|||||||
|
|
||||||
expect(controller.pendingRealDebridWebAccountIds.size).toBe(0);
|
expect(controller.pendingRealDebridWebAccountIds.size).toBe(0);
|
||||||
expect(controller.realDebridWebFallbacks.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("persist:realdebrid-web-rdw_failed");
|
||||||
expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_failed");
|
expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_failed");
|
||||||
});
|
});
|
||||||
@@ -418,7 +638,7 @@ describe("realdebrid-web", () => {
|
|||||||
controller.audit = vi.fn();
|
controller.audit = vi.fn();
|
||||||
|
|
||||||
await controller.openRealDebridLoginWindow({ accountId: "rdw_closed", create: true, dailyLimitBytes: 123_456 });
|
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));
|
await vi.waitFor(() => expect(controller.realDebridWebFallbacks.size).toBe(0));
|
||||||
|
|
||||||
expect(controller.pendingRealDebridWebAccountIds.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 controller.openRealDebridLoginWindow({ accountId: "rdw_close_auth", create: true, dailyLimitBytes: 123_456 });
|
||||||
await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1));
|
||||||
mockBrowserWindow.close();
|
mockBrowserWindows[0]?.close();
|
||||||
resolveClosingToken("close-time-token");
|
resolveClosingToken("close-time-token");
|
||||||
await vi.waitFor(() => expect(controller.settings.realDebridWebAccountIds).toEqual(["rdw_close_auth"]));
|
await vi.waitFor(() => expect(controller.settings.realDebridWebAccountIds).toEqual(["rdw_close_auth"]));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user