release: prepare v2.0.70

Ship authenticated Real-Debrid browser-form generation with isolated lifecycle recovery, restore reliable download sorting and clipboard handling from the post-v2.0.67 fixes, and localize item-log timestamps while preserving machine-readable runtime logs.
This commit is contained in:
Sucukdeluxe
2026-08-24 04:38:27 +02:00
parent 7bd31185b0
commit 06e5bf4340
11 changed files with 1280 additions and 242 deletions
+10 -2
View File
@@ -732,8 +732,17 @@ export class AppController {
private pruneRealDebridWebFallbacks(previous: AppSettings, current: AppSettings): void {
const currentIds = new Set(current.realDebridWebAccountIds);
const previouslyDisabled = new Set(previous.realDebridDisabledAccountIds || []);
const currentlyDisabled = new Set(current.realDebridDisabledAccountIds || []);
for (const accountId of previous.realDebridWebAccountIds) {
if (currentIds.has(accountId)) {
if (!previouslyDisabled.has(accountId) && currentlyDisabled.has(accountId)) {
const existing = this.realDebridWebFallbacks.get(accountId);
if (existing) {
this.realDebridWebFallbacks.delete(accountId);
existing.dispose();
}
}
continue;
}
void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => {
@@ -834,8 +843,7 @@ export class AppController {
this.manager.applyDebridAccountStatuses([status]);
const fallback = this.realDebridWebFallbacks.get(accountId);
if (fallback) {
this.realDebridWebFallbacks.delete(accountId);
fallback.dispose();
fallback.closeLoginWindow();
}
}
+18 -9
View File
@@ -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<string, string>();
const pendingLinesByItem = new Map<string, 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 {
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 {
}
}
+279
View File
@@ -0,0 +1,279 @@
import { UnrestrictedLink } from "./realdebrid";
import { filenameFromUrl, sanitizeFilename } from "./utils";
export type GenerateOutcome =
| { kind: "success"; value: UnrestrictedLink }
| { kind: "login_required" }
| { kind: "error"; status: number; error: string; errorCode: number | null; retryAfterMs: number };
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function normalizeStatus(value: unknown): number {
const status = Number(value ?? NaN);
return Number.isInteger(status) && status >= 100 && status <= 599 ? status : 0;
}
function normalizeErrorCode(value: unknown): number | null {
const code = Number(value ?? NaN);
return Number.isInteger(code) && code >= 0 ? code : null;
}
function normalizeErrorText(value: unknown, fallback: string): string {
if (typeof value !== "string") {
return fallback;
}
const normalized = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
return normalized ? normalized.slice(0, 160) : fallback;
}
function normalizeFileSize(value: unknown): number | null {
const size = Number(value ?? NaN);
return Number.isFinite(size) && size > 0 && size <= Number.MAX_SAFE_INTEGER ? Math.floor(size) : null;
}
function normalizeRetryAfterMs(value: unknown): number {
const text = typeof value === "string" ? value.trim() : "";
if (!text) {
return 0;
}
const seconds = Number(text);
if (Number.isFinite(seconds) && seconds >= 0) {
return Math.min(120_000, Math.floor(seconds * 1000));
}
const date = Date.parse(text);
return Number.isFinite(date) ? Math.min(120_000, Math.max(0, date - Date.now())) : 0;
}
function isAllowedDownloadUrl(value: string): boolean {
try {
const parsed = new URL(value);
const host = parsed.hostname.toLowerCase();
return parsed.protocol === "https:"
&& !parsed.username
&& !parsed.password
&& (!parsed.port || parsed.port === "443")
&& (host === "download.real-debrid.com" || host.endsWith(".download.real-debrid.com"));
} catch {
return false;
}
}
function normalizeFileName(payload: Record<string, unknown>, directUrl: string, originalLink: string): string {
const supplied = typeof payload.filename === "string" ? payload.filename.trim().slice(0, 1024) : "";
const suppliedBase = supplied.split(/[\\/]/).pop() || "";
const directName = filenameFromUrl(directUrl);
const originalName = filenameFromUrl(originalLink);
const candidate = suppliedBase || (directName !== "download.bin" ? directName : originalName);
return sanitizeFilename(candidate || "download.bin");
}
export function buildRealDebridWebGenerationScript(link: string): string {
const serializedLink = JSON.stringify(String(link || "")).replace(/</g, "\\u003c");
return `(async () => {
const sourceLink = ${serializedLink};
let sourceUrl;
try {
sourceUrl = new URL(sourceLink);
} catch {
return { kind: "page_error", error: "invalid_source_link" };
}
const host = sourceUrl.hostname.toLowerCase().replace(/^www\\./, "");
const pathname = sourceUrl.pathname.toLowerCase();
const isFolderLink =
((host === "mega.nz" || host === "mega.co.nz") && (pathname.startsWith("/folder/") || sourceUrl.hash.startsWith("#F!"))) ||
((host === "rapidgator.net" || host === "rg.to") && pathname.startsWith("/folder/")) ||
(host === "protected.to" && pathname.startsWith("/f-")) ||
(host === "ncrypt.in" && pathname.startsWith("/folder-")) ||
host === "adf.ly" ||
(host === "4shared.com" && (/^\\/(dir|folder)\\//).test(pathname)) ||
(host === "1fichier.com" && pathname.startsWith("/dir/")) ||
(host === "filefactory.com" && (/^\\/(f|folder)\\//).test(pathname)) ||
host === "linksave.in" ||
host === "soundcloud.com" ||
(host === "go4up.com" && pathname.startsWith("/dl/")) ||
((host === "uploaded.to" || host === "uploaded.net" || host === "ul.to" || host === "ul.net") && (/^\\/(folder|f)\\//).test(pathname)) ||
(host === "turbobit.net" && pathname.startsWith("/download/folder/")) ||
(host === "safelinking.net" && pathname.startsWith("/p/")) ||
host === "ed-protect.org" ||
((host === "drive.google.com" || host === "docs.google.com") && pathname.includes("/folders/")) ||
(host === "mediafire.com" && (pathname.startsWith("/folder/") || sourceUrl.searchParams.has("sharekey")));
if (isFolderLink) {
return { kind: "page_error", error: "folder_link_not_supported" };
}
const forbidden = document.querySelector("#forbidden");
const area = document.querySelector("#unrestrictArea");
const form = document.querySelector("#unrestrictArea #debform");
const links = document.querySelector("#unrestrictArea #links");
const password = document.querySelector("#unrestrictArea #password");
const remote = document.querySelector('#unrestrictArea input[name="remoteupload"]');
const showLinks = document.querySelector('#unrestrictArea input[name="showlinks"]');
const container = document.querySelector("#unrestrictArea #links-container");
if (forbidden || !area || !form || !links || !password || !container) {
return { kind: "login_required" };
}
if (typeof area.onsubmit !== "function" || typeof form.requestSubmit !== "function") {
return { kind: "request_error", error: "page_not_ready" };
}
return await new Promise((resolve) => {
let settled = false;
let observer;
let timer;
const finish = (value) => {
if (settled) return;
settled = true;
if (observer) observer.disconnect();
if (timer) clearTimeout(timer);
resolve(value);
};
const inspect = () => {
const anchor = container.querySelector(".link-generated a[href]");
if (anchor) {
finish({
kind: "generated",
download: String(anchor.href || anchor.getAttribute?.("href") || "").slice(0, 4096),
text: String(anchor.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 1200)
});
return;
}
const error = container.querySelector(".link-error");
if (error) {
const rawError = String(error.textContent || "").replace(/\\s+/g, " ").trim();
const sourcePrefix = sourceLink + ":";
const errorText = rawError.startsWith(sourcePrefix)
? rawError.slice(sourcePrefix.length).trim()
: rawError;
finish({
kind: "page_error",
error: errorText.slice(0, 500)
});
}
};
observer = new MutationObserver(inspect);
observer.observe(container, { childList: true, subtree: true, attributes: true });
timer = setTimeout(() => finish({ kind: "request_error", error: "generation_timeout" }), 60_000);
links.value = sourceLink;
password.value = "";
if (remote) remote.checked = false;
if (showLinks) showLinks.checked = false;
container.innerHTML = "";
try {
form.requestSubmit();
inspect();
} catch {
finish({ kind: "request_error", error: "form_submit_failed" });
}
});
})()`;
}
function parseGeneratedText(text: unknown): { fileName: string; fileSize: number | null } {
const normalized = typeof text === "string" ? text.replace(/\s+/g, " ").trim() : "";
const withoutPrefix = normalized.replace(/^[^:]{1,40}:\s*/, "");
const sizeMatch = withoutPrefix.match(/\(([\d.,]+)\s*(B|KB|MB|GB|TB)\)\s*$/i);
let fileSize: number | null = null;
if (sizeMatch) {
const value = Number(sizeMatch[1].replace(",", "."));
const unit = sizeMatch[2].toUpperCase();
const multiplier = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 }[unit] || 1;
if (Number.isFinite(value) && value > 0) {
fileSize = Math.floor(value * multiplier);
}
}
const fileName = (sizeMatch ? withoutPrefix.slice(0, sizeMatch.index).trim() : withoutPrefix).trim();
return { fileName, fileSize };
}
function normalizePageError(value: unknown): { status: number; error: string; errorCode: number | null } {
const error = normalizeErrorText(value, "web_generation_failed");
const lower = error.toLowerCase();
if (lower.includes("folder_link_not_supported")) return { status: 400, error: "folder_link_not_supported", errorCode: null };
if (lower.includes("hoster_unavailable")) return { status: 503, error: "hoster_unavailable", errorCode: 19 };
if (lower.includes("hoster_maintenance")) return { status: 503, error: "hoster_maintenance", errorCode: 17 };
if (lower.includes("file_unavailable")) return { status: 503, error: "file_unavailable", errorCode: 24 };
if (lower.includes("service_unavailable")) return { status: 503, error: "service_unavailable", errorCode: 25 };
if (lower.includes("fair_usage_limit")) return { status: 429, error: "fair_usage_limit", errorCode: 36 };
if (lower.includes("ip_not_allowed")) return { status: 403, error: "ip_not_allowed", errorCode: 22 };
if (lower.includes("traffic_exhausted")) return { status: 403, error: "traffic_exhausted", errorCode: 23 };
if (lower.includes("too_many_requests")) return { status: 429, error: "too_many_requests", errorCode: 34 };
return { status: 0, error, errorCode: null };
}
export function normalizeRealDebridWebGenerationResult(value: unknown, originalLink: string): GenerateOutcome {
const result = asRecord(value);
if (result?.kind === "login_required") {
return { kind: "login_required" };
}
if (result?.kind === "generated") {
const directUrl = typeof result.download === "string" ? result.download.trim() : "";
if (!isAllowedDownloadUrl(directUrl)) {
return { kind: "error", status: 200, error: "invalid_download_url", errorCode: null, retryAfterMs: 0 };
}
const generated = parseGeneratedText(result.text);
const directName = filenameFromUrl(directUrl);
const preferredName = directName && directName !== "download.bin"
? directName
: generated.fileName || filenameFromUrl(originalLink);
return {
kind: "success",
value: {
directUrl,
fileName: sanitizeFilename(preferredName),
fileSize: generated.fileSize,
retriesUsed: 0
}
};
}
if (result?.kind === "page_error") {
const pageError = normalizePageError(result.error);
return { kind: "error", ...pageError, retryAfterMs: 0 };
}
if (result?.kind !== "response") {
return { kind: "error", status: 0, error: "invalid_response", errorCode: null, retryAfterMs: 0 };
}
const status = normalizeStatus(result.status);
const payload = asRecord(result.payload);
const errorCode = normalizeErrorCode(payload?.error_code);
const retryAfterMs = normalizeRetryAfterMs(result.retryAfter);
if (status === 401 || status === 403 || errorCode === 8) {
return { kind: "login_required" };
}
if (!payload) {
return { kind: "error", status, error: "invalid_response", errorCode: null, retryAfterMs };
}
const errorText = normalizeErrorText(payload.error, status >= 400 ? `http_${status}` : "");
if (status < 200 || status >= 300 || errorText) {
return {
kind: "error",
status,
error: errorText || "web_generation_failed",
errorCode,
retryAfterMs
};
}
const directUrl = typeof payload.download === "string"
? payload.download.trim().slice(0, 4096)
: typeof payload.link === "string"
? payload.link.trim().slice(0, 4096)
: "";
if (!isAllowedDownloadUrl(directUrl)) {
return { kind: "error", status, error: "invalid_download_url", errorCode: null, retryAfterMs };
}
return {
kind: "success",
value: {
directUrl,
fileName: normalizeFileName(payload, directUrl, originalLink),
fileSize: normalizeFileSize(payload.filesize),
retriesUsed: 0
}
};
}
+238 -119
View File
@@ -1,21 +1,18 @@
import { BrowserWindow, session } from "electron";
import { UnrestrictedLink } from "./realdebrid";
import { filenameFromUrl, sleep } from "./utils";
import { RealDebridApiError, UnrestrictedLink } from "./realdebrid";
import { sleep } from "./utils";
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
import { applyRemoteLoginSecurity, createRemoteLoginWebPreferences, REALDEBRID_LOGIN_HOSTS } from "./browser-security";
import { buildRealDebridWebGenerationScript, normalizeRealDebridWebGenerationResult } from "./realdebrid-web-page";
const RD_BASE_URL = "https://real-debrid.com";
const RD_LOGIN_URL = RD_BASE_URL;
const RD_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<void> {
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(ms);
return;
@@ -73,8 +70,44 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
});
}
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return promise;
}
if (signal.aborted) {
throw abortError();
}
return new Promise<T>((resolve, reject) => {
let settled = false;
const onAbort = (): void => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then((value) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
});
});
}
function parseJson(text: string): Record<string, unknown> | null {
try {
@@ -88,11 +121,6 @@ function parseJson(text: string): Record<string, unknown> | null {
}
}
function looksLikeHtmlResponse(text: string): boolean {
const trimmed = text.trim();
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
}
export function extractPrivateTokenFromHtml(html: string): string | null {
const normalized = String(html || "");
if (!normalized.trim()) {
@@ -123,9 +151,17 @@ export class RealDebridWebFallback {
private loginWindow: BrowserWindow | null = null;
private loginWindowPartition = "";
private cachedToken = "";
private loginWindowPartition = "";
private generatorWindow: BrowserWindow | null = null;
private generatorWindowPartition = "";
private generatorGeneration = 0;
private lifecycleAbortController = new AbortController();
private cachedToken = "";
private cachedTokenAt = 0;
@@ -159,20 +195,28 @@ export class RealDebridWebFallback {
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
this.throwIfDisposed();
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
return this.runExclusive(async () => {
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<void> {
this.throwIfDisposed();
@@ -185,6 +229,10 @@ export class RealDebridWebFallback {
void this.primeTokenFromWindow(window);
}
public closeLoginWindow(): void {
this.disposeLoginWindow();
}
public async probeLoginState(signal?: AbortSignal): Promise<RealDebridLoginState> {
this.throwIfDisposed();
let token: string | null = null;
@@ -242,7 +290,9 @@ export class RealDebridWebFallback {
public async clearSessions(): Promise<void> {
this.disposed = true;
this.lifecycleAbortController.abort("clear-sessions");
this.disposeLoginWindow();
this.disposeGeneratorWindow();
this.cachedToken = "";
this.cachedTokenAt = 0;
for (const partition of [this.persistentPartition, this.transientPartition]) {
@@ -262,7 +312,9 @@ export class RealDebridWebFallback {
public dispose(): void {
this.disposed = true;
this.lifecycleAbortController.abort("dispose");
this.disposeLoginWindow();
this.disposeGeneratorWindow();
this.cachedToken = "";
this.cachedTokenAt = 0;
}
@@ -286,9 +338,19 @@ export class RealDebridWebFallback {
this.programmaticClosures.add(current);
current.close();
}
}
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
}
private disposeGeneratorWindow(): void {
this.generatorGeneration += 1;
const current = this.generatorWindow;
this.generatorWindow = null;
this.generatorWindowPartition = "";
if (current && !current.isDestroyed()) {
current.destroy();
}
}
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
const guardedJob = async (): Promise<T> => {
@@ -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<BrowserWindow> {
@@ -363,8 +425,73 @@ export class RealDebridWebFallback {
throw error;
}
return window;
}
}
private async ensureGeneratorWindow(signal?: AbortSignal): Promise<BrowserWindow> {
this.throwIfDisposed();
throwIfAborted(signal);
const partition = this.getPartition();
const existing = this.generatorWindow;
if (existing && !existing.isDestroyed() && this.generatorWindowPartition === partition) {
return existing;
}
if (existing && !existing.isDestroyed()) {
existing.destroy();
}
const window = new BrowserWindow({
width: 900,
height: 700,
show: false,
skipTaskbar: true,
autoHideMenuBar: true,
title: "Real-Debrid Web-Generator",
webPreferences: {
...createRemoteLoginWebPreferences(partition),
backgroundThrottling: false
}
});
applyRemoteLoginSecurity(window, {
providerHosts: REALDEBRID_LOGIN_HOSTS,
externalHosts: []
});
window.setMenuBarVisibility(false);
window.webContents.setUserAgent(RD_USER_AGENT);
window.webContents.on("render-process-gone", () => {
if (this.generatorWindow === window) {
this.disposeGeneratorWindow();
}
});
window.on("closed", () => {
if (this.generatorWindow === window) {
this.generatorWindow = null;
this.generatorWindowPartition = "";
}
});
this.generatorWindow = window;
this.generatorWindowPartition = partition;
const generation = this.generatorGeneration;
const onAbort = (): void => {
if (this.generatorWindow === window && generation === this.generatorGeneration) {
this.disposeGeneratorWindow();
}
};
signal?.addEventListener("abort", onAbort, { once: true });
try {
await raceWithAbort(window.loadURL(RD_DOWNLOADER_URL), signal);
this.throwIfDisposed();
throwIfAborted(signal);
} catch (error) {
if (this.generatorWindow === window) {
this.disposeGeneratorWindow();
}
throw error;
} finally {
signal?.removeEventListener("abort", onAbort);
}
return window;
}
private rememberToken(token: string, generation = this.lifecycleGeneration): string | null {
if (this.disposed || generation !== this.lifecycleGeneration) {
return null;
@@ -503,82 +630,74 @@ export class RealDebridWebFallback {
return null;
}
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
throwIfAborted(signal);
const token = await this.extractApiToken(signal);
if (!token) {
return { kind: "login_required" };
}
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");
}
}