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.
This commit is contained in:
Sucukdeluxe
2026-08-23 16:03:09 +02:00
parent 6f922c458d
commit c46f9a1d7a
22 changed files with 1267 additions and 964 deletions
+23
View File
@@ -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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+79
View File
@@ -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;
@@ -176,6 +178,80 @@ export async function checkRealDebridAccount(
}
}
export async function checkAllDebridAccount(
token: string,
signal?: AbortSignal,
now = Date.now()
): Promise<DebridAccountStatus> {
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<string, unknown>
: 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<string, unknown>
: null;
const user = data?.user && typeof data.user === "object"
? data.user as Record<string, unknown>
: 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,
signal?: AbortSignal,
@@ -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<DebridAccountStatus>> = [
...realDebridAccounts.map((account) => () => checkRealDebridAccount(
@@ -347,6 +425,7 @@ export async function checkAllDebridAccounts(
? (probeSignal) => probeRealDebridWebSession(account.id, probeSignal)
: undefined
)),
...(checkAllDebrid ? [() => checkAllDebridAccount(allDebridToken, signal, now)] : []),
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
];
+3 -1
View File
@@ -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 || "") };
+214 -389
View File
@@ -1,39 +1,52 @@
import { BrowserWindow, session } from "electron";
import { AllDebridHostInfo } from "../shared/types";
import { UnrestrictedLink } from "./realdebrid";
import { filenameFromUrl, sleep } from "./utils";
import { ALLDEBRID_LOGIN_HOSTS, applyRemoteLoginSecurity, createRemoteLoginWebPreferences } from "./browser-security";
const ALLDEBRID_BASE_URL = "https://alldebrid.com";
const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`;
const ALLDEBRID_SERVICE_URL = `${ALLDEBRID_BASE_URL}/service.php`;
const ALLDEBRID_SERVICE_REFERER = `${ALLDEBRID_BASE_URL}/service/?from=de`;
const ALLDEBRID_DELAYED_URL = `${ALLDEBRID_BASE_URL}/internalapi/v4/link/delayed`;
const ALLDEBRID_STATUS_URL = `${ALLDEBRID_BASE_URL}/status/`;
const ALLDEBRID_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_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";
const ALLDEBRID_POLL_INTERVAL_MS = 5_000;
type DelayedStatusPayload = {
status: number;
link: string;
timeLeft: number;
type AllDebridApiPayload = {
status?: unknown;
data?: unknown;
error?: unknown;
};
type GenerateOutcome =
| { kind: "success"; value: UnrestrictedLink }
| { kind: "login_required" };
type AllDebridPin = {
pin: string;
check: string;
expiresIn: number;
userUrl: string;
};
export type AllDebridPinLoginResult = {
apiKey: string;
};
type AllDebridPinLoginHandler = (result: AllDebridPinLoginResult) => void | Promise<void>;
type AllDebridPinLoginErrorHandler = (error: Error) => void;
function abortError(): Error {
return new Error("aborted:alldebrid-web");
return new Error("aborted:alldebrid-pin-login");
}
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!signal) {
return timeoutSignal;
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return AbortSignal.any([signal, timeoutSignal]);
return value as Record<string, unknown>;
}
function stringValue(record: Record<string, unknown> | null, key: string): string {
const value = record?.[key];
return typeof value === "string" ? value.trim() : "";
}
function positiveSeconds(record: Record<string, unknown> | 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 {
@@ -42,15 +55,8 @@ function throwIfAborted(signal?: AbortSignal): void {
}
}
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(ms);
return;
}
if (signal.aborted) {
throw abortError();
}
async function sleepWithSignal(ms: number, signal: AbortSignal): Promise<void> {
throwIfAborted(signal);
await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
@@ -71,208 +77,102 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
});
}
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return promise;
}
return new Promise<T>((resolve, reject) => {
let settled = false;
const onAbort = (): void => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then((value) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
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<AllDebridApiPayload> {
throwIfAborted(signal);
const response = await fetch(url, {
...init,
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)])
});
if (signal.aborted) {
onAbort();
}
});
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function pickString(payload: Record<string, unknown> | null, keys: string[]): string {
if (!payload) {
return "";
}
for (const key of keys) {
const value = payload[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
}
function pickNumber(payload: Record<string, unknown> | null, keys: string[]): number | null {
if (!payload) {
return null;
}
for (const key of keys) {
const value = Number(payload[key] ?? NaN);
if (Number.isFinite(value) && value >= 0) {
return Math.floor(value);
}
}
return null;
}
function parseJson(text: string): Record<string, unknown> | null {
const text = await response.text();
let payload: AllDebridApiPayload;
try {
return asRecord(JSON.parse(text) as unknown);
payload = JSON.parse(text) as AllDebridApiPayload;
} catch {
return null;
throw new Error(`AllDebrid PIN-Login: ungültige API-Antwort (HTTP ${response.status})`);
}
if (!response.ok || payload.status !== "success") {
throw apiError(payload);
}
return payload;
}
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";
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");
}
if (normalized.includes("down.gif")) {
return "down";
const parsedUrl = new URL(userUrl);
if (parsedUrl.protocol !== "https:" || parsedUrl.hostname !== "alldebrid.com") {
throw new Error("AllDebrid PIN-Login: ungültige Benutzer-URL");
}
if (normalized.includes("not.tracked")) {
return "not_tracked";
}
return "unknown";
}
function toHostStatusLabel(state: AllDebridHostInfo["state"]): string {
if (state === "up") {
return "Verfügbar";
}
if (state === "down") {
return "Unverfügbar";
}
if (state === "not_tracked") {
return "Nicht getrackt";
}
return "Unbekannt";
}
function extractHostInfoFromStatusPage(html: string, host: string): AllDebridHostInfo | null {
const wanted = normalizeHostName(host);
const rowRegex = /<tr class=['"]g1['"]>\s*<td[^>]*>[\s\S]*?<i[^>]*alt=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/td>\s*<td[^>]*class=['"]comparatif_content['"][^>]*>[\s\S]*?<img[^>]*src=['"]([^'"]+)['"][^>]*>[\s\S]*?\((?:<span[^>]*data-fdate=['"](\d+)['"][^>]*><\/span>|([^<)]*))\)/gi;
for (let match = rowRegex.exec(html); match; match = rowRegex.exec(html)) {
const hostAlt = normalizeHostName(match[1] || "");
if (hostAlt !== wanted) {
continue;
}
const state = toHostStateFromIcon(match[2] || "");
const lastCheckedSeconds = Number(match[3] ?? NaN);
return {
host,
source: "web",
state,
statusLabel: toHostStatusLabel(state),
fetchedAt: Date.now(),
lastCheckedAt: Number.isFinite(lastCheckedSeconds) ? lastCheckedSeconds * 1000 : null,
quota: null,
quotaMax: null,
quotaType: "",
limitSimuDl: null,
note: "Quota und Simultan-Slots sind per Web-Login nicht öffentlich verfügbar."
};
}
return null;
return { pin, check, expiresIn, userUrl };
}
export class AllDebridWebFallback {
private queue: Promise<unknown> = Promise.resolve();
private loginWindow: BrowserWindow | null = null;
private loginWindowPartition = "";
private loginController: AbortController | null = null;
private getRememberSession: () => boolean;
private opening: Promise<void> | null = null;
public constructor(getRememberSession: () => boolean) {
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 unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
return this.runExclusive(async () => {
throwIfAborted(overallSignal);
if (!String(link || "").trim()) {
return null;
public async openLoginWindow(signal?: AbortSignal): Promise<void> {
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 initial = await this.generate(link, overallSignal);
if (initial.kind === "success") {
return initial.value;
const opening = this.startPinLogin(signal);
this.opening = opening;
try {
await opening;
} finally {
if (this.opening === opening) {
this.opening = null;
}
return this.waitForLoginAndGenerate(link, overallSignal);
}, overallSignal);
}
public async openLoginWindow(): Promise<void> {
const window = await this.ensureLoginWindow();
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
public async getHostInfo(host: string): Promise<AllDebridHostInfo> {
const currentSession = session.fromPartition(this.getPartition());
const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, {
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Referer: ALLDEBRID_SERVICE_REFERER,
"User-Agent": ALLDEBRID_USER_AGENT
},
signal: withTimeoutSignal(undefined, 30_000)
});
const text = await response.text();
if (!response.ok) {
throw new Error(`AllDebrid Web Status HTTP ${response.status}`);
}
if (!/id=['"]statusContainer['"]/i.test(text)) {
throw new Error("AllDebrid Web-Status nicht verfügbar. Bitte zuerst im AllDebrid-Fenster einloggen.");
}
const info = extractHostInfoFromStatusPage(text, host);
if (!info) {
throw new Error(`AllDebrid Web-Status für ${host} nicht gefunden`);
}
return info;
}
public async clearSessions(): Promise<void> {
this.disposeLoginWindow();
this.cancelLoginFlow(true);
for (const partition of [ALLDEBRID_PERSISTENT_PARTITION, ALLDEBRID_TRANSIENT_PARTITION]) {
const currentSession = session.fromPartition(partition);
try {
@@ -289,57 +189,61 @@ export class AllDebridWebFallback {
}
public dispose(): void {
this.disposeLoginWindow();
this.cancelLoginFlow(true);
}
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 showWindow(window: BrowserWindow): void {
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
const queueWaitTimeoutMs = 90_000;
const guardedJob = async (): Promise<T> => {
private async startPinLogin(signal?: AbortSignal): Promise<void> {
this.cancelLoginFlow(true);
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;
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);
}
private async ensureLoginWindow(): Promise<BrowserWindow> {
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 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",
title: "AllDebrid PIN-Login",
webPreferences: createRemoteLoginWebPreferences(partition)
});
applyRemoteLoginSecurity(window, {
@@ -348,173 +252,94 @@ export class AllDebridWebFallback {
});
window.setMenuBarVisibility(false);
window.on("closed", () => {
if (this.loginWindow === window) {
if (this.loginWindow !== window) {
return;
}
this.loginWindow = null;
this.loginWindowPartition = "";
if (this.loginController === controller) {
controller.abort();
this.loginController = null;
this.clearCallerAbortListener();
}
});
this.loginWindow = window;
this.loginWindowPartition = partition;
await window.loadURL(ALLDEBRID_LOGIN_URL);
return window;
}
private async postForm(
url: string,
body: URLSearchParams,
referer: string,
signal?: AbortSignal
): Promise<{ response: Response; text: string }> {
const currentSession = session.fromPartition(this.getPartition());
const response = await currentSession.fetch(url, {
method: "POST",
headers: {
Accept: "application/json, text/javascript, */*; q=0.01",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Origin: ALLDEBRID_BASE_URL,
Referer: referer,
"User-Agent": ALLDEBRID_USER_AGENT,
"X-Requested-With": "XMLHttpRequest"
},
body: body.toString(),
signal: withTimeoutSignal(signal, 30_000)
});
const text = await response.text();
return { response, text };
}
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> {
private async pollForActivation(pin: AllDebridPin, signal: AbortSignal): Promise<AllDebridPinLoginResult> {
const deadline = Date.now() + pin.expiresIn * 1000;
while (Date.now() < deadline) {
throwIfAborted(signal);
const body = new URLSearchParams({
link,
nb: "0",
json: "true",
pw: ""
check: pin.check,
pin: pin.pin
});
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 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");
}
const trimmed = text.trim();
if (trimmed === "login") {
return { kind: "login_required" };
private async completeLogin(controller: AbortController, result: AllDebridPinLoginResult): Promise<void> {
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);
}
}
const payload = parseJson(trimmed);
if (!payload) {
throw new Error("AllDebrid Web lieferte keine JSON-Antwort");
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);
}
const errorText = pickString(payload, ["error"]);
if (errorText) {
if (errorText.toLowerCase() === "premium") {
throw new Error("AllDebrid Web: Premium erforderlich");
}
throw new Error(`AllDebrid Web: ${errorText}`);
private clearCallerAbortListener(): void {
this.removeCallerAbortListener?.();
this.removeCallerAbortListener = null;
}
const directUrl = pickString(payload, ["link"]);
const fileName = pickString(payload, ["filename"]);
const fileSize = pickNumber(payload, ["filesize"]);
if (directUrl) {
return {
kind: "success",
value: {
directUrl,
fileName: fileName || filenameFromUrl(directUrl) || filenameFromUrl(link),
fileSize,
retriesUsed: 0
}
};
}
const delayedId = payload.delayed;
if (delayedId !== undefined && delayedId !== null && delayedId !== false && String(delayedId).trim()) {
const delayed = await this.waitForDelayedLink(String(delayedId).trim(), signal);
return {
kind: "success",
value: {
directUrl: delayed.link,
fileName: fileName || filenameFromUrl(delayed.link) || filenameFromUrl(link),
fileSize: fileSize,
retriesUsed: 0
}
};
}
if (Array.isArray(payload.streams) && payload.streams.length > 0) {
throw new Error("AllDebrid Web: Streaming-Auswahl wird derzeit nicht unterstützt");
}
throw new Error("AllDebrid Web: Antwort ohne Download-Link");
}
private async waitForDelayedLink(delayedId: string, signal?: AbortSignal): Promise<DelayedStatusPayload> {
for (let attempt = 1; attempt <= 120; attempt += 1) {
throwIfAborted(signal);
const body = new URLSearchParams({ id: delayedId });
const { response, text } = await this.postForm(ALLDEBRID_DELAYED_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
if (!response.ok) {
throw new Error(`AllDebrid Web delayed HTTP ${response.status}`);
}
const payload = parseJson(text.trim());
const data = asRecord(payload?.data);
if (pickString(payload, ["status"]).toLowerCase() !== "success" || !data) {
throw new Error("AllDebrid Web: Delayed-Status ungültig");
}
const status = Number(data.status ?? NaN);
if (!Number.isFinite(status)) {
throw new Error("AllDebrid Web: Delayed-Status ohne Status");
}
if (status >= 2) {
const link = pickString(data, ["link"]);
if (!link) {
throw new Error("AllDebrid Web: Delayed-Link fehlt");
}
return {
status,
link,
timeLeft: Math.max(0, Number(data.time_left ?? 0) || 0)
};
}
const timeLeft = Math.max(0, Number(data.time_left ?? 0) || 0);
const delayMs = timeLeft > 0 ? Math.min(5_000, Math.max(1_500, timeLeft * 250)) : 2_000;
await sleepWithSignal(delayMs, signal);
}
throw new Error("AllDebrid Web: Delayed-Link Timeout");
}
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
const window = await this.ensureLoginWindow();
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
const startedAt = Date.now();
while (Date.now() - startedAt < 10 * 60 * 1000) {
throwIfAborted(signal);
if (window.isDestroyed()) {
throw new Error("AllDebrid Web-Login abgebrochen");
}
const outcome = await this.generate(link, signal);
if (outcome.kind === "success") {
if (!window.isDestroyed()) {
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();
}
return outcome.value;
}
await sleepWithSignal(1_500, signal);
}
throw new Error("AllDebrid Web-Login Timeout");
}
}
+40 -6
View File
@@ -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<AccountCommandResult> {
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);
@@ -888,6 +908,23 @@ export class AppController {
await this.allDebridWebFallback.openLoginWindow();
}
private async completeAllDebridLogin(apiKey: string): Promise<void> {
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<number> {
const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath);
this.audit("INFO", "BestDebrid Cookies importiert", {
@@ -898,9 +935,6 @@ export class AppController {
}
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
if (this.settings.allDebridUseWebLogin) {
return this.allDebridWebFallback.getHostInfo(host);
}
const token = this.settings.allDebridToken.trim();
if (!token) {
throw new Error("AllDebrid ist nicht konfiguriert");
+4 -16
View File
@@ -609,13 +609,11 @@ interface ProviderUnrestrictedLink extends UnrestrictedLink {
}
export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => Promise<UnrestrictedLink | null>;
export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type RealDebridWebUnrestrictor = (accountId: string, link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
interface DebridServiceOptions {
megaWebUnrestrict?: MegaWebUnrestrictor;
allDebridWebUnrestrict?: AllDebridWebUnrestrictor;
realDebridWebUnrestrict?: RealDebridWebUnrestrictor;
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
}
@@ -1011,7 +1009,9 @@ function parseAllDebridError(payload: Record<string, unknown> | null): string {
return errorValue.trim();
}
const errorObj = asRecord(errorValue);
return pickString(errorObj, ["message", "code"]) || "AllDebrid API error";
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 {
@@ -4431,10 +4431,6 @@ export class DebridService {
&& !getRealDebridAccountCooldown(account.id, now));
}
private shouldUseAllDebridWeb(settings: AppSettings): boolean {
return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict);
}
private shouldUseBestDebridWeb(settings: AppSettings): boolean {
return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict);
}
@@ -4664,7 +4660,7 @@ export class DebridService {
return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "web") && this.options.megaWebUnrestrict);
}
if (effectiveProvider === "alldebrid") {
return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim());
return Boolean(settings.allDebridToken.trim());
}
if (effectiveProvider === "ddownload") {
return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim());
@@ -4816,14 +4812,6 @@ export class DebridService {
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);
adResult.sourceLabel = "API";
return adResult;
+55 -142
View File
@@ -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;
@@ -546,7 +544,6 @@ type HistoryEntryCallback = (entry: HistoryEntry) => void;
type DownloadManagerOptions = {
megaWebUnrestrict?: MegaWebUnrestrictor;
allDebridWebUnrestrict?: AllDebridWebUnrestrictor;
realDebridWebUnrestrict?: RealDebridWebUnrestrictor;
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
invalidateMegaSession?: () => void;
@@ -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<string, { info: AllDebridHostInfo; cachedAt: number }>();
private providerStartReservations = new Map<string, number>();
private pacedStartReservationByItem = new Map<string, number>();
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
});
@@ -3671,8 +3693,6 @@ export class DownloadManager extends EventEmitter {
this.successDigestTimer = null;
}
this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear();
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.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<string>();
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;
@@ -7764,8 +7764,6 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.nonResumableActive = 0;
this.session.summaryText = "";
this.emitState(true);
@@ -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<AllDebridHostInfo | null> {
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 };
@@ -11124,7 +11019,6 @@ export class DownloadManager extends EventEmitter {
generation: this.lifecycleGeneration
};
this.activeTasks.set(itemId, active);
this.notePacedStartForItem(item, nowMs());
this.emitState();
void this.processItem(active).catch((err) => {
@@ -11284,7 +11178,8 @@ export class DownloadManager extends EventEmitter {
throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`);
}
const errText = compactErrorText(unrestrictError);
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) {
const terminalAllDebridError = classifyAllDebridTerminalUnrestrictError(errText, cooldownProvider);
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText) && !terminalAllDebridError) {
this.recordProviderFailure(cooldownProvider);
if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) {
const busyCooldownMs = isTemporaryUnrestrictError(errText)
@@ -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, []);
@@ -15567,8 +15482,6 @@ export class DownloadManager extends EventEmitter {
this.runScopeKind = null;
this.runOutcomes.clear();
this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear();
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
+7 -4
View File
@@ -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) {
+13 -3
View File
@@ -291,9 +291,13 @@ function normalizeDebridAccountStatuses(
megaIds: string[],
debridLinkIds: string[],
realDebridIds: string[],
legacyRealDebridTargetId: string | null
legacyRealDebridTargetId: string | null,
allDebridConfigured: boolean
): Record<string, DebridAccountStatus> {
const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds]);
if (allDebridConfigured) {
allowed.add("svc-alldebrid");
}
const result: Record<string, DebridAccountStatus> = {};
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [storedKey, raw] of Object.entries(value as Record<string, unknown>)) {
@@ -308,10 +312,15 @@ function normalizeDebridAccountStatuses(
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"
: entry.provider === "alldebrid"
? "alldebrid"
: "megadebrid";
let username = typeof entry.username === "string" ? entry.username : undefined;
let email = typeof entry.email === "string" ? entry.email : undefined;
@@ -543,7 +552,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
bestToken: asText(settings.bestToken),
bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin),
allDebridToken: asText(settings.allDebridToken),
allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin),
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,
+23 -7
View File
@@ -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"
+1 -1
View File
@@ -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;
+71 -1
View File
@@ -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";
@@ -180,7 +180,77 @@ 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 };
+28 -3
View File
@@ -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");
+145 -125
View File
@@ -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({
expect(fetchMock.mock.calls[1]).toEqual([
"https://api.alldebrid.com/v4/pin/check",
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<Response>((_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))
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<Response>(() => {}));
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<Response>(() => {}));
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);
});
});
+79 -1
View File
@@ -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"
})
]);
});
});
+13
View File
@@ -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");
+43 -13
View File
@@ -1074,6 +1074,30 @@ describe("debrid service", () => {
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 = {
...defaultSettings(),
@@ -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 }));
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 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 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(allDebridWeb).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledTimes(0);
expect(result.sourceLabel).toBe("API");
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("treats AllDebrid web mode as not configured when callback is unavailable", async () => {
it("treats AllDebrid browser authorization without an API key as not configured", async () => {
const settings = {
...defaultSettings(),
allDebridToken: "",
+178 -72
View File
@@ -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<voi
}
}
function mockAllDebridApi(
resolveLink: (link: string) => { directUrl: string; fileName: string; fileSize: number },
limitSimuDl: number
): void {
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
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<void> {
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<Response> => {
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<Response> => {
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<string, number>;
};
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);
+32
View File
@@ -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 });
+39 -3
View File
@@ -1007,14 +1007,21 @@ describe("settings storage", () => {
expect(normalized.debridAccountStatuses[key.id].email).toBeUndefined();
});
it("defaults AllDebrid web login to disabled and normalizes the flag", () => {
it("migrates legacy AllDebrid web login without a PIN-issued API key to reauthorization", () => {
expect(defaultSettings().allDebridUseWebLogin).toBe(false);
const normalizedEnabled = normalizeSettings({
const legacyWebOnly = normalizeSettings({
...defaultSettings(),
allDebridUseWebLogin: 1 as unknown as boolean
});
expect(normalizedEnabled.allDebridUseWebLogin).toBe(true);
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(),
@@ -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);