From c46f9a1d7a043e566fc9d5fd900895816279844d Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 23 Aug 2026 16:03:09 +0200 Subject: [PATCH] release: rebuild AllDebrid integration for v2.0.66 Replace the legacy cookie and reCAPTCHA scraper with AllDebrid's official browser PIN authorization and API-key flow. Persist verified account identity and premium status, include AllDebrid in direct and bulk checks, and migrate stale web-only configurations to reauthorization. Remove the unconditional three-second scheduler delay while retaining provider-reported slot limits. Preserve official error codes and terminate unavailable-link, unsupported-link, authentication, IP, and server restriction failures without retries or provider cooldowns. --- CHANGELOG.md | 23 + package-lock.json | 4 +- package.json | 2 +- src/main/account-check.ts | 81 +++- src/main/account-commands.ts | 4 +- src/main/all-debrid-web.ts | 827 +++++++++++++-------------------- src/main/app-controller.ts | 58 ++- src/main/debrid.ts | 56 +-- src/main/download-manager.ts | 227 +++------ src/main/renderer-state.ts | 11 +- src/main/storage.ts | 22 +- src/renderer/App.tsx | 30 +- src/shared/types.ts | 2 +- tests/account-check.test.ts | 72 ++- tests/account-commands.test.ts | 31 +- tests/alldebrid-web.test.ts | 272 ++++++----- tests/app-controller.test.ts | 80 +++- tests/app-shell.test.tsx | 13 + tests/debrid.test.ts | 78 +++- tests/download-manager.test.ts | 250 +++++++--- tests/renderer-state.test.ts | 32 ++ tests/storage.test.ts | 56 ++- 22 files changed, 1267 insertions(+), 964 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ff6b9..1a4ed62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.66] - 2026-08-23 + +### AllDebrid authentication + +- Replace the legacy website and cookie workflow with AllDebrid's official browser PIN authorization. +- Store the issued API key securely and use the official API for account details, host availability, link inspection, and link unlocking. +- Close the authorization window automatically after activation while keeping manual close, timeout, and repeated-open behavior deterministic. +- Require existing legacy browser-login configurations to authorize again instead of treating an empty session as a usable account. + +### Account status + +- Validate AllDebrid API and browser-authorized accounts through the official user endpoint. +- Display and persist username, email address, premium state, and expiration time in the account table. +- Include AllDebrid in active-account checks, full-account checks, direct row checks, and activation checks. +- Preserve the PIN-issued API key while editing a browser-authorized account. + +### Download scheduling and recovery + +- Remove the unconditional three-second delay before every AllDebrid download while continuing to honor live simultaneous-download limits reported by the provider. +- Treat unavailable links and unsupported link or host responses as terminal item failures without retrying unrelated downloads. +- Treat invalid, missing, blocked, banned, server-restricted, and IP-restricted credentials as terminal authentication failures without provider cooldowns. +- Preserve official AllDebrid error codes alongside their messages so retry and cooldown decisions remain precise. + ## [2.0.65] - 2026-08-23 ### Extract now behavior diff --git a/package-lock.json b/package-lock.json index f69c443..c8a1aef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-debrid-downloader", - "version": "2.0.65", + "version": "2.0.66", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-debrid-downloader", - "version": "2.0.65", + "version": "2.0.66", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index de54088..89ccc99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-debrid-downloader", - "version": "2.0.65", + "version": "2.0.66", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/account-check.ts b/src/main/account-check.ts index cd6694e..a3cd019 100644 --- a/src/main/account-check.ts +++ b/src/main/account-check.ts @@ -8,11 +8,13 @@ import { compactErrorText } from "./utils"; const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php"; const DEBRID_LINK_API = "https://debrid-link.com/api/v2"; const REAL_DEBRID_USER_API = "https://api.real-debrid.com/rest/1.0/user"; +const ALL_DEBRID_USER_API = "https://api.alldebrid.com/v4/user"; const CHECK_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"; const CHECK_TIMEOUT_MS = 20000; export const REAL_DEBRID_STATUS_ID = "svc-realdebrid"; +export const ALL_DEBRID_STATUS_ID = "svc-alldebrid"; export interface RealDebridSessionProbeResult { valid: boolean; @@ -175,6 +177,80 @@ export async function checkRealDebridAccount( return { ...base, message: aborted ? "Prüfung abgebrochen" : `Prüfung fehlgeschlagen: ${errText}` }; } } + +export async function checkAllDebridAccount( + token: string, + signal?: AbortSignal, + now = Date.now() +): Promise { + const base: DebridAccountStatus = { + accountId: ALL_DEBRID_STATUS_ID, + provider: "alldebrid", + label: "AllDebrid", + maskedLogin: "Geschützter API-Key", + valid: false, + isPremium: false, + premiumUntilMs: null, + message: "", + checkedAt: now + }; + const apiKey = token.trim(); + if (!apiKey) { + return { ...base, message: "Kein API-Key hinterlegt" }; + } + try { + const response = await fetch(ALL_DEBRID_USER_API, { + headers: { + Authorization: `Bearer ${apiKey}`, + "User-Agent": CHECK_USER_AGENT + }, + signal: timeoutSignal(signal, CHECK_TIMEOUT_MS) + }); + const text = await response.text(); + const payload = parseJsonSafe(text); + const error = payload?.error && typeof payload.error === "object" + ? payload.error as Record + : null; + const errorCode = String(error?.code || "").trim(); + if (!response.ok || payload?.status !== "success") { + if (response.status === 401 || response.status === 403 || errorCode === "AUTH_MISSING_APIKEY" || errorCode === "AUTH_BAD_APIKEY") { + return { ...base, message: "Ungültiger API-Key" }; + } + if (errorCode === "AUTH_BLOCKED") { + return { ...base, message: "API-Key für diese IP blockiert" }; + } + if (errorCode === "AUTH_USER_BANNED") { + return { ...base, message: "AllDebrid-Account gesperrt" }; + } + return { ...base, message: `Prüfung fehlgeschlagen (HTTP ${response.status})` }; + } + const data = payload.data && typeof payload.data === "object" + ? payload.data as Record + : null; + const user = data?.user && typeof data.user === "object" + ? data.user as Record + : null; + if (!user) { + return { ...base, message: "Prüfung fehlgeschlagen: Ungültige API-Antwort" }; + } + const premiumUntilSec = Number(user.premiumUntil || 0); + const premiumUntilMs = Number.isFinite(premiumUntilSec) && premiumUntilSec > 0 ? premiumUntilSec * 1000 : 0; + const isPremium = Boolean(user.isPremium) && premiumUntilMs > now; + return { + ...base, + valid: true, + isPremium, + premiumUntilMs, + username: String(user.username || "").trim() || undefined, + email: String(user.email || "").trim() || undefined, + message: isPremium ? formatRemaining(premiumUntilMs, now) : "Kein Premium (Free)" + }; + } catch (error) { + const errText = compactErrorText(error); + const aborted = signal?.aborted || /aborted/i.test(errText); + return { ...base, message: aborted ? "Prüfung abgebrochen" : `Prüfung fehlgeschlagen: ${errText}` }; + } +} export async function checkMegaDebridAccount( account: MegaDebridAccountEntry, @@ -337,6 +413,8 @@ export async function checkAllDebridAccounts( const realDebridAccounts = scope === "all" ? allRealDebridAccounts : providerEnabled("realdebrid") ? allRealDebridAccounts.filter((account) => account.enabled) : []; + const allDebridToken = String(settings.allDebridToken || "").trim(); + const checkAllDebrid = Boolean(allDebridToken) && (scope === "all" || providerEnabled("alldebrid")); const taskFns: Array<() => Promise> = [ ...realDebridAccounts.map((account) => () => checkRealDebridAccount( @@ -347,7 +425,8 @@ export async function checkAllDebridAccounts( ? (probeSignal) => probeRealDebridWebSession(account.id, probeSignal) : undefined )), - ...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)), + ...(checkAllDebrid ? [() => checkAllDebridAccount(allDebridToken, signal, now)] : []), + ...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)), ...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now)) ]; diff --git a/src/main/account-commands.ts b/src/main/account-commands.ts index c143e90..bda30a1 100644 --- a/src/main/account-commands.ts +++ b/src/main/account-commands.ts @@ -127,6 +127,8 @@ export function validateAccountCredentialCheckInput(value: unknown): AccountCred && raw.kind !== "realdebrid-web" && raw.kind !== "megadebrid-api" && raw.kind !== "megadebrid-web" + && raw.kind !== "alldebrid-api" + && raw.kind !== "alldebrid-web" && raw.kind !== "debridlink-api") invalid(); return { kind: raw.kind, @@ -547,7 +549,7 @@ function setSingle(settings: AppSettings, kind: RendererAccountKind, identity: s if (kind === "bestdebrid-api") return { ...settings, bestToken: validateSecret(secret || ""), bestDebridUseWebLogin: false }; if (kind === "bestdebrid-web") return { ...settings, bestToken: "", bestDebridUseWebLogin: true }; if (kind === "alldebrid-api") return { ...settings, allDebridToken: validateSecret(secret || ""), allDebridUseWebLogin: false }; - if (kind === "alldebrid-web") return { ...settings, allDebridToken: "", allDebridUseWebLogin: true }; + if (kind === "alldebrid-web") return { ...settings, allDebridToken: secret ? validateSecret(secret) : settings.allDebridToken, allDebridUseWebLogin: true }; if (kind === "ddownload-login") return { ...settings, ddownloadLogin: validateIdentity(identity || ""), ddownloadPassword: validateSecret(secret || "") }; if (kind === "onefichier-api") return { ...settings, oneFichierApiKey: validateSecret(secret || "") }; if (kind === "linksnappy-login") return { ...settings, linkSnappyLogin: validateIdentity(identity || ""), linkSnappyPassword: validateSecret(secret || "") }; diff --git a/src/main/all-debrid-web.ts b/src/main/all-debrid-web.ts index 4f7004e..51117b7 100644 --- a/src/main/all-debrid-web.ts +++ b/src/main/all-debrid-web.ts @@ -1,520 +1,345 @@ -import { BrowserWindow, session } from "electron"; -import { AllDebridHostInfo } from "../shared/types"; -import { UnrestrictedLink } from "./realdebrid"; -import { filenameFromUrl, sleep } from "./utils"; +import { BrowserWindow, session } from "electron"; 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 { - 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 }); - }); + +const ALLDEBRID_PIN_GET_URL = "https://api.alldebrid.com/v4.1/pin/get"; +const ALLDEBRID_PIN_CHECK_URL = "https://api.alldebrid.com/v4/pin/check"; +const ALLDEBRID_PERSISTENT_PARTITION = "persist:alldebrid-web"; +const ALLDEBRID_TRANSIENT_PARTITION = "alldebrid-web"; +const ALLDEBRID_POLL_INTERVAL_MS = 5_000; + +type AllDebridApiPayload = { + status?: unknown; + data?: unknown; + error?: unknown; +}; + +type AllDebridPin = { + pin: string; + check: string; + expiresIn: number; + userUrl: string; +}; + +export type AllDebridPinLoginResult = { + apiKey: string; +}; + +type AllDebridPinLoginHandler = (result: AllDebridPinLoginResult) => void | Promise; + +type AllDebridPinLoginErrorHandler = (error: Error) => void; + +function abortError(): Error { + return new Error("aborted:alldebrid-pin-login"); } -async function raceWithAbort(promise: Promise, signal?: AbortSignal): Promise { - if (!signal) { - return promise; +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; } - return new Promise((resolve, reject) => { - let settled = false; + return value as Record; +} + +function stringValue(record: Record | null, key: string): string { + const value = record?.[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function positiveSeconds(record: Record | null, key: string): number { + const value = Number(record?.[key] ?? NaN); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortError(); + } +} + +async function sleepWithSignal(ms: number, signal: AbortSignal): Promise { + throwIfAborted(signal); + 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 (settled) { - return; + if (timer) { + clearTimeout(timer); + timer = null; } - 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 | 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 { - } - 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(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); - const result = raceWithAbort(run, signal); - this.queue = result.then(() => undefined, () => undefined); - return result; + +function apiError(payload: AllDebridApiPayload): Error { + const error = asRecord(payload.error); + const code = stringValue(error, "code"); + const message = stringValue(error, "message"); + const detail = [code, message].filter(Boolean).join(": "); + return new Error(detail ? `AllDebrid PIN-Login: ${detail}` : "AllDebrid PIN-Login fehlgeschlagen"); +} + +async function requestPayload(url: string, init: RequestInit, signal: AbortSignal): Promise { + throwIfAborted(signal); + const response = await fetch(url, { + ...init, + signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]) + }); + const text = await response.text(); + let payload: AllDebridApiPayload; + try { + payload = JSON.parse(text) as AllDebridApiPayload; + } catch { + throw new Error(`AllDebrid PIN-Login: ungültige API-Antwort (HTTP ${response.status})`); } - - 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", + if (!response.ok || payload.status !== "success") { + throw apiError(payload); + } + return payload; +} + +function parsePin(payload: AllDebridApiPayload): AllDebridPin { + const data = asRecord(payload.data); + const pin = stringValue(data, "pin"); + const check = stringValue(data, "check"); + const expiresIn = positiveSeconds(data, "expires_in"); + const userUrl = stringValue(data, "user_url"); + if (!pin || !check || !expiresIn || !userUrl) { + throw new Error("AllDebrid PIN-Login: unvollständige PIN-Antwort"); + } + const parsedUrl = new URL(userUrl); + if (parsedUrl.protocol !== "https:" || parsedUrl.hostname !== "alldebrid.com") { + throw new Error("AllDebrid PIN-Login: ungültige Benutzer-URL"); + } + return { pin, check, expiresIn, userUrl }; +} + +export class AllDebridWebFallback { + private loginWindow: BrowserWindow | null = null; + + private loginController: AbortController | null = null; + + private opening: Promise | null = null; + + private removeCallerAbortListener: (() => void) | null = null; + + private readonly getRememberSession: () => boolean; + + private readonly onAuthenticated: AllDebridPinLoginHandler; + + private readonly onLoginFailed: AllDebridPinLoginErrorHandler; + + public constructor( + getRememberSession: () => boolean, + onAuthenticated: AllDebridPinLoginHandler = () => {}, + onLoginFailed: AllDebridPinLoginErrorHandler = () => {} + ) { + this.getRememberSession = getRememberSession; + this.onAuthenticated = onAuthenticated; + this.onLoginFailed = onLoginFailed; + } + + public async openLoginWindow(signal?: AbortSignal): Promise { + const current = this.loginWindow; + if (this.loginController && !this.loginController.signal.aborted && current && !current.isDestroyed()) { + this.showWindow(current); + return; + } + if (this.opening) { + await this.opening; + const opened = this.loginWindow; + if (opened && !opened.isDestroyed()) { + this.showWindow(opened); + } + return; + } + + const opening = this.startPinLogin(signal); + this.opening = opening; + try { + await opening; + } finally { + if (this.opening === opening) { + this.opening = null; + } + } + } + + public async clearSessions(): Promise { + this.cancelLoginFlow(true); + 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.cancelLoginFlow(true); + } + + private getPartition(): string { + return this.getRememberSession() ? ALLDEBRID_PERSISTENT_PARTITION : ALLDEBRID_TRANSIENT_PARTITION; + } + + private showWindow(window: BrowserWindow): void { + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); + } + + private async startPinLogin(signal?: AbortSignal): Promise { + this.cancelLoginFlow(true); + throwIfAborted(signal); + const controller = new AbortController(); + this.loginController = controller; + if (signal) { + const onAbort = (): void => this.cancelLoginFlow(true); + signal.addEventListener("abort", onAbort, { once: true }); + this.removeCallerAbortListener = () => signal.removeEventListener("abort", onAbort); + } + + try { + const payload = await requestPayload(ALLDEBRID_PIN_GET_URL, { method: "GET" }, controller.signal); + const pin = parsePin(payload); + throwIfAborted(controller.signal); + const window = this.createLoginWindow(controller); + await window.loadURL(pin.userUrl); + throwIfAborted(controller.signal); + this.showWindow(window); + void this.pollForActivation(pin, controller.signal).then( + (result) => this.completeLogin(controller, result), + (error) => this.failLogin(controller, error) + ); + } catch (error) { + if (this.loginController === controller) { + this.cancelLoginFlow(true); + } + throw error; + } + } + + private createLoginWindow(controller: AbortController): BrowserWindow { + const partition = this.getPartition(); + const window = new BrowserWindow({ + width: 1120, + height: 900, + minWidth: 980, + minHeight: 760, + autoHideMenuBar: true, + title: "AllDebrid PIN-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 { - 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"); - } -} + window.setMenuBarVisibility(false); + window.on("closed", () => { + if (this.loginWindow !== window) { + return; + } + this.loginWindow = null; + if (this.loginController === controller) { + controller.abort(); + this.loginController = null; + this.clearCallerAbortListener(); + } + }); + this.loginWindow = window; + return window; + } + + private async pollForActivation(pin: AllDebridPin, signal: AbortSignal): Promise { + const deadline = Date.now() + pin.expiresIn * 1000; + while (Date.now() < deadline) { + throwIfAborted(signal); + const body = new URLSearchParams({ + check: pin.check, + pin: pin.pin + }); + const payload = await requestPayload(ALLDEBRID_PIN_CHECK_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + body: body.toString() + }, signal); + const data = asRecord(payload.data); + if (data?.activated === true) { + const apiKey = stringValue(data, "apikey"); + if (!apiKey) { + throw new Error("AllDebrid PIN-Login: aktivierte Antwort ohne API-Key"); + } + return { apiKey }; + } + const serverExpiresIn = positiveSeconds(data, "expires_in"); + if (!serverExpiresIn) { + break; + } + const remainingMs = Math.max(0, deadline - Date.now()); + if (!remainingMs) { + break; + } + await sleepWithSignal(Math.min(ALLDEBRID_POLL_INTERVAL_MS, remainingMs), signal); + } + throw new Error("AllDebrid PIN-Login Timeout"); + } + + private async completeLogin(controller: AbortController, result: AllDebridPinLoginResult): Promise { + if (this.loginController !== controller || controller.signal.aborted) { + return; + } + try { + await this.onAuthenticated(result); + } catch (error) { + this.failLogin(controller, error); + return; + } + if (this.loginController === controller) { + this.cancelLoginFlow(true); + } + } + + private failLogin(controller: AbortController, error: unknown): void { + if (this.loginController !== controller || controller.signal.aborted) { + return; + } + const normalized = error instanceof Error ? error : new Error(String(error)); + this.cancelLoginFlow(true); + this.onLoginFailed(normalized); + } + + private clearCallerAbortListener(): void { + this.removeCallerAbortListener?.(); + this.removeCallerAbortListener = null; + } + + private cancelLoginFlow(closeWindow: boolean): void { + const controller = this.loginController; + this.loginController = null; + controller?.abort(); + this.clearCallerAbortListener(); + const window = this.loginWindow; + this.loginWindow = null; + if (closeWindow && window && !window.isDestroyed()) { + window.close(); + } + } +} diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 068028a..e858813 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -36,7 +36,7 @@ import { importDlcContainers } from "./container"; import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants"; import { DownloadManager } from "./download-manager"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; -import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, checkRealDebridAccount, retainConfiguredRealDebridStatuses } from "./account-check"; +import { checkAllDebridAccount, checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, checkRealDebridAccount, retainConfiguredRealDebridStatuses } from "./account-check"; import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; @@ -192,11 +192,14 @@ export class AppController { login: this.settings.megaLogin, password: this.settings.megaPassword })); - this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken); + this.allDebridWebFallback = new AllDebridWebFallback( + () => this.settings.rememberToken, + ({ apiKey }) => this.completeAllDebridLogin(apiKey), + (error) => logger.warn(`AllDebrid PIN-Login fehlgeschlagen: ${String(error.message || error)}`) + ); this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken); this.manager = new DownloadManager(this.settings, session, this.storagePaths, { megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account), - allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), realDebridWebUnrestrict: (accountId: string, link: string, signal?: AbortSignal) => this.unrestrictRealDebridWebAccount(accountId, link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), @@ -575,6 +578,9 @@ export class AppController { } public async executeAccountCommand(command: AccountCommand): Promise { + if (command.action === "delete" && (command.kind === "alldebrid-api" || command.kind === "alldebrid-web")) { + this.allDebridWebFallback.dispose(); + } const applied = applyAccountCommand(this.settings, command); let checkedStatus: DebridAccountStatus | null = null; const redactions = collectAccountStatusRedactionValues(applied.settings, command); @@ -594,6 +600,9 @@ export class AppController { if (!account) throw new Error("Account-Payload ist ungültig"); checkedStatus = await checkRealDebridAccount(account); } + if (command.action !== "delete" && applied.response.accountId && command.kind === "alldebrid-api") { + checkedStatus = await checkAllDebridAccount(applied.settings.allDebridToken); + } if (checkedStatus) { checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions); } @@ -662,6 +671,17 @@ export class AppController { } return status; } + if (input.kind === "alldebrid-api" || input.kind === "alldebrid-web") { + const token = input.secret?.trim() || this.settings.allDebridToken.trim(); + if (!token || (input.accountId && input.accountId !== "svc-alldebrid")) { + throw new Error("Account-Payload ist ungültig"); + } + const status = sanitizeDebridAccountStatus(await checkAllDebridAccount(token), redactions); + if (!input.secret && this.settings.allDebridToken.trim()) { + this.manager.applyDebridAccountStatuses([status]); + } + return status; + } const key = input.secret?.trim() ? parseDebridLinkApiKeys(input.secret)[0] : parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).find((entry) => entry.id === input.accountId); @@ -883,10 +903,27 @@ export class AppController { } } - public async openAllDebridLoginWindow(): Promise { - this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); - await this.allDebridWebFallback.openLoginWindow(); - } + public async openAllDebridLoginWindow(): Promise { + this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); + await this.allDebridWebFallback.openLoginWindow(); + } + + private async completeAllDebridLogin(apiKey: string): Promise { + const next = this.updateSettings({ + allDebridToken: apiKey, + allDebridUseWebLogin: true, + disabledProviders: this.settings.disabledProviders.filter((provider) => provider !== "alldebrid") + }); + const status = sanitizeDebridAccountStatus( + await checkAllDebridAccount(next.allDebridToken), + collectAccountStatusRedactionValues(next) + ); + this.manager.applyDebridAccountStatuses([status]); + this.audit("INFO", "AllDebrid PIN-Login abgeschlossen", { + valid: status.valid, + premium: status.isPremium + }); + } public async importBestDebridCookies(filePath: string): Promise { const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath); @@ -897,11 +934,8 @@ export class AppController { return imported; } - public async getAllDebridHostInfo(host = "rapidgator"): Promise { - if (this.settings.allDebridUseWebLogin) { - return this.allDebridWebFallback.getHostInfo(host); - } - const token = this.settings.allDebridToken.trim(); + public async getAllDebridHostInfo(host = "rapidgator"): Promise { + const token = this.settings.allDebridToken.trim(); if (!token) { throw new Error("AllDebrid ist nicht konfiguriert"); } diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 456bbea..43df29f 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -608,16 +608,14 @@ interface ProviderUnrestrictedLink extends UnrestrictedLink { providerLabel: string; } -export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => Promise; -export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; +export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => Promise; export type RealDebridWebUnrestrictor = (accountId: string, link: string, signal?: AbortSignal) => Promise; -export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; - -interface DebridServiceOptions { - megaWebUnrestrict?: MegaWebUnrestrictor; - allDebridWebUnrestrict?: AllDebridWebUnrestrictor; - realDebridWebUnrestrict?: RealDebridWebUnrestrictor; - bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; +export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; + +interface DebridServiceOptions { + megaWebUnrestrict?: MegaWebUnrestrictor; + realDebridWebUnrestrict?: RealDebridWebUnrestrictor; + bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; } function cloneSettings(settings: AppSettings): AppSettings { @@ -1005,14 +1003,16 @@ function parseError(status: number, responseText: string, payload: Record | null): string { - const errorValue = payload?.error; - if (typeof errorValue === "string" && errorValue.trim()) { - return errorValue.trim(); - } - const errorObj = asRecord(errorValue); - return pickString(errorObj, ["message", "code"]) || "AllDebrid API error"; -} +function parseAllDebridError(payload: Record | null): string { + const errorValue = payload?.error; + if (typeof errorValue === "string" && errorValue.trim()) { + return errorValue.trim(); + } + const errorObj = asRecord(errorValue); + const code = pickString(errorObj, ["code"]); + const message = pickString(errorObj, ["message"]); + return code && message ? `${code}: ${message}` : code || message || "AllDebrid API error"; +} function normalizeAllDebridHostKey(value: string): string { return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase(); @@ -4431,11 +4431,7 @@ export class DebridService { && !getRealDebridAccountCooldown(account.id, now)); } - private shouldUseAllDebridWeb(settings: AppSettings): boolean { - return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict); - } - - private shouldUseBestDebridWeb(settings: AppSettings): boolean { + private shouldUseBestDebridWeb(settings: AppSettings): boolean { return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict); } @@ -4663,8 +4659,8 @@ export class DebridService { if (effectiveProvider === "megadebrid-web") { return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "web") && this.options.megaWebUnrestrict); } - if (effectiveProvider === "alldebrid") { - return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim()); + if (effectiveProvider === "alldebrid") { + return Boolean(settings.allDebridToken.trim()); } if (effectiveProvider === "ddownload") { return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim()); @@ -4815,16 +4811,8 @@ export class DebridService { if (effectiveProvider === "megadebrid-web") { return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal); } - if (effectiveProvider === "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"); - } - result.sourceLabel = "Web"; - return result; - } - const adResult = await new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal); + if (effectiveProvider === "alldebrid") { + const adResult = await new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal); adResult.sourceLabel = "API"; return adResult; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index e6e869e..4f25fe7 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -65,7 +65,7 @@ function releaseTlsSkip(): void { } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifactsFromScope, removeSampleArtifactsFromScope } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, isProviderDisabledForSelection, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; +import { BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, isProviderDisabledForSelection, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; import { classifyExtractionError, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; @@ -218,8 +218,6 @@ const MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES = 5 * 1024; const ALLDEBRID_HOST_INFO_TTL_MS = 60000; -const ALLDEBRID_START_STAGGER_MS = 3000; - const ARCHIVE_SETTLE_MIN_DELAY_MS = 1500; const ARCHIVE_SETTLE_POLL_MS = 250; @@ -545,9 +543,8 @@ function retryLimitToMaxRetries(retryLimit: number): number { type HistoryEntryCallback = (entry: HistoryEntry) => void; type DownloadManagerOptions = { - megaWebUnrestrict?: MegaWebUnrestrictor; - allDebridWebUnrestrict?: AllDebridWebUnrestrictor; - realDebridWebUnrestrict?: RealDebridWebUnrestrictor; + megaWebUnrestrict?: MegaWebUnrestrictor; + realDebridWebUnrestrict?: RealDebridWebUnrestrictor; bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; invalidateMegaSession?: () => void; onHistoryEntry?: HistoryEntryCallback; @@ -814,6 +811,35 @@ function isPermanentLinkError(errorText: string): boolean { || text.includes("file was deleted"); } +function classifyAllDebridTerminalUnrestrictError( + errorText: string, + providerKey: string +): { kind: "link_unavailable" | "auth"; detail: string } | null { + if (!String(providerKey || "").toLowerCase().startsWith("alldebrid")) { + return null; + } + const detail = String(errorText || "").trim(); + const text = detail.toLowerCase(); + const normalized = text.replace(/[\s-]+/g, "_"); + const linkUnavailable = normalized.includes("link_down") + || /\b(?:BAD_LINK|LINK_HOST_NOT_SUPPORTED|LINK_NOT_SUPPORTED)\b/i.test(detail) + || /(?:this|the)?\s*link\s+is\s+not\s+available\s+on\s+the\s+file\s+hoster\s+website/i.test(detail) + || /(?:file|link)\s+(?:is\s+)?(?:dead|offline|deleted|removed|no\s+longer\s+available)/i.test(detail) + || /(?:file|link)[_\s-]*(?:is[_\s-]*)?(?:not[_\s-]*available|unavailable)/i.test(detail); + if (linkUnavailable) { + return { kind: "link_unavailable", detail }; + } + const authFailure = /\b(?:AUTH_(?:MISSING_APIKEY|BAD_APIKEY|BLOCKED|USER_BANNED|IP_MISMATCH)|NO_SERVER)\b/i.test(detail) + || /\b(?:api[ _-]?key|access[ _-]?token|token)\b.{0,40}\b(?:invalid|expired|missing|revoked|blocked)\b/i.test(detail) + || /\b(?:invalid|expired|missing|revoked|blocked)\b.{0,40}\b(?:api[ _-]?key|access[ _-]?token|token)\b/i.test(detail) + || /\b(?:authentication|authorization)\s+(?:failed|required|missing)\b/i.test(detail) + || /\bnot authenticated\b|\bunauthorized\b|\baccount\s+(?:banned|blocked)\b/i.test(detail); + if (authFailure) { + return { kind: "auth", detail }; + } + return null; +} + function isUnrestrictFailure(errorText: string): boolean { const text = String(errorText || "").toLowerCase(); return text.includes("unrestrict") || text.includes("debrid-link") || text.includes("debrid_link_") @@ -2381,9 +2407,6 @@ export class DownloadManager extends EventEmitter { private allDebridHostInfoCache = new Map(); - private providerStartReservations = new Map(); - private pacedStartReservationByItem = new Map(); - private lastStaleResetAt = 0; private onHistoryEntryCallback?: HistoryEntryCallback; @@ -2413,7 +2436,6 @@ export class DownloadManager extends EventEmitter { } this.debridService = new DebridService(settings, { megaWebUnrestrict: options.megaWebUnrestrict, - allDebridWebUnrestrict: options.allDebridWebUnrestrict, realDebridWebUnrestrict: options.realDebridWebUnrestrict, bestDebridWebUnrestrict: options.bestDebridWebUnrestrict }); @@ -3670,10 +3692,8 @@ export class DownloadManager extends EventEmitter { clearTimeout(this.successDigestTimer); this.successDigestTimer = null; } - this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); - this.retryStateByItem.clear(); + this.retryAfterByItem.clear(); + this.retryStateByItem.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); this.itemContributedBytes.clear(); @@ -7244,8 +7264,6 @@ export class DownloadManager extends EventEmitter { this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.itemContributedBytes.clear(); this.reservedTargetPaths.clear(); @@ -7361,8 +7379,6 @@ export class DownloadManager extends EventEmitter { this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.itemContributedBytes.clear(); this.reservedTargetPaths.clear(); @@ -7489,8 +7505,6 @@ export class DownloadManager extends EventEmitter { this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); @@ -7522,8 +7536,6 @@ export class DownloadManager extends EventEmitter { this.runOutcomes.clear(); this.runCompletedPackages.clear(); this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); this.http416FreshRestartByItem.clear(); this.itemContributedBytes.clear(); @@ -7606,24 +7618,12 @@ export class DownloadManager extends EventEmitter { this.session.reconnectUntil = 0; this.session.reconnectReason = ""; if (hasScopedRun) { - const paceKeys = new Set(); for (const itemId of stoppedItemIds) { - const item = this.session.items[itemId]; - const paceKey = item ? this.getPacedStartKeyForItem(item) : ""; - if (paceKey) paceKeys.add(paceKey); this.retryAfterByItem.delete(itemId); - this.pacedStartReservationByItem.delete(itemId); this.retryStateByItem.delete(itemId); } - for (const paceKey of paceKeys) { - if (this.countFuturePacedStarts(paceKey, nowMs()) <= 0) { - this.providerStartReservations.delete(paceKey); - } - } } else { this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); this.retryStateByItem.clear(); } this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; @@ -7762,11 +7762,9 @@ export class DownloadManager extends EventEmitter { this.runPackageIds.clear(); this.runScopeKind = null; this.runOutcomes.clear(); - this.runCompletedPackages.clear(); - this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); - this.nonResumableActive = 0; + this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); + this.nonResumableActive = 0; this.session.summaryText = ""; this.emitState(true); logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`); @@ -10178,7 +10176,7 @@ export class DownloadManager extends EventEmitter { return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim()); } if (effectiveProvider === "alldebrid") { - return Boolean(this.settings.allDebridUseWebLogin || this.settings.allDebridToken.trim()); + return Boolean(this.settings.allDebridToken.trim()); } if (effectiveProvider === "ddownload") { return Boolean(this.settings.ddownloadLogin.trim() && this.settings.ddownloadPassword.trim()); @@ -10313,38 +10311,6 @@ export class DownloadManager extends EventEmitter { return count; } - private getPacedStartKeyForItem(item: DownloadItem): string | null { - const provider = this.getExpectedProviderForItem(item); - if (provider !== "alldebrid") { - return null; - } - return provider; - } - - private countFuturePacedStarts(paceKey: string, now: number, excludeItemId?: string): number { - let count = 0; - for (const [itemId, reservedAt] of this.pacedStartReservationByItem.entries()) { - if (excludeItemId && itemId === excludeItemId) { - continue; - } - if (reservedAt <= now) { - continue; - } - const item = this.session.items[itemId]; - if (!item) { - continue; - } - if (this.getPacedStartKeyForItem(item) !== paceKey) { - continue; - } - if (item.status !== "queued" && item.status !== "reconnect_wait") { - continue; - } - count += 1; - } - return count; - } - private getProviderValidatingTaskCount(provider: DebridProvider, excludeItemId?: string): number { let count = 0; for (const active of this.activeTasks.values()) { @@ -10391,76 +10357,6 @@ export class DownloadManager extends EventEmitter { return Number.MAX_SAFE_INTEGER; } - private delayPacedStartForItem(item: DownloadItem, now: number): boolean { - const paceKey = this.getPacedStartKeyForItem(item); - if (!paceKey) { - return false; - } - - const existingReadyAt = this.retryAfterByItem.get(item.id) || 0; - const existingPacedAt = this.pacedStartReservationByItem.get(item.id) || 0; - if (existingPacedAt > 0 && existingPacedAt <= now) { - this.pacedStartReservationByItem.delete(item.id); - return false; - } - if (existingPacedAt > now) { - const scheduledAt = Math.max(existingReadyAt, existingPacedAt); - this.retryAfterByItem.set(item.id, scheduledAt); - item.status = "queued"; - item.speedBps = 0; - item.fullStatus = `AllDebrid Start in ${Math.max(1, Math.ceil((scheduledAt - now) / 1000))}s`; - item.updatedAt = now; - return true; - } - - const failureKey = this.getProviderFailureKeyForItem(item, "alldebrid"); - const startLimit = this.getAllDebridStartLimit(extractHosterKey(item.url)); - const activeProviderTasks = this.activeTasks.size; - const activeHosterTasks = this.getActiveTaskCountForFailureKey(failureKey); - const futureReservations = this.countFuturePacedStarts(paceKey, now, item.id); - const remainingGlobalSlots = Math.max(0, Math.max(1, Number(this.settings.maxParallel) || 1) - activeProviderTasks - futureReservations); - const remainingHosterSlots = Number.isFinite(startLimit) - ? Math.max(0, startLimit - activeHosterTasks - futureReservations) - : Number.MAX_SAFE_INTEGER; - const availableReservationSlots = Math.min(remainingGlobalSlots, remainingHosterSlots); - if (availableReservationSlots <= 0) { - this.pacedStartReservationByItem.delete(item.id); - if ((item.fullStatus || "").startsWith("AllDebrid Start in ")) { - item.fullStatus = "Wartet"; - item.updatedAt = now; - } - return true; - } - - const scheduledAt = Math.max(existingReadyAt, now + ALLDEBRID_START_STAGGER_MS); - if (scheduledAt <= now) { - this.pacedStartReservationByItem.delete(item.id); - return false; - } - this.retryAfterByItem.set(item.id, scheduledAt); - this.pacedStartReservationByItem.set(item.id, scheduledAt); - item.status = "queued"; - item.speedBps = 0; - item.fullStatus = `AllDebrid Start in ${Math.max(1, Math.ceil((scheduledAt - now) / 1000))}s`; - item.updatedAt = now; - return true; - } - - private notePacedStartForItem(item: DownloadItem, now: number): void { - const paceKey = this.getPacedStartKeyForItem(item); - if (!paceKey) { - return; - } - - const reservedAt = this.pacedStartReservationByItem.get(item.id) || 0; - if (reservedAt > 0) { - this.pacedStartReservationByItem.delete(item.id); - } - if (this.countFuturePacedStarts(paceKey, now) <= 0) { - this.providerStartReservations.delete(paceKey); - } - } - private getConfiguredAllDebridStartLimit(): number { const configured = Math.floor(Number(this.settings.maxParallel || 1)); if (Number.isFinite(configured) && configured > 0) { @@ -10508,7 +10404,7 @@ export class DownloadManager extends EventEmitter { private async getAllDebridHostInfoCached(hosterKey: string, signal?: AbortSignal, forceRefresh = false): Promise { const normalizedHost = String(hosterKey || "").trim().toLowerCase(); - if (!normalizedHost || this.settings.allDebridUseWebLogin) { + if (!normalizedHost) { return null; } const token = this.settings.allDebridToken.trim(); @@ -10872,7 +10768,6 @@ export class DownloadManager extends EventEmitter { if (retryAfter > now) continue; if (item.status !== "queued" && item.status !== "reconnect_wait") continue; if (this.activeTasks.has(itemId)) continue; - if (this.delayPacedStartForItem(item, now)) continue; if (this.shouldDelayStartForItem(item)) continue; const candidate = { packageId, itemId }; @@ -11123,9 +11018,8 @@ export class DownloadManager extends EventEmitter { phaseDeadlineAt: item.updatedAt + getUnrestrictTimeoutMs() + 15_000, generation: this.lifecycleGeneration }; - this.activeTasks.set(itemId, active); - this.notePacedStartForItem(item, nowMs()); - this.emitState(); + this.activeTasks.set(itemId, active); + this.emitState(); void this.processItem(active).catch((err) => { logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`); @@ -11282,11 +11176,12 @@ export class DownloadManager extends EventEmitter { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { this.recordProviderFailure(cooldownProvider); throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`); - } - const errText = compactErrorText(unrestrictError); - if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { - this.recordProviderFailure(cooldownProvider); - if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) { + } + const errText = compactErrorText(unrestrictError); + const terminalAllDebridError = classifyAllDebridTerminalUnrestrictError(errText, cooldownProvider); + if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText) && !terminalAllDebridError) { + this.recordProviderFailure(cooldownProvider); + if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) { const busyCooldownMs = isTemporaryUnrestrictError(errText) ? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000) : Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); @@ -11814,6 +11709,27 @@ export class DownloadManager extends EventEmitter { return; } + const allDebridTerminalError = classifyAllDebridTerminalUnrestrictError( + errorText, + this.getProviderFailureKeyForItem(item) + ); + if (allDebridTerminalError) { + item.status = "failed"; + this.recordRunOutcome(item.id, "failed"); + item.lastError = allDebridTerminalError.detail; + item.fullStatus = allDebridTerminalError.kind === "auth" + ? `AllDebrid-Anmeldung fehlgeschlagen: ${allDebridTerminalError.detail}` + : `Link ungültig: ${allDebridTerminalError.detail}`; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.retryStateByItem.delete(item.id); + const failPkgAllDebrid = this.session.packages[item.packageId]; + if (failPkgAllDebrid) this.refreshPackageStatus(failPkgAllDebrid); + this.persistSoon(); + this.emitState(); + return; + } + if (isPermanentLinkError(errorText)) { logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`); item.status = "failed"; @@ -15501,7 +15417,6 @@ export class DownloadManager extends EventEmitter { delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); this.retryAfterByItem.delete(itemId); - this.pacedStartReservationByItem.delete(itemId); this.retryStateByItem.delete(itemId); if (pkg.itemIds.length === 0) { this.removePackageFromSession(packageId, []); @@ -15566,10 +15481,8 @@ export class DownloadManager extends EventEmitter { this.runPackageIds.clear(); this.runScopeKind = null; this.runOutcomes.clear(); - this.retryAfterByItem.clear(); - this.providerStartReservations.clear(); - this.pacedStartReservationByItem.clear(); - this.retryStateByItem.clear(); + this.retryAfterByItem.clear(); + this.retryStateByItem.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); this.itemContributedBytes.clear(); diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 3983de1..1f5419e 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -33,7 +33,8 @@ function singleAccount( provider: DebridProvider, identity: string, maskedIdentity: string, - hasSecret: boolean + hasSecret: boolean, + status: DebridAccountStatus | null = null ): RendererAccount { return { accountId: `svc-${provider}`, @@ -46,7 +47,7 @@ function singleAccount( dailyLimitBytes: settings.providerDailyLimitBytes[provider] || 0, dailyUsageBytes: settings.providerDailyUsageBytes[provider] || 0, totalUsageBytes: settings.providerTotalUsageBytes[provider] || 0, - status: null + status }; } @@ -112,13 +113,15 @@ export function createRendererAccounts(settings: AppSettings): RendererAccount[] )); } if (settings.allDebridUseWebLogin || settings.allDebridToken.trim()) { + const status = safeStatus(settings.debridAccountStatuses["svc-alldebrid"], redactions); accounts.push(singleAccount( settings, settings.allDebridUseWebLogin ? "alldebrid-web" : "alldebrid-api", "alldebrid", - "", + status?.username || "", settings.allDebridUseWebLogin ? "Browser-Login" : maskValue(settings.allDebridToken), - true + true, + status )); } if (settings.ddownloadLogin.trim() && settings.ddownloadPassword) { diff --git a/src/main/storage.ts b/src/main/storage.ts index f50084e..eab872d 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -291,9 +291,13 @@ function normalizeDebridAccountStatuses( megaIds: string[], debridLinkIds: string[], realDebridIds: string[], - legacyRealDebridTargetId: string | null + legacyRealDebridTargetId: string | null, + allDebridConfigured: boolean ): Record { const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds]); + if (allDebridConfigured) { + allowed.add("svc-alldebrid"); + } const result: Record = {}; if (value && typeof value === "object" && !Array.isArray(value)) { for (const [storedKey, raw] of Object.entries(value as Record)) { @@ -304,15 +308,20 @@ function normalizeDebridAccountStatuses( if (storedKey === "svc-realdebrid" && result[key]) { continue; } - const entry = raw as Partial; + const entry = raw as Partial; if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") { continue; } + if (key === "svc-alldebrid" && entry.provider !== "alldebrid") { + continue; + } const provider = entry.provider === "debridlink" ? "debridlink" : entry.provider === "realdebrid" ? "realdebrid" - : "megadebrid"; + : entry.provider === "alldebrid" + ? "alldebrid" + : "megadebrid"; let username = typeof entry.username === "string" ? entry.username : undefined; let email = typeof entry.email === "string" ? entry.email : undefined; if (provider === "debridlink" && !username && email && !email.includes("@")) { @@ -542,8 +551,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { megaDebridPreferApi, bestToken: asText(settings.bestToken), bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin), - allDebridToken: asText(settings.allDebridToken), - allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin), + allDebridToken: asText(settings.allDebridToken), + allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin && asText(settings.allDebridToken)), ddownloadLogin: asText(settings.ddownloadLogin), ddownloadPassword: asText(settings.ddownloadPassword), oneFichierApiKey: asText(settings.oneFichierApiKey), @@ -657,7 +666,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { megaDebridAccountIds, debridLinkApiKeyIds, realDebridAccountIds, - legacyRealDebridTargetId + legacyRealDebridTargetId, + Boolean(asText(settings.allDebridToken)) ), providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, dailyStartEnabled: settings.dailyStartEnabled !== undefined ? Boolean(settings.dailyStartEnabled) : defaults.dailyStartEnabled, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5a30afc..8733127 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -465,7 +465,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [ serviceLabel: "AllDebrid", title: "AllDebrid Web-Login", modeLabel: "Web-Login", - pickerDescription: "Login über Browserfenster für reCAPTCHA.", + pickerDescription: "Sichere Browser-Autorisierung über den offiziellen AllDebrid-PIN-Login.", }, { kind: "ddownload-login", @@ -565,7 +565,7 @@ function getAccountCredentialLabel(kind: AccountKind): string { case "bestdebrid-web": case "realdebrid-web": case "alldebrid-web": - return "Login gespeichert"; + return "Geschützter API-Zugang"; case "realdebrid-api": case "bestdebrid-api": case "alldebrid-api": @@ -2576,7 +2576,7 @@ export function App(): ReactElement { }); } } else { - const serviceAccountId = null; + const serviceAccountId = entry.service === "alldebrid" ? "svc-alldebrid" : null; rows.push({ rowKey: `svc-${entry.service}`, entry, @@ -2990,6 +2990,12 @@ export function App(): ReactElement { showToast("Real-Debrid Login-Fenster geöffnet", 2200); return; } + if (dialogSnapshot.kind === "alldebrid-web") { + await window.rd.openAllDebridLogin(); + closeAccountDialog(); + showToast("AllDebrid PIN-Login geöffnet", 2200); + return; + } const command = buildAccountCreateCommand(dialogSnapshot); if (!command) throw new Error("Account-Payload ist ungültig"); const result = await window.rd.createAccount(command); @@ -3153,7 +3159,15 @@ export function App(): ReactElement { ...settingsDraft, disabledProviders: nextDisabledProviders }; - await persistAccountToggle(nextDraft); + await persistAccountToggle( + nextDraft, + !nextDisabledProviders.includes(provider) && entry.service === "alldebrid" + ? () => window.rd.checkAccountCredentials({ + kind: entry.kind as "alldebrid-api" | "alldebrid-web", + accountId: "svc-alldebrid" + }) + : undefined + ); showToast( nextDisabledProviders.includes(provider) ? `${entry.serviceLabel} deaktiviert` @@ -3246,7 +3260,8 @@ export function App(): ReactElement { const checkAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); - if ((row.entry.kind === "realdebrid-api" || row.entry.kind === "realdebrid-web") && row.accountId) { + if ((row.entry.kind === "realdebrid-api" || row.entry.kind === "realdebrid-web" + || row.entry.kind === "alldebrid-api" || row.entry.kind === "alldebrid-web") && row.accountId) { const kind = row.entry.kind; const accountId = row.accountId; void performQuickAction(async () => { @@ -5725,9 +5740,10 @@ export function App(): ReactElement { const editSnapshot = accountEditDialog; void performQuickAction(async () => { if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink" - || editSnapshot.target.kind === "realdebrid-api" || editSnapshot.target.kind === "realdebrid-web") { + || editSnapshot.target.kind === "realdebrid-api" || editSnapshot.target.kind === "realdebrid-web" + || editSnapshot.target.kind === "alldebrid-api" || editSnapshot.target.kind === "alldebrid-web") { const secret = editSnapshot.target.type === "mega" ? editSnapshot.password : editSnapshot.token; - const kind = editSnapshot.target.kind as "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api"; + const kind = editSnapshot.target.kind as "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api" | "alldebrid-api" | "alldebrid-web"; const status = await window.rd.checkAccountCredentials({ kind, accountId: editSnapshot.target.type === "mega" diff --git a/src/shared/types.ts b/src/shared/types.ts index fff9288..0a8783e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -431,7 +431,7 @@ export interface AccountCommandResult { } export interface AccountCredentialCheckInput { - kind: "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "debridlink-api"; + kind: "realdebrid-api" | "realdebrid-web" | "megadebrid-api" | "megadebrid-web" | "alldebrid-api" | "alldebrid-web" | "debridlink-api"; accountId?: string; identity?: string; secret?: string; diff --git a/tests/account-check.test.ts b/tests/account-check.test.ts index b492e36..165efdb 100644 --- a/tests/account-check.test.ts +++ b/tests/account-check.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID, retainConfiguredRealDebridStatuses } from "../src/main/account-check"; +import { ALL_DEBRID_STATUS_ID, checkAllDebridAccount, checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID, retainConfiguredRealDebridStatuses } from "../src/main/account-check"; import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts"; import { getDebridLinkApiKeyId, type DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys"; import type { AppSettings } from "../src/shared/types"; @@ -179,8 +179,78 @@ describe("checkRealDebridAccount", () => { }); }); }); + +describe("checkAllDebridAccount", () => { + it("reads identity and premium expiry from the official user endpoint", async () => { + const premiumUntilSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60; + mockFetchOnce(200, { + status: "success", + data: { + user: { + username: "all-user", + email: "all-user@example.test", + isPremium: true, + premiumUntil: premiumUntilSec + } + } + }); + + const status = await checkAllDebridAccount("all-api-key", undefined, NOW); + + expect(status).toMatchObject({ + accountId: ALL_DEBRID_STATUS_ID, + provider: "alldebrid", + valid: true, + isPremium: true, + premiumUntilMs: premiumUntilSec * 1000, + username: "all-user", + email: "all-user@example.test" + }); + expect(fetch).toHaveBeenCalledWith("https://api.alldebrid.com/v4/user", expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer all-api-key" }) + })); + }); + + it("reports authentication errors without accepting the API key", async () => { + mockFetchOnce(401, { + status: "error", + error: { code: "AUTH_BAD_APIKEY", message: "The auth apikey is invalid" } + }); + + const status = await checkAllDebridAccount("bad-key", undefined, NOW); + + expect(status).toMatchObject({ + accountId: ALL_DEBRID_STATUS_ID, + provider: "alldebrid", + valid: false, + isPremium: false, + message: "Ungültiger API-Key" + }); + }); +}); describe("checkAllDebridAccounts", () => { + it("checks configured AllDebrid in all scope and only when enabled in active scope", async () => { + const settings = { + ...defaultSettings(), + allDebridToken: "all-api-key", + disabledProviders: ["alldebrid" as const] + }; + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + status: "success", + data: { user: { username: "all-user", email: "all@example.test", isPremium: true, premiumUntil: 4_102_444_800 } } + }) + })) as unknown as typeof fetch); + + const active = await checkAllDebridAccounts(settings, undefined, undefined, "active"); + const all = await checkAllDebridAccounts(settings, undefined, undefined, "all"); + + expect(active).toEqual([]); + expect(all).toEqual([expect.objectContaining({ accountId: ALL_DEBRID_STATUS_ID, provider: "alldebrid", valid: true })]); + }); it("discards a late Real-Debrid result after its account was removed", () => { const removedId = "rda_removedAfterCheck"; const lateStatus = { accountId: removedId, provider: "realdebrid" as const, label: "API-Token 1", maskedLogin: "Geschützt", valid: true, isPremium: true, premiumUntilMs: null, message: "Premium aktiv", checkedAt: NOW }; diff --git a/tests/account-commands.test.ts b/tests/account-commands.test.ts index 7ddbb7c..d4dc6f6 100644 --- a/tests/account-commands.test.ts +++ b/tests/account-commands.test.ts @@ -141,10 +141,15 @@ describe("write-only account commands", () => { expect(() => api.validateAccountSecretRequest?.({ kind: "realdebrid-api", accountId: "svc-realdebrid", secret: "not-allowed" })).toThrow(/ungültig/i); }); - it.each(["realdebrid-api", "realdebrid-web"] as const)("accepts %s credential checks at the IPC boundary", (kind) => { - expect(validateAccountCredentialCheckInput({ kind, accountId: "svc-realdebrid" })).toEqual({ + it.each([ + ["realdebrid-api", "svc-realdebrid"], + ["realdebrid-web", "svc-realdebrid"], + ["alldebrid-api", "svc-alldebrid"], + ["alldebrid-web", "svc-alldebrid"] + ] as const)("accepts %s credential checks at the IPC boundary", (kind, accountId) => { + expect(validateAccountCredentialCheckInput({ kind, accountId })).toEqual({ kind, - accountId: "svc-realdebrid", + accountId, identity: undefined, secret: undefined }); @@ -265,6 +270,26 @@ describe("write-only account commands", () => { expect(JSON.stringify(replaced.response)).not.toContain(secret); }); + it("keeps the PIN-issued AllDebrid API key when editing the browser-authorized account", () => { + const apiKey = "fixture-all-pin-api-key"; + const settings = { + ...defaultSettings(), + allDebridUseWebLogin: true, + allDebridToken: apiKey + }; + + const replaced = applyAccountCommand(settings, validateAccountCommand({ + action: "replace", + kind: "alldebrid-web", + accountId: "svc-alldebrid", + secret: "", + dailyLimitBytes: 2 * GIB + })); + + expect(replaced.settings.allDebridUseWebLogin).toBe(true); + expect(replaced.settings.allDebridToken).toBe(apiKey); + }); + it("replaces a Mega-Debrid account while preserving sibling accounts and mode-specific state", () => { const firstId = getMegaDebridAccountId("first@example.test"); const oldId = getMegaDebridAccountId("second@example.test"); diff --git a/tests/alldebrid-web.test.ts b/tests/alldebrid-web.test.ts index d097de9..2e982d5 100644 --- a/tests/alldebrid-web.test.ts +++ b/tests/alldebrid-web.test.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { mockFromPartition, mockSession, - mockFetch, mockBrowserWindowCtor, mockLoadURL, mockShow, @@ -12,7 +11,6 @@ const { mockSetWindowOpenHandler, mockSetPermissionRequestHandler } = vi.hoisted(() => { - const fetch = vi.fn(); const clearStorageData = vi.fn(); const clearCache = vi.fn(); const fromPartition = vi.fn(); @@ -31,6 +29,9 @@ const { show, focus, close: vi.fn(() => { + if (destroyed) { + return; + } destroyed = true; windowEvents.closed?.(); }), @@ -57,11 +58,9 @@ const { return { mockFromPartition: fromPartition, mockSession: { - fetch, clearStorageData, clearCache }, - mockFetch: fetch, mockBrowserWindowCtor: BrowserWindowCtor, mockLoadURL: loadURL, mockShow: show, @@ -84,21 +83,59 @@ vi.mock("electron", () => ({ import { AllDebridWebFallback } from "../src/main/all-debrid-web"; -describe("alldebrid-web", () => { +function pinResponse(expiresIn = 600): Response { + return new Response(JSON.stringify({ + status: "success", + data: { + pin: "ABCD", + check: "check-token", + expires_in: expiresIn, + user_url: "https://alldebrid.com/pin/?pin=ABCD", + base_url: "https://alldebrid.com/pin/" + } + }), { status: 200 }); +} + +function checkResponse(activated: boolean, expiresIn: number, apiKey?: string): Response { + return new Response(JSON.stringify({ + status: "success", + data: { + activated, + expires_in: expiresIn, + ...(apiKey ? { apikey: apiKey } : {}) + } + }), { status: 200 }); +} + +describe("alldebrid PIN auth", () => { beforeEach(() => { mockFromPartition.mockReturnValue(mockSession); + vi.stubGlobal("fetch", vi.fn()); }); afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); vi.clearAllMocks(); mockFromPartition.mockReturnValue(mockSession); }); - it("opens the AllDebrid login window with the shared restrictive browser boundary", async () => { - const fallback = new AllDebridWebFallback(() => true); + it("opens the official PIN URL and reports the API key after activation", async () => { + vi.useFakeTimers(); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(pinResponse()) + .mockResolvedValueOnce(checkResponse(false, 595)) + .mockResolvedValueOnce(checkResponse(true, 590, "all-debrid-api-key")); + const authenticated = vi.fn(); + const fallback = new AllDebridWebFallback(() => true, authenticated); await fallback.openLoginWindow(); + expect(fetchMock.mock.calls[0]).toEqual([ + "https://api.alldebrid.com/v4.1/pin/get", + expect.objectContaining({ method: "GET" }) + ]); expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1); expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ webPreferences: { @@ -112,128 +149,111 @@ describe("alldebrid-web", () => { })); expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1); expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1); - expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/register/?from=de"); - expect(mockShow).toHaveBeenCalled(); - expect(mockFocus).toHaveBeenCalled(); - }); + expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/pin/?pin=ABCD"); + expect(mockShow).toHaveBeenCalledTimes(1); + expect(mockFocus).toHaveBeenCalledTimes(1); + expect(authenticated).not.toHaveBeenCalled(); - it("uses an existing AllDebrid Web session to unrestrict without opening a login window", async () => { - mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ - link: "https://alldebrid.direct/session-file.bin", - filename: "session-file.bin", - filesize: 9876 - }), { status: 200 })); - const fallback = new AllDebridWebFallback(() => true); + await vi.advanceTimersByTimeAsync(5_000); + await vi.waitFor(() => expect(authenticated).toHaveBeenCalledWith({ apiKey: "all-debrid-api-key" })); - const result = await fallback.unrestrict("https://rapidgator.net/file/session"); - - expect(result).toEqual({ - directUrl: "https://alldebrid.direct/session-file.bin", - fileName: "session-file.bin", - fileSize: 9876, - retriesUsed: 0 - }); - expect(mockBrowserWindowCtor).not.toHaveBeenCalled(); - expect(mockFetch).toHaveBeenCalledTimes(1); - expect(mockFetch.mock.calls[0]?.[0]).toBe("https://alldebrid.com/service.php"); - expect(mockFetch.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ - method: "POST", - body: "link=https%3A%2F%2Frapidgator.net%2Ffile%2Fsession&nb=0&json=true&pw=" - })); - }); - - it("releases an aborted caller while the active web request ignores its signal", async () => { - let rejectRequest!: (error: Error) => void; - mockFetch - .mockReturnValueOnce(new Promise((_resolve, reject) => { - rejectRequest = reject; - })) - .mockResolvedValueOnce(new Response(JSON.stringify({ - link: "https://alldebrid.direct/second.bin", - filename: "second.bin", - filesize: 333 - }), { status: 200 })); - const fallback = new AllDebridWebFallback(() => true); - const controller = new AbortController(); - const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal) - .then(() => "resolved" as const, (error) => String(error)); - - await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); - controller.abort("test-stop"); - const outcome = await Promise.race([ - running, - new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200)) + expect(fetchMock.mock.calls[1]).toEqual([ + "https://api.alldebrid.com/v4/pin/check", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" }, + body: "check=check-token&pin=ABCD" + }) ]); - - expect(outcome).toContain("aborted:alldebrid-web"); - const secondOutcome = await Promise.race([ - fallback.unrestrict("https://rapidgator.net/file/second"), - new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200)) - ]); - expect(secondOutcome).toEqual({ - directUrl: "https://alldebrid.direct/second.bin", - fileName: "second.bin", - fileSize: 333, - retriesUsed: 0 - }); - expect(mockFetch).toHaveBeenCalledTimes(2); - rejectRequest(new Error("late alldebrid rejection")); - await Promise.resolve(); - }); - - it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => { - mockFetch.mockResolvedValue(new Response(JSON.stringify({ - link: "https://alldebrid.direct/next.bin", - filename: "next.bin", - filesize: 666 - }), { status: 200 })); - const fallback = new AllDebridWebFallback(() => true); - const controller = new AbortController(); - controller.abort("before-queue"); - const unhandled: unknown[] = []; - const onUnhandled = (reason: unknown) => unhandled.push(reason); - process.on("unhandledRejection", onUnhandled); - try { - await expect(fallback.unrestrict("https://rapidgator.net/file/pre-aborted", controller.signal)) - .rejects.toThrow("aborted:alldebrid-web"); - await new Promise((resolve) => setImmediate(resolve)); - await expect(fallback.unrestrict("https://rapidgator.net/file/next")).resolves.toMatchObject({ - directUrl: "https://alldebrid.direct/next.bin", - fileName: "next.bin" - }); - expect(unhandled).toEqual([]); - expect(mockFetch).toHaveBeenCalledTimes(1); - } finally { - process.off("unhandledRejection", onUnhandled); - } - }); - - it("opens the login window after login_required and retries generation with the same session partition", async () => { - mockFetch - .mockResolvedValueOnce(new Response("login", { status: 200 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ - link: "https://alldebrid.direct/retry-file.bin", - filename: "retry-file.bin", - filesize: 12345 - }), { status: 200 })); - const fallback = new AllDebridWebFallback(() => true); - - const result = await fallback.unrestrict("https://rapidgator.net/file/retry"); - - expect(result).toEqual({ - directUrl: "https://alldebrid.direct/retry-file.bin", - fileName: "retry-file.bin", - fileSize: 12345, - retriesUsed: 0 - }); - expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1); - expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/register/?from=de"); - expect(mockShow).toHaveBeenCalled(); - expect(mockFocus).toHaveBeenCalled(); - expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1); - expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1); + expect(authenticated).toHaveBeenCalledTimes(1); expect(mockClose).toHaveBeenCalledTimes(1); - expect(mockFromPartition).toHaveBeenCalledWith("persist:alldebrid-web"); - expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("returns after opening the window instead of waiting for PIN activation", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(pinResponse()) + .mockReturnValueOnce(new Promise(() => {})); + const fallback = new AllDebridWebFallback(() => false, vi.fn()); + + await expect(fallback.openLoginWindow()).resolves.toBeUndefined(); + + expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/pin/?pin=ABCD"); + expect(mockShow).toHaveBeenCalledTimes(1); + }); + + it("reuses the active PIN window without creating another flow", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(pinResponse()) + .mockReturnValueOnce(new Promise(() => {})); + const fallback = new AllDebridWebFallback(() => true, vi.fn()); + + await fallback.openLoginWindow(); + await fallback.openLoginWindow(); + + expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls.filter(([url]) => url === "https://api.alldebrid.com/v4.1/pin/get")).toHaveLength(1); + expect(mockShow).toHaveBeenCalledTimes(2); + expect(mockFocus).toHaveBeenCalledTimes(2); + }); + + it("stops polling after the user closes the window and never reopens it", async () => { + vi.useFakeTimers(); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(pinResponse()) + .mockResolvedValue(checkResponse(false, 595)); + const authenticated = vi.fn(); + const fallback = new AllDebridWebFallback(() => true, authenticated); + + await fallback.openLoginWindow(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + mockClose(); + await vi.advanceTimersByTimeAsync(30_000); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1); + expect(authenticated).not.toHaveBeenCalled(); + }); + + it("closes the window and stops polling when the caller aborts", async () => { + vi.useFakeTimers(); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(pinResponse()) + .mockResolvedValue(checkResponse(false, 595)); + const authenticated = vi.fn(); + const fallback = new AllDebridWebFallback(() => true, authenticated); + const controller = new AbortController(); + + await fallback.openLoginWindow(controller.signal); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + controller.abort(); + await vi.advanceTimersByTimeAsync(30_000); + + expect(mockClose).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(authenticated).not.toHaveBeenCalled(); + }); + + it("times out from the server expiry without reopening the login window", async () => { + vi.useFakeTimers(); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce(pinResponse(1)) + .mockResolvedValueOnce(checkResponse(false, 1)); + const authenticated = vi.fn(); + const failed = vi.fn(); + const fallback = new AllDebridWebFallback(() => true, authenticated, failed); + + await fallback.openLoginWindow(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.waitFor(() => expect(failed).toHaveBeenCalledTimes(1)); + + expect(failed.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ message: "AllDebrid PIN-Login Timeout" })); + expect(authenticated).not.toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/app-controller.test.ts b/tests/app-controller.test.ts index ec1fa5f..0e55003 100644 --- a/tests/app-controller.test.ts +++ b/tests/app-controller.test.ts @@ -29,7 +29,7 @@ function createController(settings: AppSettings): AppController { const controller = Object.create(AppController.prototype) as any; controller.settings = settings; controller.storagePaths = createStoragePaths(dir); - controller.manager = { setSettings: vi.fn() }; + controller.manager = { setSettings: vi.fn(), applyDebridAccountStatuses: vi.fn() }; controller.audit = vi.fn(); controller.overlayLiveUsageCounters = vi.fn(); controller.pruneRealDebridWebFallbacks = vi.fn(); @@ -93,3 +93,81 @@ describe("AppController daily start settings", () => { expect(controller.getSettings().scheduledStartEpochMs).toBe(scheduledStartEpochMs); }); }); + +describe("AppController AllDebrid account checks", () => { + it("routes a stored AllDebrid API account through the AllDebrid user endpoint and persists its status", async () => { + const controller = createController({ + ...defaultSettings(), + allDebridToken: "fixture-all-api-key" + }) as any; + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ + status: "success", + data: { + user: { + username: "all-user", + email: "all@example.test", + isPremium: true, + premiumUntil: "1800000000" + } + } + }), { status: 200, headers: { "Content-Type": "application/json" } })); + + const status = await controller.checkAccountCredentials({ + kind: "alldebrid-api", + accountId: "svc-alldebrid" + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0][0])).toBe("https://api.alldebrid.com/v4/user"); + expect(status).toMatchObject({ + accountId: "svc-alldebrid", + provider: "alldebrid", + valid: true, + isPremium: true, + username: "all-user", + email: "all@example.test" + }); + expect(controller.manager.applyDebridAccountStatuses).toHaveBeenCalledWith([status]); + }); + + it("stores a PIN-issued API key, enables AllDebrid and applies the checked account status", async () => { + configureCredentialProtector({ + isEncryptionAvailable: () => false, + encryptString: (value) => Buffer.from(value, "utf8"), + decryptString: (value) => Buffer.from(value).toString("utf8") + }); + const controller = createController({ + ...defaultSettings(), + allDebridToken: "", + allDebridUseWebLogin: false, + disabledProviders: ["alldebrid"] + }) as any; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ + status: "success", + data: { + user: { + username: "pin-user", + email: "pin@example.test", + isPremium: true, + premiumUntil: "1800000000" + } + } + }), { status: 200, headers: { "Content-Type": "application/json" } })); + + await controller.completeAllDebridLogin("fixture-pin-api-key"); + + expect(controller.getSettings()).toMatchObject({ + allDebridToken: "fixture-pin-api-key", + allDebridUseWebLogin: true + }); + expect(controller.getSettings().disabledProviders).not.toContain("alldebrid"); + expect(controller.manager.applyDebridAccountStatuses).toHaveBeenCalledWith([ + expect.objectContaining({ + accountId: "svc-alldebrid", + provider: "alldebrid", + valid: true, + username: "pin-user" + }) + ]); + }); +}); diff --git a/tests/app-shell.test.tsx b/tests/app-shell.test.tsx index a2752aa..1a40e3e 100644 --- a/tests/app-shell.test.tsx +++ b/tests/app-shell.test.tsx @@ -75,6 +75,19 @@ describe("desktop shell", () => { expect(menu).toContain("openCollectorInput()"); }); + it("uses the AllDebrid PIN flow before creating an account and exposes direct account checks", () => { + const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); + const createFlow = source.slice(source.indexOf("const onSaveAccountDialog"), source.indexOf("const onResetAccountDailyUsage")); + const rowFlow = source.slice(source.indexOf("const accountRows"), source.indexOf("const [accountStatusSort")); + const checkFlow = source.slice(source.indexOf("const checkAccountTableRow"), source.indexOf("const onCheckUpdates")); + + expect(createFlow).toContain('dialogSnapshot.kind === "alldebrid-web"'); + expect(createFlow.indexOf("openAllDebridLogin")).toBeLessThan(createFlow.indexOf("buildAccountCreateCommand")); + expect(rowFlow).toContain('entry.service === "alldebrid" ? "svc-alldebrid" : null'); + expect(checkFlow).toContain('row.entry.kind === "alldebrid-api"'); + expect(checkFlow).toContain('row.entry.kind === "alldebrid-web"'); + }); + it("places the delete confirmation opt-out below the right-aligned actions", () => { const source = readFileSync(new URL("../src/renderer/views/downloads/DeleteConfirmationDialog.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8"); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 0aceabc..abeacda 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -1045,7 +1045,7 @@ describe("debrid service", () => { expect(calls).toBe(1); }); - it("does not retry AllDebrid auth failures (403)", async () => { + it("does not retry AllDebrid auth failures (403)", async () => { const settings = { ...defaultSettings(), allDebridToken: "ad-token", @@ -1071,8 +1071,32 @@ describe("debrid service", () => { const service = new DebridService(settings); await expect(service.unrestrictLink("https://hoster.example/file/no-retry-ad")).rejects.toThrow(); - expect(calls).toBe(1); - }); + expect(calls).toBe(1); + }); + + it("preserves the official AllDebrid error code alongside its message", async () => { + const settings = { + ...defaultSettings(), + allDebridToken: "ad-token", + providerOrder: [] as const, + providerPrimary: "alldebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + globalThis.fetch = (async () => new Response(JSON.stringify({ + status: "error", + error: { + code: "NO_SERVER", + message: "Servers are not allowed on this endpoint" + } + }), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch; + + const service = new DebridService(settings); + + await expect(service.unrestrictLink("https://rapidgator.net/file/no-server")) + .rejects.toThrow(/NO_SERVER: Servers are not allowed/); + }); it("supports AllDebrid unlock", async () => { const settings = { @@ -1321,7 +1345,7 @@ describe("debrid service", () => { expect(info[0].hostStateLabel).toBe("Offline"); }); - it("uses AllDebrid web path when enabled", async () => { + it("uses the official AllDebrid API after browser authorization", async () => { const settings = { ...defaultSettings(), allDebridToken: "ad-token", @@ -1333,26 +1357,32 @@ describe("debrid service", () => { 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 fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.alldebrid.com/v4/link/unlock")) { + return new Response(JSON.stringify({ + status: "success", + data: { + link: "https://df4ea4.debrid.it/dl/example/from-api.rar", + filename: "from-api.rar", + filesize: 1234 + } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("not-found", { status: 404 }); + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const service = new DebridService(settings); + 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(result.sourceLabel).toBe("API"); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("treats AllDebrid browser authorization without an API key as not configured", async () => { const settings = { ...defaultSettings(), allDebridToken: "", diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 1828bcf..a5ce88a 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -140,8 +140,6 @@ describe("selected item run scope", () => { internal.retryAfterByItem.set(itemIds[1], 200); internal.retryStateByItem.set(itemIds[0], { freshRetryUsed: true, resumeHardResetUsed: false }); internal.retryStateByItem.set(itemIds[1], { freshRetryUsed: false, resumeHardResetUsed: true }); - internal.pacedStartReservationByItem.set(itemIds[0], 100); - internal.pacedStartReservationByItem.set(itemIds[1], 200); internal.standalonePackageResults.add("foreign-package:1"); manager.stop(); @@ -152,8 +150,6 @@ describe("selected item run scope", () => { expect(internal.retryAfterByItem.get(itemIds[1])).toBe(200); expect(internal.retryStateByItem.has(itemIds[0])).toBe(false); expect(internal.retryStateByItem.has(itemIds[1])).toBe(true); - expect(internal.pacedStartReservationByItem.has(itemIds[0])).toBe(false); - expect(internal.pacedStartReservationByItem.get(itemIds[1])).toBe(200); expect(internal.standalonePackageResults.has("foreign-package:1")).toBe(true); expect(internal.suppressedPackageResults.has("foreign-package:1")).toBe(false); }); @@ -181,7 +177,7 @@ describe("selected item run scope", () => { expect(internal.retryAfterByItem.has(itemIds[1])).toBe(true); }); - it("resuming preserves item, disk and provider cooldown state", async () => { + it("resuming preserves item and provider cooldown state", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-cooldowns-")); tempDirs.push(root); const { manager, itemIds } = createSelectedItemManager(root); @@ -192,15 +188,11 @@ describe("selected item run scope", () => { internal.session.paused = true; const now = Date.now(); internal.retryAfterByItem.set(itemIds[0], now + 11_000); - internal.providerStartReservations.set("provider-key", now + 12_000); - internal.pacedStartReservationByItem.set(itemIds[0], now + 13_000); internal.providerFailures.set("provider-key", { count: 1, lastFailAt: now, cooldownUntil: now + 14_000 }); manager.togglePause(); expect(internal.retryAfterByItem.get(itemIds[0])).toBe(now + 11_000); - expect(internal.providerStartReservations.get("provider-key")).toBe(now + 12_000); - expect(internal.pacedStartReservationByItem.get(itemIds[0])).toBe(now + 13_000); expect(internal.providerFailures.get("provider-key")?.cooldownUntil).toBe(now + 14_000); expect(internal.findNextQueuedItem()).toBeNull(); }); @@ -1589,6 +1581,31 @@ async function waitFor(predicate: () => boolean, timeoutMs = 15000): Promise { directUrl: string; fileName: string; fileSize: number }, + limitSimuDl: number +): void { + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/user/hosts")) { + return new Response(JSON.stringify({ + status: "success", + data: { hosts: { rapidgator: { name: "Rapidgator", status: true, limitSimuDl } } } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("/link/unlock")) { + const bodyText = init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body || ""); + const originalLink = new URLSearchParams(bodyText).get("link") || ""; + const resolved = resolveLink(originalLink); + return new Response(JSON.stringify({ + status: "success", + data: { link: resolved.directUrl, filename: resolved.fileName, filesize: resolved.fileSize } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return originalFetch(input, init); + }; +} + async function removeDirWithRetries(dir: string): Promise { let lastError: unknown = null; for (let attempt = 1; attempt <= 5; attempt += 1) { @@ -9298,7 +9315,118 @@ describe("download manager", () => { } }); - it("limits AllDebrid rapidgator starts to one active task by default", async () => { + it("starts AllDebrid items immediately without paced-start reservations", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + let unrestrictCalls = 0; + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/user/hosts")) { + return new Response(JSON.stringify({ + status: "success", + data: { hosts: { rapidgator: { name: "Rapidgator", status: true, limitSimuDl: 2 } } } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("/link/unlock")) { + unrestrictCalls += 1; + await new Promise(() => {}); + } + return originalFetch(input); + }; + const manager = new DownloadManager( + { + ...defaultSettings(), + allDebridToken: "ad-token", + providerOrder: [], + providerPrimary: "alldebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 2 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "ad-immediate", links: ["https://rapidgator.net/file/ad-immediate/sample.rar.html"] }]); + await manager.start(); + + await waitFor(() => unrestrictCalls === 1, 1000); + const internal = manager as any; + expect(internal.pacedStartReservationByItem).toBeUndefined(); + expect(internal.providerStartReservations).toBeUndefined(); + + manager.stop(); + await waitFor(() => !manager.getSnapshot().session.running, 5000); + }); + + it.each([ + ["LINK_DOWN", "This link is not available on the file hoster website", "Link ungültig"], + ["BAD_LINK", "The link format is invalid", "Link ungültig"], + ["LINK_HOST_NOT_SUPPORTED", "This host is not supported", "Link ungültig"], + ["LINK_NOT_SUPPORTED", "This link is not supported", "Link ungültig"], + ["AUTH_MISSING_APIKEY", "The auth apikey was not sent", "AllDebrid-Anmeldung fehlgeschlagen"], + ["AUTH_BAD_APIKEY", "The API key is invalid", "AllDebrid-Anmeldung fehlgeschlagen"], + ["NO_SERVER", "Servers are not allowed on this endpoint", "AllDebrid-Anmeldung fehlgeschlagen"] + ])("fails terminal AllDebrid errors without retry or host cooldown: %s", async (code, message, expectedStatus) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + let unrestrictCalls = 0; + globalThis.fetch = async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/user/hosts")) { + return new Response(JSON.stringify({ + status: "success", + data: { hosts: { rapidgator: { name: "Rapidgator", status: true, limitSimuDl: 1 } } } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("/link/unlock")) { + unrestrictCalls += 1; + return new Response(JSON.stringify({ status: "error", error: { code, message } }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return originalFetch(input); + }; + const manager = new DownloadManager( + { + ...defaultSettings(), + allDebridToken: "ad-token", + providerOrder: [], + providerPrimary: "alldebrid", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + retryLimit: 0, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "ad-terminal", links: ["https://rapidgator.net/file/ad-terminal/sample.rar.html"] }]); + await manager.start(); + await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.status === "failed", 5000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + const internal = manager as any; + expect(unrestrictCalls).toBe(1); + expect(item.retries).toBe(0); + expect(item.fullStatus).toContain(expectedStatus); + expect(internal.retryAfterByItem.has(item.id)).toBe(false); + expect(internal.providerFailures.has("alldebrid:rapidgator")).toBe(false); + }); + + it("respects the one-slot AllDebrid Rapidgator limit returned by the API", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const binary = Buffer.alloc(2 * 1024 * 1024, 6); @@ -9447,7 +9575,7 @@ describe("download manager", () => { } }, 35000); - it("allows concurrent AllDebrid Web Rapidgator starts up to configured parallelism", async () => { + it("allows concurrent AllDebrid Rapidgator starts up to the reported slot limit", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const chunk = Buffer.alloc(256 * 1024, 9); @@ -9493,11 +9621,15 @@ describe("download manager", () => { const directUrl3 = `http://127.0.0.1:${address.port}/ad-web-3`; try { + mockAllDebridApi((link) => ({ + directUrl: link === link2 ? directUrl2 : link === link3 ? directUrl3 : directUrl1, + fileName: link === link2 ? "ad-web-2.bin" : link === link3 ? "ad-web-3.bin" : "ad-web-1.bin", + fileSize: chunk.length * 10 + }), 3); const manager = new DownloadManager( { ...defaultSettings(), allDebridToken: "ad-token", - allDebridUseWebLogin: true, providerOrder: [], providerPrimary: "alldebrid", providerSecondary: "none", @@ -9510,15 +9642,7 @@ describe("download manager", () => { maxParallel: 3 }, emptySession(), - createStoragePaths(path.join(root, "state")), - { - allDebridWebUnrestrict: async (link) => ({ - fileName: link === link2 ? "ad-web-2.bin" : link === link3 ? "ad-web-3.bin" : "ad-web-1.bin", - directUrl: link === link2 ? directUrl2 : link === link3 ? directUrl3 : directUrl1, - fileSize: chunk.length * 10, - retriesUsed: 0 - }) - } + createStoragePaths(path.join(root, "state")) ); manager.addPackages([{ name: "ad-web-parallel", links: [link1, link2, link3] }]); @@ -10285,7 +10409,7 @@ describe("download manager", () => { await new Promise((resolve) => setTimeout(resolve, 150)); }); - it("shows the same AllDebrid countdown for all immediately free slots", async () => { + it("starts all immediately free AllDebrid slots without a countdown", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const chunk = Buffer.alloc(256 * 1024, 9); @@ -10327,11 +10451,19 @@ describe("download manager", () => { const links = Array.from({ length: totalLinks }, (_, index) => `https://rapidgator.net/file/web-${index + 1}/sample.part${index + 1}.rar.html`); try { + mockAllDebridApi((link) => { + const match = link.match(/web-(\d+)/); + const slot = Number(match?.[1] || 1); + return { + fileName: `ad-web-${slot}.bin`, + directUrl: `http://127.0.0.1:${address.port}/ad-web-${slot}`, + fileSize: chunk.length * 10 + }; + }, 5); const manager = new DownloadManager( { ...defaultSettings(), allDebridToken: "ad-token", - allDebridUseWebLogin: true, providerOrder: [], providerPrimary: "alldebrid", providerSecondary: "none", @@ -10344,19 +10476,7 @@ describe("download manager", () => { maxParallel: 5 }, emptySession(), - createStoragePaths(path.join(root, "state")), - { - allDebridWebUnrestrict: async (link) => { - const match = link.match(/web-(\d+)/); - const slot = Number(match?.[1] || 1); - return { - fileName: `ad-web-${slot}.bin`, - directUrl: `http://127.0.0.1:${address.port}/ad-web-${slot}`, - fileSize: chunk.length * 10, - retriesUsed: 0 - }; - } - } + createStoragePaths(path.join(root, "state")) ); manager.addPackages([{ name: "ad-web-visibility", links }]); @@ -10364,18 +10484,15 @@ describe("download manager", () => { await waitFor(() => { const items = Object.values(manager.getSnapshot().session.items); - const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || "")); - return countdownItems.length === 5; + return items.filter((item) => item.status === "downloading" || item.status === "validating").length === 5; }, 10000); const items = Object.values(manager.getSnapshot().session.items); const activeCount = items.filter((item) => item.status === "downloading" || item.status === "validating").length; const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || "")); - const uniqueCountdowns = new Set(countdownItems.map((item) => item.fullStatus || "")); - expect(activeCount).toBe(0); - expect(countdownItems.length).toBe(5); - expect(uniqueCountdowns.size).toBe(1); + expect(activeCount).toBe(5); + expect(countdownItems).toHaveLength(0); manager.stop(); await waitFor(() => !manager.getSnapshot().session.running, 15000); @@ -10385,7 +10502,7 @@ describe("download manager", () => { } }, 20000); - it("starts immediately free AllDebrid slots after the same 3 second delay", async () => { + it("starts immediately free AllDebrid API slots without a fixed delay", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const binary = Buffer.alloc(512 * 1024, 5); @@ -10469,19 +10586,11 @@ describe("download manager", () => { manager.addPackages([{ name: "ad-paced", links: [link1, link2, link3] }]); await manager.start(); - const managerInternals = manager as unknown as { - retryAfterByItem: Map; - }; - await waitFor(() => managerInternals.retryAfterByItem.size >= 3, 5000); - - const now = Date.now(); - const readyTimes = [...managerInternals.retryAfterByItem.values()].sort((a, b) => a - b); - expect(readyTimes.length).toBe(3); - const firstDelay = readyTimes[0] - now; - const lastDelay = readyTimes[readyTimes.length - 1] - now; - expect(firstDelay).toBeGreaterThan(2000); - expect(firstDelay).toBeLessThan(4500); - expect(lastDelay - firstDelay).toBeLessThan(500); + await waitFor(() => { + const items = Object.values(manager.getSnapshot().session.items); + return items.filter((item) => item.status === "downloading" || item.status === "validating").length === 3; + }, 1000); + expect(Object.values(manager.getSnapshot().session.items).some((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || ""))).toBe(false); manager.stop(); await waitFor(() => !manager.getSnapshot().session.running, 15000); @@ -10491,7 +10600,7 @@ describe("download manager", () => { } }, 20000); - it("tops up newly freed AllDebrid slots with a fresh 3 second countdown", async () => { + it("tops up newly freed AllDebrid slots immediately", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const shortBinary = Buffer.alloc(64 * 1024, 7); @@ -10537,11 +10646,18 @@ describe("download manager", () => { ]; try { + mockAllDebridApi((link) => { + const slot = links.indexOf(link) + 1; + return { + fileName: `ad-topup-${slot}.bin`, + directUrl: `http://127.0.0.1:${address.port}/ad-${slot}`, + fileSize: slot === 1 ? shortBinary.length : longBinary.length + }; + }, 3); const manager = new DownloadManager( { ...defaultSettings(), allDebridToken: "ad-token", - allDebridUseWebLogin: true, providerOrder: [], providerPrimary: "alldebrid", providerSecondary: "none", @@ -10554,18 +10670,7 @@ describe("download manager", () => { maxParallel: 3 }, emptySession(), - createStoragePaths(path.join(root, "state")), - { - allDebridWebUnrestrict: async (link) => { - const slot = links.indexOf(link) + 1; - return { - fileName: `ad-topup-${slot}.bin`, - directUrl: `http://127.0.0.1:${address.port}/ad-${slot}`, - fileSize: slot === 1 ? shortBinary.length : longBinary.length, - retriesUsed: 0 - }; - } - } + createStoragePaths(path.join(root, "state")) ); manager.addPackages([{ name: "ad-topup", links }]); @@ -10579,9 +10684,10 @@ describe("download manager", () => { await waitFor(() => { const items = Object.values(manager.getSnapshot().session.items); const completedCount = items.filter((item) => item.status === "completed").length; - const countdownItems = items.filter((item) => /^AllDebrid Start in [123]s$/.test(item.fullStatus || "")); - return completedCount >= 1 && countdownItems.length === 1; + const downloadingCount = items.filter((item) => item.status === "downloading").length; + return completedCount >= 1 && downloadingCount === 3; }, 12000); + expect(Object.values(manager.getSnapshot().session.items).some((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || ""))).toBe(false); manager.stop(); await waitFor(() => !manager.getSnapshot().session.running, 15000); diff --git a/tests/renderer-state.test.ts b/tests/renderer-state.test.ts index 9eab3e7..d8bf6b1 100644 --- a/tests/renderer-state.test.ts +++ b/tests/renderer-state.test.ts @@ -7,6 +7,8 @@ import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import type { AppSettings, RendererAccountKind } from "../src/shared/types"; +const NOW = 1_700_000_000_000; + const SECRETS = { token: "fixture-rd-token-7vQ2", megaPassword: "fixture-mega-password-8kM3", @@ -81,6 +83,36 @@ describe("renderer state serialization", () => { expect(JSON.stringify(state)).not.toContain(secondToken); expect(realDebridRows[0].accountId).not.toBe(`rda_${crypto.createHash("sha256").update(firstToken).digest("hex").slice(0, 32)}`); }); + it("projects the persisted AllDebrid service status", () => { + const settings = { + ...defaultSettings(), + allDebridToken: "fixture-all-status-secret", + debridAccountStatuses: { + "svc-alldebrid": { + accountId: "svc-alldebrid", + provider: "alldebrid" as const, + label: "AllDebrid", + maskedLogin: "Geschützter API-Key", + valid: true, + isPremium: true, + premiumUntilMs: NOW, + username: "all-user", + email: "all@example.test", + message: "Premium aktiv", + checkedAt: 1 + } + } + }; + + const account = createRendererState(settings).accounts.find((entry) => entry.accountId === "svc-alldebrid"); + + expect(account).toMatchObject({ + kind: "alldebrid-api", + provider: "alldebrid", + identity: "all-user", + status: expect.objectContaining({ valid: true, username: "all-user", email: "all@example.test" }) + }); + }); it.each(ACCOUNT_FIXTURES)("serializes $kind without its representative secret", ({ kind, secret, settings }) => { const state = createRendererState({ ...defaultSettings(), ...settings }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 321c205..525e755 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1007,16 +1007,23 @@ describe("settings storage", () => { expect(normalized.debridAccountStatuses[key.id].email).toBeUndefined(); }); - 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({ + it("migrates legacy AllDebrid web login without a PIN-issued API key to reauthorization", () => { + expect(defaultSettings().allDebridUseWebLogin).toBe(false); + + const legacyWebOnly = normalizeSettings({ + ...defaultSettings(), + allDebridUseWebLogin: 1 as unknown as boolean + }); + expect(legacyWebOnly.allDebridUseWebLogin).toBe(false); + + const authorizedWeb = normalizeSettings({ + ...defaultSettings(), + allDebridToken: "fixture-pin-issued-key", + allDebridUseWebLogin: 1 as unknown as boolean + }); + expect(authorizedWeb.allDebridUseWebLogin).toBe(true); + + const normalizedDisabled = normalizeSettings({ ...defaultSettings(), allDebridUseWebLogin: 0 as unknown as boolean }); @@ -1034,6 +1041,35 @@ describe("settings storage", () => { expect(normalized.historyRetentionMode).toBe("permanent"); }); + it("keeps the AllDebrid service status only while AllDebrid is configured", () => { + const status = { + accountId: "svc-alldebrid", + provider: "alldebrid" as const, + label: "AllDebrid", + maskedLogin: "Geschützter API-Key", + valid: true, + isPremium: true, + premiumUntilMs: Date.now() + 1000, + username: "all-user", + email: "all@example.test", + message: "Premium aktiv", + checkedAt: Date.now() + }; + + const configured = normalizeSettings({ + ...defaultSettings(), + allDebridToken: "all-api-key", + debridAccountStatuses: { "svc-alldebrid": status } + }); + const removed = normalizeSettings({ + ...defaultSettings(), + debridAccountStatuses: { "svc-alldebrid": status } + }); + + expect(configured.debridAccountStatuses["svc-alldebrid"]).toEqual(status); + expect(removed.debridAccountStatuses["svc-alldebrid"]).toBeUndefined(); + }); + it("loads legacy history without inventing structured durations", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir);