From 06e5bf434074c5503fe393c8530ce9f036bda735 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 24 Aug 2026 04:38:27 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 24 ++ README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/main/app-controller.ts | 12 +- src/main/item-log.ts | 27 +- src/main/realdebrid-web-page.ts | 279 ++++++++++++++++++++ src/main/realdebrid-web.ts | 357 ++++++++++++++++--------- tests/item-log.test.ts | 35 ++- tests/realdebrid-web-page.test.ts | 356 +++++++++++++++++++++++++ tests/realdebrid-web.test.ts | 424 +++++++++++++++++++++++------- 11 files changed, 1280 insertions(+), 242 deletions(-) create mode 100644 src/main/realdebrid-web-page.ts create mode 100644 tests/realdebrid-web-page.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0321782..a33384b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index bf3f343..fccdd76 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/package-lock.json b/package-lock.json index 3be956b..cc3b3af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d0b88e1..42917bd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index e4d3b84..e8a2979 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -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(); } } diff --git a/src/main/item-log.ts b/src/main/item-log.ts index 37b6c55..ca4c43f 100644 --- a/src/main/item-log.ts +++ b/src/main/item-log.ts @@ -1,7 +1,6 @@ -import fs from "node:fs"; -import { logTimestamp } from "./log-timestamp"; -import path from "node:path"; -import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; const ITEM_LOG_FLUSH_INTERVAL_MS = 200; const ITEM_LOG_RETENTION_DAYS = 30; @@ -20,7 +19,17 @@ let itemLogsDir: string | null = null; const knownLogPaths = new Map(); const pendingLinesByItem = new Map(); const initializedThisProcess = new Set(); -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 { const trimmed = String(itemId || "").trim(); @@ -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 { } } diff --git a/src/main/realdebrid-web-page.ts b/src/main/realdebrid-web-page.ts new file mode 100644 index 0000000..cc9b6f7 --- /dev/null +++ b/src/main/realdebrid-web-page.ts @@ -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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +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, 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(/ { + 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 + } + }; +} diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts index edbab9f..13ced34 100644 --- a/src/main/realdebrid-web.ts +++ b/src/main/realdebrid-web.ts @@ -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_APITOKEN_URL = `${RD_BASE_URL}/apitoken`; -const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`; +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_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; @@ -47,7 +44,7 @@ function throwIfAborted(signal?: AbortSignal): void { } } -async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { +async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { if (!signal) { await sleep(ms); return; @@ -73,8 +70,44 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise }; signal.addEventListener("abort", onAbort, { once: true }); - }); -} + }); +} + +async function raceWithAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return promise; + } + if (signal.aborted) { + throw abortError(); + } + return new Promise((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 | null { try { @@ -88,11 +121,6 @@ function parseJson(text: string): Record | null { } } -function looksLikeHtmlResponse(text: string): boolean { - const trimmed = text.trim(); - return trimmed.startsWith(" { this.throwIfDisposed(); - const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000); - return this.runExclusive(async () => { - throwIfAborted(overallSignal); - if (!String(link || "").trim()) { - return null; - } - - const initial = await this.generate(link, overallSignal); - if (initial.kind === "success") { - return initial.value; - } - throw new Error("Real-Debrid Web-Login erforderlich"); - }, overallSignal); - } + 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; + } + + const initial = await this.generate(link, overallSignal); + if (initial.kind === "success") { + return initial.value; + } + throw new Error("Real-Debrid Web-Login erforderlich"); + }, overallSignal); + } catch (error) { + this.throwIfDisposed(); + throw error; + } + } public async openLoginWindow(): Promise { this.throwIfDisposed(); @@ -185,6 +229,10 @@ export class RealDebridWebFallback { void this.primeTokenFromWindow(window); } + public closeLoginWindow(): void { + this.disposeLoginWindow(); + } + public async probeLoginState(signal?: AbortSignal): Promise { this.throwIfDisposed(); let token: string | null = null; @@ -242,7 +290,9 @@ export class RealDebridWebFallback { public async clearSessions(): Promise { 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; } @@ -286,9 +338,19 @@ export class RealDebridWebFallback { this.programmaticClosures.add(current); current.close(); } - } - - private async runExclusive(job: () => Promise, signal?: AbortSignal): Promise { + } + + private disposeGeneratorWindow(): void { + this.generatorGeneration += 1; + const current = this.generatorWindow; + this.generatorWindow = null; + this.generatorWindowPartition = ""; + if (current && !current.isDestroyed()) { + current.destroy(); + } + } + + private async runExclusive(job: () => Promise, signal?: AbortSignal): Promise { const queuedAt = Date.now(); const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000; const guardedJob = async (): Promise => { @@ -299,9 +361,9 @@ export class RealDebridWebFallback { } return job(); }; - const run = this.queue.then(guardedJob, guardedJob); - this.queue = run.then(() => undefined, () => undefined); - return run; + const run = this.queue.then(guardedJob, guardedJob); + this.queue = run.then(() => undefined, () => undefined); + return raceWithAbort(run, signal); } private async ensureLoginWindow(): Promise { @@ -363,8 +425,73 @@ export class RealDebridWebFallback { throw error; } return window; - } - + } + + private async ensureGeneratorWindow(signal?: AbortSignal): Promise { + 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,82 +630,74 @@ export class RealDebridWebFallback { return null; } - private async generate(link: string, signal?: AbortSignal): Promise { - throwIfAborted(signal); - - const token = await this.extractApiToken(signal); - if (!token) { - return { kind: "login_required" }; - } - - for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { - 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" }; - } - - 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 - } - }; - } catch (error) { - if (signal?.aborted) { - throw abortError(); - } - if (attempt >= REQUEST_RETRIES) { - throw error; - } - await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal); - } - } - - throw new Error("Real-Debrid Web: Unrestrict fehlgeschlagen"); - } + 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 window = await this.ensureGeneratorWindow(signal); + const generation = this.generatorGeneration; + const onAbort = (): void => { + if (this.generatorWindow === window && generation === this.generatorGeneration) { + this.disposeGeneratorWindow(); + } + }; + 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) { + this.throwIfDisposed(); + if (signal?.aborted) { + throw abortError(); + } + if (error instanceof RealDebridApiError || attempt >= REQUEST_RETRIES) { + throw error; + } + await sleepWithSignal(Math.min(5000, 400 * 2 ** attempt), signal); + } + } + + throw new Error("Real-Debrid Web: Unrestrict fehlgeschlagen"); + } } diff --git a/tests/item-log.test.ts b/tests/item-log.test.ts index a735390..832a1a3 100644 --- a/tests/item-log.test.ts +++ b/tests/item-log.test.ts @@ -30,9 +30,12 @@ describe("item-log", () => { expect(logPath).not.toBeNull(); expect(fs.existsSync(logPath!)).toBe(true); - const content = fs.readFileSync(logPath!, "utf8"); - expect(content).toContain("Item-Log Start"); - expect(content).toContain("episode.part2.rar"); + 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 () => { @@ -60,9 +63,29 @@ describe("item-log", () => { expect(logPath).not.toBeNull(); const content = fs.readFileSync(logPath!, "utf8"); expect(content).toContain("Entpack-Fehler"); - expect(content).toContain("archive=episode.part2.rar"); - expect(content).toContain("code=missing_parts"); - }); + 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", () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-")); diff --git a/tests/realdebrid-web-page.test.ts b/tests/realdebrid-web-page.test.ts new file mode 100644 index 0000000..d9646b3 --- /dev/null +++ b/tests/realdebrid-web-page.test.ts @@ -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, + pageDocument: Record, + mutationObserver: typeof MutationObserverMock + ) => Promise; + + 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"; + 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: "private" }, "https://hoster.example/file")) + .toEqual({ + kind: "error", + status: 0, + error: "invalid_response", + errorCode: null, + retryAfterMs: 0 + }); + }); +}); diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts index 49744ad..e628e7e 100644 --- a/tests/realdebrid-web.test.ts +++ b/tests/realdebrid-web.test.ts @@ -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 => 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 webContentsEvents: Record void> = {}; - const windowEvents: Record 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 setWindowOpenHandler = vi.fn((_handler: unknown) => undefined); + const setPermissionRequestHandler = vi.fn((_handler: unknown) => undefined); + const browserWindows: any[] = []; + const BrowserWindowCtor = vi.fn((_options: unknown) => { - destroyed = false; + const webContentsEvents: Record void> = {}; + const windowEvents: Record 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; }); @@ -68,7 +75,7 @@ const { mockClearStorageData: clearStorageData, mockClearCache: clearCache, mockFromPartition: fromPartition, - mockBrowserWindow: browserWindow, + mockBrowserWindows: browserWindows, mockBrowserWindowCtor: BrowserWindowCtor, mockExecuteJavaScript: executeJavaScript, mockLoadURL: loadURL, @@ -100,7 +107,8 @@ describe("realdebrid-web", () => { clearCache: mockClearCache }; - beforeEach(() => { + 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 () => { - const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ - download: "https://cdn.real-debrid.example/file.bin", - filename: "file.bin", - filesize: 12345 - }), { status: 200 })); - vi.stubGlobal("fetch", apiFetch); - - mockExecuteJavaScript.mockResolvedValue("token-from-window"); - + 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({ + error: "hoster_unavailable", + error_code: 19 + }), { status: 503 })); + vi.stubGlobal("fetch", apiFetch); + 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", - fileName: "file.bin", - fileSize: 12345, - retriesUsed: 0 + const result = await fallback.unrestrict("https://rapidgator.net/file/abc"); + + expect(result).toEqual({ + 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(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(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com/downloader"); + expect(mockShow).not.toHaveBeenCalled(); + expect(mockFocus).not.toHaveBeenCalled(); + expect(mockSessionFetch).not.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((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((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((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((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((_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((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("login", { 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)) - .rejects.toThrow("Login erforderlich"), - expect(second.unrestrict("https://rapidgator.net/file/background-second", secondController.signal)) - .rejects.toThrow("Login erforderlich") - ]); - } finally { - clearTimeout(abortTimer); - } + await Promise.all([ + expect(first.unrestrict("https://rapidgator.net/file/background-first")) + .rejects.toThrow("Login erforderlich"), + expect(second.unrestrict("https://rapidgator.net/file/background-second")) + .rejects.toThrow("Login erforderlich") + ]); - 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("", { 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("login", { 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"]));