From e4a60a033b990d9da4b2e0ed46cecca8945ac171 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 6 Mar 2026 04:17:22 +0100 Subject: [PATCH] Release v1.6.69 --- package-lock.json | 4 +- package.json | 2 +- src/main/all-debrid-web.ts | 484 +++++++++++++++++++++++++++++++++++ src/main/app-controller.ts | 36 ++- src/main/constants.ts | 1 + src/main/debrid.ts | 158 +++++++++++- src/main/download-manager.ts | 8 +- src/main/main.ts | 8 + src/main/storage.ts | 1 + src/preload/preload.ts | 3 + src/renderer/App.tsx | 146 ++++++++++- src/shared/ipc.ts | 2 + src/shared/preload-api.ts | 3 + src/shared/types.ts | 18 ++ tests/debrid.test.ts | 84 +++++- tests/storage.test.ts | 16 ++ 16 files changed, 960 insertions(+), 14 deletions(-) create mode 100644 src/main/all-debrid-web.ts diff --git a/package-lock.json b/package-lock.json index 2399cd8..1bbce4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.5.66", + "version": "1.6.69", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.5.66", + "version": "1.6.69", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index e8a30be..3033221 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.6.66", + "version": "1.6.69", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/all-debrid-web.ts b/src/main/all-debrid-web.ts new file mode 100644 index 0000000..71d7ce4 --- /dev/null +++ b/src/main/all-debrid-web.ts @@ -0,0 +1,484 @@ +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"); + } +} diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index dcb90b4..d7fa66d 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { app } from "electron"; import { AddLinksPayload, + AllDebridHostInfo, AppSettings, DuplicatePolicy, HistoryEntry, @@ -18,8 +19,10 @@ import { import { importDlcContainers } from "./container"; import { APP_VERSION } from "./constants"; import { DownloadManager } from "./download-manager"; +import { fetchAllDebridHostInfo } from "./debrid"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; +import { AllDebridWebFallback } from "./all-debrid-web"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage"; @@ -42,6 +45,8 @@ export class AppController { private megaWebFallback: MegaWebFallback; + private allDebridWebFallback: AllDebridWebFallback; + private lastUpdateCheck: UpdateCheckResult | null = null; private lastUpdateCheckAt = 0; @@ -61,8 +66,10 @@ export class AppController { login: this.settings.megaLogin, password: this.settings.megaPassword })); + this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken); this.manager = new DownloadManager(this.settings, session, this.storagePaths, { megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal), + allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), onHistoryEntry: (entry: HistoryEntry) => { addHistoryEntry(this.storagePaths, entry); @@ -104,6 +111,7 @@ export class AppController { settings.token.trim() || (settings.megaLogin.trim() && settings.megaPassword.trim()) || settings.bestToken.trim() + || settings.allDebridUseWebLogin || settings.allDebridToken.trim() || (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim()) || settings.oneFichierApiKey.trim() @@ -143,13 +151,14 @@ export class AppController { public updateSettings(partial: Partial): AppSettings { const sanitizedPatch = sanitizeSettingsPatch(partial); + const previousSettings = this.settings; const nextSettings = normalizeSettings({ - ...this.settings, + ...previousSettings, ...sanitizedPatch }); - if (settingsFingerprint(nextSettings) === settingsFingerprint(this.settings)) { - return this.settings; + if (settingsFingerprint(nextSettings) === settingsFingerprint(previousSettings)) { + return previousSettings; } // Preserve the live totalDownloadedAllTime from the download manager @@ -158,9 +167,29 @@ export class AppController { this.settings = nextSettings; saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); + if (previousSettings.rememberToken && !this.settings.rememberToken) { + void this.allDebridWebFallback.clearSessions().catch((error) => { + logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); + }); + } return this.settings; } + public async openAllDebridLoginWindow(): Promise { + await this.allDebridWebFallback.openLoginWindow(); + } + + public async getAllDebridHostInfo(host = "rapidgator"): Promise { + if (this.settings.allDebridUseWebLogin) { + return this.allDebridWebFallback.getHostInfo(host); + } + const token = this.settings.allDebridToken.trim(); + if (!token) { + throw new Error("AllDebrid ist nicht konfiguriert"); + } + return fetchAllDebridHostInfo(token, host); + } + public async checkUpdates(): Promise { const result = await checkGitHubUpdate(this.settings.updateRepo); if (!result.error) { @@ -350,6 +379,7 @@ export class AppController { abortActiveUpdateDownload(); this.manager.prepareForShutdown(); this.megaWebFallback.dispose(); + this.allDebridWebFallback.dispose(); shutdownSessionLog(); logger.info("App beendet"); } diff --git a/src/main/constants.ts b/src/main/constants.ts index c40b7f4..e6b3008 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -45,6 +45,7 @@ export function defaultSettings(): AppSettings { megaPassword: "", bestToken: "", allDebridToken: "", + allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 400d139..1d02582 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1,4 +1,4 @@ -import { AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types"; +import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types"; import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { logger } from "./logger"; import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; @@ -10,6 +10,7 @@ const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024; const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4"; +const ALL_DEBRID_API_BASE_V41 = "https://api.alldebrid.com/v4.1"; const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1"; const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i; @@ -29,9 +30,11 @@ interface ProviderUnrestrictedLink extends UnrestrictedLink { } export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; +export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; interface DebridServiceOptions { megaWebUnrestrict?: MegaWebUnrestrictor; + allDebridWebUnrestrict?: AllDebridWebUnrestrictor; } function cloneSettings(settings: AppSettings): AppSettings { @@ -201,6 +204,43 @@ function parseAllDebridError(payload: Record | null): string { return pickString(errorObj, ["message", "code"]) || "AllDebrid API error"; } +function normalizeAllDebridHostKey(value: string): string { + return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase(); +} + +function toAllDebridHostState(value: unknown): AllDebridHostInfo["state"] { + if (value === true) { + return "up"; + } + if (value === false) { + return "down"; + } + const normalized = String(value || "").trim().toLowerCase(); + if (normalized === "up" || normalized === "online" || normalized === "available") { + return "up"; + } + if (normalized === "down" || normalized === "offline" || normalized === "unavailable") { + return "down"; + } + if (normalized === "not_tracked" || normalized === "not tracked") { + return "not_tracked"; + } + return "unknown"; +} + +function toAllDebridHostStatusLabel(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 uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] { const seen = new Set(); const result: DebridProvider[] = []; @@ -886,6 +926,105 @@ class AllDebridClient { return result; } + public async getHostInfo(host: string, signal?: AbortSignal): Promise { + const wanted = normalizeAllDebridHostKey(host); + let lastError = ""; + + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + const response = await fetch(`${ALL_DEBRID_API_BASE_V41}/user/hosts`, { + method: "GET", + headers: { + Authorization: `Bearer ${this.token}`, + "User-Agent": DEBRID_USER_AGENT + }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const text = await response.text(); + const payload = asRecord(parseJson(text)); + + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + throw new Error(reason); + } + + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text); + if (looksHtml) { + throw new Error("AllDebrid lieferte HTML statt JSON"); + } + if (!payload) { + throw new Error("AllDebrid Antwort ist kein JSON-Objekt"); + } + + const status = pickString(payload, ["status"]); + if (status && status.toLowerCase() === "error") { + throw new Error(parseAllDebridError(payload)); + } + + const data = asRecord(payload.data); + const hosts = asRecord(data?.hosts); + if (!hosts) { + throw new Error("AllDebrid Antwort ohne Host-Liste"); + } + + let hostEntry = asRecord(hosts[host]) || asRecord(hosts[wanted]); + if (!hostEntry) { + for (const entry of Object.values(hosts)) { + const candidate = asRecord(entry); + const candidateName = normalizeAllDebridHostKey(pickString(candidate, ["name"])); + if (candidateName === wanted) { + hostEntry = candidate; + break; + } + } + } + + if (!hostEntry) { + throw new Error(`AllDebrid Host ${host} nicht gefunden`); + } + + const state = toAllDebridHostState(hostEntry.status); + const quota = pickNumber(hostEntry, ["quota"]); + const quotaMax = pickNumber(hostEntry, ["quotaMax"]); + const limitSimuDl = pickNumber(hostEntry, ["limitSimuDl"]); + const quotaType = pickString(hostEntry, ["quotaType"]); + const note = quota === null && quotaMax === null && limitSimuDl === null + ? "AllDebrid liefert für diesen Host aktuell keine Quota- oder Slot-Daten." + : ""; + + return { + host: pickString(hostEntry, ["name"]) || host, + source: "api", + state, + statusLabel: toAllDebridHostStatusLabel(state), + fetchedAt: Date.now(), + lastCheckedAt: null, + quota, + quotaMax, + quotaType, + limitSimuDl, + note + }; + } catch (error) { + lastError = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { + break; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + throw new Error(String(lastError || "AllDebrid Host-Info fehlgeschlagen").replace(/^Error:\s*/i, "")); + } + public async unrestrictLink(link: string, signal?: AbortSignal): Promise { let lastError = ""; for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { @@ -963,6 +1102,10 @@ class AllDebridClient { } } +export async function fetchAllDebridHostInfo(token: string, host = "rapidgator", signal?: AbortSignal): Promise { + return new AllDebridClient(token).getHostInfo(host, signal); +} + // ── 1Fichier Client ── class OneFichierClient { @@ -1290,6 +1433,10 @@ export class DebridService { return clean; } + private shouldUseAllDebridWeb(settings: AppSettings): boolean { + return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict); + } + public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise { const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings); @@ -1415,7 +1562,7 @@ export class DebridService { return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim() && this.options.megaWebUnrestrict); } if (provider === "alldebrid") { - return Boolean(settings.allDebridToken.trim()); + return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim()); } if (provider === "ddownload") { return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim()); @@ -1434,6 +1581,13 @@ export class DebridService { return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link, signal); } if (provider === "alldebrid") { + if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) { + const result = await this.options.allDebridWebUnrestrict(link, signal); + if (!result) { + throw new Error("AllDebrid-Web-Fallback nicht verfügbar"); + } + return result; + } return new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal); } if (provider === "ddownload") { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index d0b65e5..b9c6007 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -38,7 +38,7 @@ function releaseTlsSkip(): void { } } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; -import { DebridService, MegaWebUnrestrictor, checkRapidgatorOnline } from "./debrid"; +import { AllDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, checkRapidgatorOnline } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { logger } from "./logger"; @@ -157,6 +157,7 @@ type HistoryEntryCallback = (entry: HistoryEntry) => void; type DownloadManagerOptions = { megaWebUnrestrict?: MegaWebUnrestrictor; + allDebridWebUnrestrict?: AllDebridWebUnrestrictor; invalidateMegaSession?: () => void; onHistoryEntry?: HistoryEntryCallback; }; @@ -948,7 +949,10 @@ export class DownloadManager extends EventEmitter { this.session = cloneSession(session); this.itemCount = Object.keys(this.session.items).length; this.storagePaths = storagePaths; - this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict }); + this.debridService = new DebridService(settings, { + megaWebUnrestrict: options.megaWebUnrestrict, + allDebridWebUnrestrict: options.allDebridWebUnrestrict + }); this.invalidateMegaSessionFn = options.invalidateMegaSession; this.onHistoryEntryCallback = options.onHistoryEntry; this.applyOnStartCleanupPolicy(); diff --git a/src/main/main.ts b/src/main/main.ts index 562e407..f79a625 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -446,6 +446,14 @@ function registerIpcHandlers(): void { } }); + ipcMain.handle(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => { + await controller.openAllDebridLoginWindow(); + }); + + ipcMain.handle(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO, async () => { + return controller.getAllDebridHostInfo(); + }); + ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => { const options = { properties: ["openFile"] as Array<"openFile">, diff --git a/src/main/storage.ts b/src/main/storage.ts index 498c017..25bb84d 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -111,6 +111,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { megaPassword: asText(settings.megaPassword), bestToken: asText(settings.bestToken), allDebridToken: asText(settings.allDebridToken), + allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin), ddownloadLogin: asText(settings.ddownloadLogin), ddownloadPassword: asText(settings.ddownloadPassword), oneFichierApiKey: asText(settings.oneFichierApiKey), diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 9c46c21..88377a0 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from "electron"; import { AddLinksPayload, + AllDebridHostInfo, AppSettings, DuplicatePolicy, HistoryEntry, @@ -51,6 +52,8 @@ const api: ElectronApi = { importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openSessionLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), + openAllDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), + getAllDebridHostInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), resetPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 583e48e..7a0232e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,6 @@ import { DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { + AllDebridHostInfo, AppSettings, AppTheme, BandwidthScheduleEntry, @@ -62,7 +63,7 @@ const emptyStats = (): DownloadStats => ({ const emptySnapshot = (): UiSnapshot => ({ settings: { - token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", + token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", archivePasswordList: "", rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid", providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "", @@ -141,6 +142,35 @@ function humanSize(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(3)} TB`; } +function formatAllDebridSourceLabel(source: AllDebridHostInfo["source"]): string { + return source === "web" ? "Web-Login" : "API-Key"; +} + +function formatAllDebridQuota(info: AllDebridHostInfo): string { + const suffix = info.quotaType ? ` (${info.quotaType})` : ""; + if (info.quota !== null && info.quotaMax !== null) { + return `${info.quota} / ${info.quotaMax}${suffix}`; + } + if (info.quota !== null) { + return `${info.quota}${suffix}`; + } + if (info.quotaMax !== null) { + return `max. ${info.quotaMax}${suffix}`; + } + return info.source === "web" ? "Nur per API-Key sichtbar" : "Nicht angegeben"; +} + +function formatAllDebridSimuLimit(info: AllDebridHostInfo): string { + if (info.limitSimuDl === null) { + return info.source === "web" ? "Nur per API-Key sichtbar" : "Nicht angegeben"; + } + return String(info.limitSimuDl); +} + +function formatAllDebridTimestamp(info: AllDebridHostInfo): string { + return formatDateTime(info.lastCheckedAt || info.fetchedAt); +} + interface BandwidthChartProps { items: Record; running: boolean; @@ -528,6 +558,9 @@ export function App(): ReactElement { const [selectedHistoryIds, setSelectedHistoryIds] = useState>(new Set()); const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null); const historyCtxMenuRef = useRef(null); + const [allDebridHostInfo, setAllDebridHostInfo] = useState(null); + const [allDebridHostLoading, setAllDebridHostLoading] = useState(false); + const allDebridHostRequestRef = useRef(0); // Load history when tab changes to history useEffect(() => { @@ -612,6 +645,31 @@ export function App(): ReactElement { }, timeoutMs); }, []); + const loadAllDebridHostInfo = useCallback(async (silent = false): Promise => { + const requestId = allDebridHostRequestRef.current + 1; + allDebridHostRequestRef.current = requestId; + setAllDebridHostLoading(true); + try { + const info = await window.rd.getAllDebridHostInfo(); + if (!mountedRef.current || allDebridHostRequestRef.current !== requestId) { + return; + } + setAllDebridHostInfo(info); + } catch (error) { + if (!mountedRef.current || allDebridHostRequestRef.current !== requestId) { + return; + } + setAllDebridHostInfo(null); + if (!silent) { + showToast(`AllDebrid Status fehlgeschlagen: ${String(error)}`, 3200); + } + } finally { + if (mountedRef.current && allDebridHostRequestRef.current === requestId) { + setAllDebridHostLoading(false); + } + } + }, [showToast]); + const clearImportQueueFocusListener = useCallback((): void => { const handler = importQueueFocusHandlerRef.current; if (!handler) { @@ -866,12 +924,28 @@ export function App(): ReactElement { return [...active, ...rest]; }, [packages, snapshot.session.running, snapshot.session.items]); + const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim()); + const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin + || snapshot.settings.allDebridToken !== settingsDraft.allDebridToken; + useEffect(() => { if (!snapshot.session.running) { setShowAllPackages(false); } }, [snapshot.session.running]); + useEffect(() => { + if (settingsSubTab !== "accounts") { + return; + } + if (!hasSavedAllDebridAccount) { + setAllDebridHostInfo(null); + setAllDebridHostLoading(false); + return; + } + void loadAllDebridHostInfo(true); + }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); + // Auto-expand packages that are currently extracting (only once per extraction cycle) useEffect(() => { const extractingPkgIds: string[] = []; @@ -917,11 +991,11 @@ export function App(): ReactElement { if (settingsDraft.bestToken.trim()) { list.push("bestdebrid"); } - if (settingsDraft.allDebridToken.trim()) { + if (settingsDraft.allDebridUseWebLogin || settingsDraft.allDebridToken.trim()) { list.push("alldebrid"); } return list; - }, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken]); + }, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken, settingsDraft.allDebridUseWebLogin]); // DDownload is a direct file hoster (not a debrid service) and is used automatically // for ddownload.com/ddl.to URLs. It counts as a configured account but does not @@ -1062,6 +1136,16 @@ export function App(): ReactElement { }); }; + const onOpenAllDebridLogin = async (): Promise => { + await performQuickAction(async () => { + await persistDraftSettings(); + await window.rd.openAllDebridLogin(); + showToast("AllDebrid Login-Fenster geöffnet", 2200); + }, (error) => { + showToast(`AllDebrid Login fehlgeschlagen: ${String(error)}`, 2800); + }); + }; + const onCheckUpdates = async (): Promise => { let updateResult: UpdateCheckResult | null = null; await performQuickAction(async () => { @@ -2744,6 +2828,62 @@ export function App(): ReactElement { setText("bestToken", e.target.value)} /> setText("allDebridToken", e.target.value)} /> + + {settingsDraft.allDebridUseWebLogin && ( + <> +
Beim ersten Link oder über den Button unten öffnet sich ein echtes AllDebrid-Browserfenster. Der Login läuft dort manuell, damit reCAPTCHA sauber funktioniert.
+ + + )} +
+
+ AllDebrid Rapidgator Status + +
+ {!hasSavedAllDebridAccount && ( +
Nach dem Speichern eines AllDebrid-Accounts wird hier der Rapidgator-Status angezeigt.
+ )} + {hasSavedAllDebridAccount && !allDebridHostInfo && !allDebridHostLoading && ( +
Noch keine Host-Information geladen.
+ )} + {hasSavedAllDebridAccount && allDebridHostLoading && !allDebridHostInfo && ( +
Rapidgator-Status wird geladen...
+ )} + {allDebridHostInfo && ( + <> +
+
+
Status
+
{allDebridHostInfo.statusLabel}
+
+
+
Quelle
+
{formatAllDebridSourceLabel(allDebridHostInfo.source)}
+
+
+
Letztes Update
+
{formatAllDebridTimestamp(allDebridHostInfo)}
+
+
+
Quota
+
{formatAllDebridQuota(allDebridHostInfo)}
+
+
+
Simultan-Downloads
+
{formatAllDebridSimuLimit(allDebridHostInfo)}
+
+
+ {allDebridHostInfo.note && ( +
{allDebridHostInfo.note}
+ )} + + )} + {allDebridSettingsDirty && hasSavedAllDebridAccount && ( +
Status basiert auf den zuletzt gespeicherten AllDebrid-Einstellungen.
+ )} +
setText("ddownloadLogin", e.target.value)} /> diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index db0c47d..5066b95 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -34,6 +34,8 @@ export const IPC_CHANNELS = { IMPORT_BACKUP: "app:import-backup", OPEN_LOG: "app:open-log", OPEN_SESSION_LOG: "app:open-session-log", + OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", + GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info", RETRY_EXTRACTION: "queue:retry-extraction", EXTRACT_NOW: "queue:extract-now", RESET_PACKAGE: "queue:reset-package", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index f0b360a..3c2ffa5 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -1,5 +1,6 @@ import type { AddLinksPayload, + AllDebridHostInfo, AppSettings, DuplicatePolicy, HistoryEntry, @@ -46,6 +47,8 @@ export interface ElectronApi { importBackup: () => Promise<{ restored: boolean; message: string }>; openLog: () => Promise; openSessionLog: () => Promise; + openAllDebridLogin: () => Promise; + getAllDebridHostInfo: () => Promise; retryExtraction: (packageId: string) => Promise; extractNow: (packageId: string) => Promise; resetPackage: (packageId: string) => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index b6283a1..0aeb438 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -42,6 +42,7 @@ export interface AppSettings { megaPassword: string; bestToken: string; allDebridToken: string; + allDebridUseWebLogin: boolean; ddownloadLogin: string; ddownloadPassword: string; oneFichierApiKey: string; @@ -240,6 +241,23 @@ export interface UpdateInstallProgress { message: string; } +export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown"; +export type AllDebridHostInfoSource = "api" | "web"; + +export interface AllDebridHostInfo { + host: string; + source: AllDebridHostInfoSource; + state: AllDebridHostState; + statusLabel: string; + fetchedAt: number; + lastCheckedAt: number | null; + quota: number | null; + quotaMax: number | null; + quotaType: string; + limitSimuDl: number | null; + note: string; +} + export interface ParsedHashEntry { fileName: string; algorithm: "crc32" | "md5" | "sha1"; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 7647be4..0fcd169 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants"; -import { DebridService, extractRapidgatorFilenameFromHtml, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid"; +import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, filenameFromRapidgatorUrlPath, normalizeResolvedFilename } from "../src/main/debrid"; const originalFetch = globalThis.fetch; @@ -243,6 +243,88 @@ describe("debrid service", () => { expect(result.fileSize).toBe(4096); }); + it("loads AllDebrid host info via api", async () => { + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.alldebrid.com/v4.1/user/hosts")) { + return new Response(JSON.stringify({ + status: "success", + data: { + hosts: { + rapidgator: { + name: "rapidgator", + status: false, + quota: 1200, + quotaMax: 2400, + quotaType: "traffic", + limitSimuDl: 2 + } + } + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const info = await fetchAllDebridHostInfo("ad-token", "rapidgator"); + expect(info.source).toBe("api"); + expect(info.host).toBe("rapidgator"); + expect(info.state).toBe("down"); + expect(info.statusLabel).toBe("Unverfügbar"); + expect(info.quota).toBe(1200); + expect(info.quotaMax).toBe(2400); + expect(info.quotaType).toBe("traffic"); + expect(info.limitSimuDl).toBe(2); + }); + + it("uses AllDebrid web path when enabled", async () => { + const settings = { + ...defaultSettings(), + allDebridToken: "ad-token", + allDebridUseWebLogin: true, + providerPrimary: "alldebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const allDebridWeb = vi.fn(async () => ({ + fileName: "from-web.rar", + directUrl: "https://df4ea4.debrid.it/dl/example/from-web.rar", + fileSize: 1234, + retriesUsed: 0 + })); + + const service = new DebridService(settings, { allDebridWebUnrestrict: allDebridWeb }); + const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html"); + expect(result.provider).toBe("alldebrid"); + expect(result.directUrl).toContain("debrid.it/dl/"); + expect(result.fileSize).toBe(1234); + expect(allDebridWeb).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(0); + }); + + it("treats AllDebrid web mode as not configured when callback is unavailable", async () => { + const settings = { + ...defaultSettings(), + allDebridToken: "", + allDebridUseWebLogin: true, + providerPrimary: "alldebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://rapidgator.net/file/missing-alldebrid-web")).rejects.toThrow(/nicht konfiguriert/i); + }); + it("treats MegaDebrid as not configured when web fallback callback is unavailable", async () => { const settings = { ...defaultSettings(), diff --git a/tests/storage.test.ts b/tests/storage.test.ts index a817757..f164bdd 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -155,6 +155,22 @@ describe("settings storage", () => { expect(normalized.archivePasswordList).toBe("one\ntwo\nthree"); }); + it("defaults AllDebrid web login to disabled and normalizes the flag", () => { + expect(defaultSettings().allDebridUseWebLogin).toBe(false); + + const normalizedEnabled = normalizeSettings({ + ...defaultSettings(), + allDebridUseWebLogin: 1 as unknown as boolean + }); + expect(normalizedEnabled.allDebridUseWebLogin).toBe(true); + + const normalizedDisabled = normalizeSettings({ + ...defaultSettings(), + allDebridUseWebLogin: 0 as unknown as boolean + }); + expect(normalizedDisabled.allDebridUseWebLogin).toBe(false); + }); + it("assigns and preserves bandwidth schedule ids", () => { const normalized = normalizeSettings({ ...defaultSettings(),