import { BrowserWindow, session } from "electron"; import { AllDebridHostInfo } from "../shared/types"; import { UnrestrictedLink } from "./realdebrid"; import { filenameFromUrl, sleep } from "./utils"; 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 { if (!signal) { await sleep(ms); return; } if (signal.aborted) { throw abortError(); } await new Promise((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 }); }); } function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } return value as Record; } function pickString(payload: Record | 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 | 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 | 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 = /\s*]*>[\s\S]*?]*alt=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/td>\s*]*class=['"]comparatif_content['"][^>]*>[\s\S]*?]*src=['"]([^'"]+)['"][^>]*>[\s\S]*?\((?:]*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 = 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 { 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 { const window = await this.ensureLoginWindow(); if (window.isMinimized()) { window.restore(); } window.show(); window.focus(); } public async getHostInfo(host: string): Promise { 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 { 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 { // ignore } try { await currentSession.clearCache(); } catch { // ignore } } } 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(job: () => Promise, signal?: AbortSignal): Promise { const queuedAt = Date.now(); const queueWaitTimeoutMs = 90_000; const guardedJob = async (): Promise => { 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); this.queue = run.then(() => undefined, () => undefined); return run; } private async ensureLoginWindow(): Promise { 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: { partition, contextIsolation: true, nodeIntegration: false } }); 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 { 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 { 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 { 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"); } }