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:
@@ -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
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user