Files
Multi-Debrid-Downloader/src/main/all-debrid-web.ts
T
Sucukdeluxe f57b513625 Align provider selection and abort observation
Share one pure provider selection plan between real unrestrict routing and cooldown projection, including direct 1Fichier and DDownload paths, disabled Mega aliases, and the secondary-provider exception used when Real-Debrid is cooling down. Attach terminal observers to raw serialized web jobs before evaluating already-aborted signals so late rejections stay handled while the queue remains available. Add RED-to-GREEN regressions for each provider-selection counterexample and pre-aborted Real-Debrid, AllDebrid, and BestDebrid jobs.
2026-08-22 11:05:42 +02:00

521 lines
16 KiB
TypeScript

import { BrowserWindow, session } from "electron";
import { AllDebridHostInfo } from "../shared/types";
import { UnrestrictedLink } from "./realdebrid";
import { filenameFromUrl, sleep } from "./utils";
import { ALLDEBRID_LOGIN_HOSTS, applyRemoteLoginSecurity, createRemoteLoginWebPreferences } from "./browser-security";
const ALLDEBRID_BASE_URL = "https://alldebrid.com";
const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`;
const ALLDEBRID_SERVICE_URL = `${ALLDEBRID_BASE_URL}/service.php`;
const ALLDEBRID_SERVICE_REFERER = `${ALLDEBRID_BASE_URL}/service/?from=de`;
const ALLDEBRID_DELAYED_URL = `${ALLDEBRID_BASE_URL}/internalapi/v4/link/delayed`;
const ALLDEBRID_STATUS_URL = `${ALLDEBRID_BASE_URL}/status/`;
const ALLDEBRID_PERSISTENT_PARTITION = "persist:alldebrid-web";
const ALLDEBRID_TRANSIENT_PARTITION = "alldebrid-web";
const ALLDEBRID_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 DelayedStatusPayload = {
status: number;
link: string;
timeLeft: number;
};
type GenerateOutcome =
| { kind: "success"; value: UnrestrictedLink }
| { kind: "login_required" };
function abortError(): Error {
return new Error("aborted:alldebrid-web");
}
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!signal) {
return timeoutSignal;
}
return AbortSignal.any([signal, timeoutSignal]);
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw abortError();
}
}
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(ms);
return;
}
if (signal.aborted) {
throw abortError();
}
await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
signal.removeEventListener("abort", onAbort);
resolve();
}, Math.max(0, ms));
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal.removeEventListener("abort", onAbort);
reject(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return promise;
}
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);
});
if (signal.aborted) {
onAbort();
}
});
}
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 pickString(payload: Record<string, unknown> | null, keys: string[]): string {
if (!payload) {
return "";
}
for (const key of keys) {
const value = payload[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
}
function pickNumber(payload: Record<string, unknown> | null, keys: string[]): number | null {
if (!payload) {
return null;
}
for (const key of keys) {
const value = Number(payload[key] ?? NaN);
if (Number.isFinite(value) && value >= 0) {
return Math.floor(value);
}
}
return null;
}
function parseJson(text: string): Record<string, unknown> | null {
try {
return asRecord(JSON.parse(text) as unknown);
} catch {
return null;
}
}
function normalizeHostName(value: string): string {
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
}
function toHostStateFromIcon(url: string): AllDebridHostInfo["state"] {
const normalized = String(url || "").toLowerCase();
if (normalized.includes("up.gif")) {
return "up";
}
if (normalized.includes("down.gif")) {
return "down";
}
if (normalized.includes("not.tracked")) {
return "not_tracked";
}
return "unknown";
}
function toHostStatusLabel(state: AllDebridHostInfo["state"]): string {
if (state === "up") {
return "Verfügbar";
}
if (state === "down") {
return "Unverfügbar";
}
if (state === "not_tracked") {
return "Nicht getrackt";
}
return "Unbekannt";
}
function extractHostInfoFromStatusPage(html: string, host: string): AllDebridHostInfo | null {
const wanted = normalizeHostName(host);
const rowRegex = /<tr class=['"]g1['"]>\s*<td[^>]*>[\s\S]*?<i[^>]*alt=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/td>\s*<td[^>]*class=['"]comparatif_content['"][^>]*>[\s\S]*?<img[^>]*src=['"]([^'"]+)['"][^>]*>[\s\S]*?\((?:<span[^>]*data-fdate=['"](\d+)['"][^>]*><\/span>|([^<)]*))\)/gi;
for (let match = rowRegex.exec(html); match; match = rowRegex.exec(html)) {
const hostAlt = normalizeHostName(match[1] || "");
if (hostAlt !== wanted) {
continue;
}
const state = toHostStateFromIcon(match[2] || "");
const lastCheckedSeconds = Number(match[3] ?? NaN);
return {
host,
source: "web",
state,
statusLabel: toHostStatusLabel(state),
fetchedAt: Date.now(),
lastCheckedAt: Number.isFinite(lastCheckedSeconds) ? lastCheckedSeconds * 1000 : null,
quota: null,
quotaMax: null,
quotaType: "",
limitSimuDl: null,
note: "Quota und Simultan-Slots sind per Web-Login nicht öffentlich verfügbar."
};
}
return null;
}
export class AllDebridWebFallback {
private queue: Promise<unknown> = Promise.resolve();
private loginWindow: BrowserWindow | null = null;
private loginWindowPartition = "";
private getRememberSession: () => boolean;
public constructor(getRememberSession: () => boolean) {
this.getRememberSession = getRememberSession;
}
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
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;
}
return this.waitForLoginAndGenerate(link, overallSignal);
}, overallSignal);
}
public async openLoginWindow(): Promise<void> {
const window = await this.ensureLoginWindow();
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
public async getHostInfo(host: string): Promise<AllDebridHostInfo> {
const currentSession = session.fromPartition(this.getPartition());
const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, {
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Referer: ALLDEBRID_SERVICE_REFERER,
"User-Agent": ALLDEBRID_USER_AGENT
},
signal: withTimeoutSignal(undefined, 30_000)
});
const text = await response.text();
if (!response.ok) {
throw new Error(`AllDebrid Web Status HTTP ${response.status}`);
}
if (!/id=['"]statusContainer['"]/i.test(text)) {
throw new Error("AllDebrid Web-Status nicht verfügbar. Bitte zuerst im AllDebrid-Fenster einloggen.");
}
const info = extractHostInfoFromStatusPage(text, host);
if (!info) {
throw new Error(`AllDebrid Web-Status für ${host} nicht gefunden`);
}
return info;
}
public async clearSessions(): Promise<void> {
this.disposeLoginWindow();
for (const partition of [ALLDEBRID_PERSISTENT_PARTITION, ALLDEBRID_TRANSIENT_PARTITION]) {
const currentSession = session.fromPartition(partition);
try {
await currentSession.clearStorageData({
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
});
} catch {
}
try {
await currentSession.clearCache();
} catch {
}
}
}
public dispose(): void {
this.disposeLoginWindow();
}
private getPartition(): string {
return this.getRememberSession() ? ALLDEBRID_PERSISTENT_PARTITION : ALLDEBRID_TRANSIENT_PARTITION;
}
private disposeLoginWindow(): void {
const current = this.loginWindow;
this.loginWindow = null;
this.loginWindowPartition = "";
if (current && !current.isDestroyed()) {
current.close();
}
}
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
const queueWaitTimeoutMs = 90_000;
const guardedJob = async (): Promise<T> => {
throwIfAborted(signal);
const waited = Date.now() - queuedAt;
if (waited > queueWaitTimeoutMs) {
throw new Error(`AllDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
}
return job();
};
const run = this.queue.then(guardedJob, guardedJob);
const result = raceWithAbort(run, signal);
this.queue = result.then(() => undefined, () => undefined);
return result;
}
private async ensureLoginWindow(): Promise<BrowserWindow> {
const partition = this.getPartition();
const existing = this.loginWindow;
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) {
return existing;
}
if (existing && !existing.isDestroyed()) {
existing.close();
}
const window = new BrowserWindow({
width: 1120,
height: 900,
minWidth: 980,
minHeight: 760,
autoHideMenuBar: true,
title: "AllDebrid Web-Login",
webPreferences: createRemoteLoginWebPreferences(partition)
});
applyRemoteLoginSecurity(window, {
providerHosts: ALLDEBRID_LOGIN_HOSTS,
externalHosts: ALLDEBRID_LOGIN_HOSTS
});
window.setMenuBarVisibility(false);
window.on("closed", () => {
if (this.loginWindow === window) {
this.loginWindow = null;
this.loginWindowPartition = "";
}
});
this.loginWindow = window;
this.loginWindowPartition = partition;
await window.loadURL(ALLDEBRID_LOGIN_URL);
return window;
}
private async postForm(
url: string,
body: URLSearchParams,
referer: string,
signal?: AbortSignal
): Promise<{ response: Response; text: string }> {
const currentSession = session.fromPartition(this.getPartition());
const response = await currentSession.fetch(url, {
method: "POST",
headers: {
Accept: "application/json, text/javascript, */*; q=0.01",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Origin: ALLDEBRID_BASE_URL,
Referer: referer,
"User-Agent": ALLDEBRID_USER_AGENT,
"X-Requested-With": "XMLHttpRequest"
},
body: body.toString(),
signal: withTimeoutSignal(signal, 30_000)
});
const text = await response.text();
return { response, text };
}
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
throwIfAborted(signal);
const body = new URLSearchParams({
link,
nb: "0",
json: "true",
pw: ""
});
const { response, text } = await this.postForm(ALLDEBRID_SERVICE_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
if (!response.ok) {
throw new Error(`AllDebrid Web HTTP ${response.status}`);
}
const trimmed = text.trim();
if (trimmed === "login") {
return { kind: "login_required" };
}
const payload = parseJson(trimmed);
if (!payload) {
throw new Error("AllDebrid Web lieferte keine JSON-Antwort");
}
const errorText = pickString(payload, ["error"]);
if (errorText) {
if (errorText.toLowerCase() === "premium") {
throw new Error("AllDebrid Web: Premium erforderlich");
}
throw new Error(`AllDebrid Web: ${errorText}`);
}
const directUrl = pickString(payload, ["link"]);
const fileName = pickString(payload, ["filename"]);
const fileSize = pickNumber(payload, ["filesize"]);
if (directUrl) {
return {
kind: "success",
value: {
directUrl,
fileName: fileName || filenameFromUrl(directUrl) || filenameFromUrl(link),
fileSize,
retriesUsed: 0
}
};
}
const delayedId = payload.delayed;
if (delayedId !== undefined && delayedId !== null && delayedId !== false && String(delayedId).trim()) {
const delayed = await this.waitForDelayedLink(String(delayedId).trim(), signal);
return {
kind: "success",
value: {
directUrl: delayed.link,
fileName: fileName || filenameFromUrl(delayed.link) || filenameFromUrl(link),
fileSize: fileSize,
retriesUsed: 0
}
};
}
if (Array.isArray(payload.streams) && payload.streams.length > 0) {
throw new Error("AllDebrid Web: Streaming-Auswahl wird derzeit nicht unterstützt");
}
throw new Error("AllDebrid Web: Antwort ohne Download-Link");
}
private async waitForDelayedLink(delayedId: string, signal?: AbortSignal): Promise<DelayedStatusPayload> {
for (let attempt = 1; attempt <= 120; attempt += 1) {
throwIfAborted(signal);
const body = new URLSearchParams({ id: delayedId });
const { response, text } = await this.postForm(ALLDEBRID_DELAYED_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
if (!response.ok) {
throw new Error(`AllDebrid Web delayed HTTP ${response.status}`);
}
const payload = parseJson(text.trim());
const data = asRecord(payload?.data);
if (pickString(payload, ["status"]).toLowerCase() !== "success" || !data) {
throw new Error("AllDebrid Web: Delayed-Status ungültig");
}
const status = Number(data.status ?? NaN);
if (!Number.isFinite(status)) {
throw new Error("AllDebrid Web: Delayed-Status ohne Status");
}
if (status >= 2) {
const link = pickString(data, ["link"]);
if (!link) {
throw new Error("AllDebrid Web: Delayed-Link fehlt");
}
return {
status,
link,
timeLeft: Math.max(0, Number(data.time_left ?? 0) || 0)
};
}
const timeLeft = Math.max(0, Number(data.time_left ?? 0) || 0);
const delayMs = timeLeft > 0 ? Math.min(5_000, Math.max(1_500, timeLeft * 250)) : 2_000;
await sleepWithSignal(delayMs, signal);
}
throw new Error("AllDebrid Web: Delayed-Link Timeout");
}
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
const window = await this.ensureLoginWindow();
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
const startedAt = Date.now();
while (Date.now() - startedAt < 10 * 60 * 1000) {
throwIfAborted(signal);
if (window.isDestroyed()) {
throw new Error("AllDebrid Web-Login abgebrochen");
}
const outcome = await this.generate(link, signal);
if (outcome.kind === "success") {
if (!window.isDestroyed()) {
window.close();
}
return outcome.value;
}
await sleepWithSignal(1_500, signal);
}
throw new Error("AllDebrid Web-Login Timeout");
}
}