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. 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 ## [2.0.65] - 2026-08-23
### Extract now behavior ### Extract now behavior
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "multi-debrid-downloader", "name": "multi-debrid-downloader",
"version": "2.0.65", "version": "2.0.66",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-debrid-downloader", "name": "multi-debrid-downloader",
"version": "2.0.65", "version": "2.0.66",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "0.6.0", "adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "multi-debrid-downloader", "name": "multi-debrid-downloader",
"version": "2.0.65", "version": "2.0.66",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
+80 -1
View File
@@ -8,11 +8,13 @@ import { compactErrorText } from "./utils";
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php"; const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
const DEBRID_LINK_API = "https://debrid-link.com/api/v2"; 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 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 = 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"; "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; const CHECK_TIMEOUT_MS = 20000;
export const REAL_DEBRID_STATUS_ID = "svc-realdebrid"; export const REAL_DEBRID_STATUS_ID = "svc-realdebrid";
export const ALL_DEBRID_STATUS_ID = "svc-alldebrid";
export interface RealDebridSessionProbeResult { export interface RealDebridSessionProbeResult {
valid: boolean; valid: boolean;
@@ -175,6 +177,80 @@ export async function checkRealDebridAccount(
return { ...base, message: aborted ? "Prüfung abgebrochen" : `Prüfung fehlgeschlagen: ${errText}` }; return { ...base, message: aborted ? "Prüfung abgebrochen" : `Prüfung fehlgeschlagen: ${errText}` };
} }
} }
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( export async function checkMegaDebridAccount(
account: MegaDebridAccountEntry, account: MegaDebridAccountEntry,
@@ -337,6 +413,8 @@ export async function checkAllDebridAccounts(
const realDebridAccounts = scope === "all" const realDebridAccounts = scope === "all"
? allRealDebridAccounts ? allRealDebridAccounts
: providerEnabled("realdebrid") ? allRealDebridAccounts.filter((account) => account.enabled) : []; : 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>> = [ const taskFns: Array<() => Promise<DebridAccountStatus>> = [
...realDebridAccounts.map((account) => () => checkRealDebridAccount( ...realDebridAccounts.map((account) => () => checkRealDebridAccount(
@@ -347,7 +425,8 @@ export async function checkAllDebridAccounts(
? (probeSignal) => probeRealDebridWebSession(account.id, probeSignal) ? (probeSignal) => probeRealDebridWebSession(account.id, probeSignal)
: undefined : undefined
)), )),
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)), ...(checkAllDebrid ? [() => checkAllDebridAccount(allDebridToken, signal, now)] : []),
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now)) ...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 !== "realdebrid-web"
&& raw.kind !== "megadebrid-api" && raw.kind !== "megadebrid-api"
&& raw.kind !== "megadebrid-web" && raw.kind !== "megadebrid-web"
&& raw.kind !== "alldebrid-api"
&& raw.kind !== "alldebrid-web"
&& raw.kind !== "debridlink-api") invalid(); && raw.kind !== "debridlink-api") invalid();
return { return {
kind: raw.kind, 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-api") return { ...settings, bestToken: validateSecret(secret || ""), bestDebridUseWebLogin: false };
if (kind === "bestdebrid-web") return { ...settings, bestToken: "", bestDebridUseWebLogin: true }; if (kind === "bestdebrid-web") return { ...settings, bestToken: "", bestDebridUseWebLogin: true };
if (kind === "alldebrid-api") return { ...settings, allDebridToken: validateSecret(secret || ""), allDebridUseWebLogin: false }; 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 === "ddownload-login") return { ...settings, ddownloadLogin: validateIdentity(identity || ""), ddownloadPassword: validateSecret(secret || "") };
if (kind === "onefichier-api") return { ...settings, oneFichierApiKey: validateSecret(secret || "") }; if (kind === "onefichier-api") return { ...settings, oneFichierApiKey: validateSecret(secret || "") };
if (kind === "linksnappy-login") return { ...settings, linkSnappyLogin: validateIdentity(identity || ""), linkSnappyPassword: validateSecret(secret || "") }; if (kind === "linksnappy-login") return { ...settings, linkSnappyLogin: validateIdentity(identity || ""), linkSnappyPassword: validateSecret(secret || "") };
+326 -501
View File
@@ -1,520 +1,345 @@
import { BrowserWindow, session } from "electron"; 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"; import { ALLDEBRID_LOGIN_HOSTS, applyRemoteLoginSecurity, createRemoteLoginWebPreferences } from "./browser-security";
const ALLDEBRID_BASE_URL = "https://alldebrid.com"; const ALLDEBRID_PIN_GET_URL = "https://api.alldebrid.com/v4.1/pin/get";
const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`; const ALLDEBRID_PIN_CHECK_URL = "https://api.alldebrid.com/v4/pin/check";
const ALLDEBRID_SERVICE_URL = `${ALLDEBRID_BASE_URL}/service.php`; const ALLDEBRID_PERSISTENT_PARTITION = "persist:alldebrid-web";
const ALLDEBRID_SERVICE_REFERER = `${ALLDEBRID_BASE_URL}/service/?from=de`; const ALLDEBRID_TRANSIENT_PARTITION = "alldebrid-web";
const ALLDEBRID_DELAYED_URL = `${ALLDEBRID_BASE_URL}/internalapi/v4/link/delayed`; const ALLDEBRID_POLL_INTERVAL_MS = 5_000;
const ALLDEBRID_STATUS_URL = `${ALLDEBRID_BASE_URL}/status/`;
const ALLDEBRID_PERSISTENT_PARTITION = "persist:alldebrid-web"; type AllDebridApiPayload = {
const ALLDEBRID_TRANSIENT_PARTITION = "alldebrid-web"; status?: unknown;
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"; data?: unknown;
error?: unknown;
type DelayedStatusPayload = { };
status: number;
link: string; type AllDebridPin = {
timeLeft: number; pin: string;
}; check: string;
expiresIn: number;
type GenerateOutcome = userUrl: string;
| { kind: "success"; value: UnrestrictedLink } };
| { kind: "login_required" };
export type AllDebridPinLoginResult = {
function abortError(): Error { apiKey: string;
return new Error("aborted:alldebrid-web"); };
}
type AllDebridPinLoginHandler = (result: AllDebridPinLoginResult) => void | Promise<void>;
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs); type AllDebridPinLoginErrorHandler = (error: Error) => void;
if (!signal) {
return timeoutSignal; function abortError(): Error {
} return new Error("aborted:alldebrid-pin-login");
return AbortSignal.any([signal, timeoutSignal]);
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw abortError();
}
}
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(ms);
return;
}
if (signal.aborted) {
throw abortError();
}
await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
signal.removeEventListener("abort", onAbort);
resolve();
}, Math.max(0, ms));
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal.removeEventListener("abort", onAbort);
reject(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
});
} }
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> { function asRecord(value: unknown): Record<string, unknown> | null {
if (!signal) { if (!value || typeof value !== "object" || Array.isArray(value)) {
return promise; return null;
} }
return new Promise<T>((resolve, reject) => { return value as Record<string, unknown>;
let settled = false; }
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 {
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;
signal.removeEventListener("abort", onAbort);
resolve();
}, Math.max(0, ms));
const onAbort = (): void => { const onAbort = (): void => {
if (settled) { if (timer) {
return; clearTimeout(timer);
timer = null;
} }
settled = true;
signal.removeEventListener("abort", onAbort); signal.removeEventListener("abort", onAbort);
reject(abortError()); reject(abortError());
}; };
signal.addEventListener("abort", onAbort, { once: true }); signal.addEventListener("abort", onAbort, { once: true });
promise.then((value) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
});
if (signal.aborted) {
onAbort();
}
}); });
} }
function asRecord(value: unknown): Record<string, unknown> | null { function apiError(payload: AllDebridApiPayload): Error {
if (!value || typeof value !== "object" || Array.isArray(value)) { const error = asRecord(payload.error);
return null; const code = stringValue(error, "code");
} const message = stringValue(error, "message");
return value as Record<string, unknown>; const detail = [code, message].filter(Boolean).join(": ");
} return new Error(detail ? `AllDebrid PIN-Login: ${detail}` : "AllDebrid PIN-Login fehlgeschlagen");
}
function pickString(payload: Record<string, unknown> | null, keys: string[]): string {
if (!payload) { async function requestPayload(url: string, init: RequestInit, signal: AbortSignal): Promise<AllDebridApiPayload> {
return ""; throwIfAborted(signal);
} const response = await fetch(url, {
for (const key of keys) { ...init,
const value = payload[key]; signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)])
if (typeof value === "string" && value.trim()) { });
return value.trim(); const text = await response.text();
} let payload: AllDebridApiPayload;
} try {
return ""; payload = JSON.parse(text) as AllDebridApiPayload;
} } catch {
throw new Error(`AllDebrid PIN-Login: ungültige API-Antwort (HTTP ${response.status})`);
function pickNumber(payload: Record<string, unknown> | null, keys: string[]): number | null {
if (!payload) {
return null;
}
for (const key of keys) {
const value = Number(payload[key] ?? NaN);
if (Number.isFinite(value) && value >= 0) {
return Math.floor(value);
}
}
return null;
}
function parseJson(text: string): Record<string, unknown> | null {
try {
return asRecord(JSON.parse(text) as unknown);
} catch {
return null;
}
}
function normalizeHostName(value: string): string {
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
}
function toHostStateFromIcon(url: string): AllDebridHostInfo["state"] {
const normalized = String(url || "").toLowerCase();
if (normalized.includes("up.gif")) {
return "up";
}
if (normalized.includes("down.gif")) {
return "down";
}
if (normalized.includes("not.tracked")) {
return "not_tracked";
}
return "unknown";
}
function toHostStatusLabel(state: AllDebridHostInfo["state"]): string {
if (state === "up") {
return "Verfügbar";
}
if (state === "down") {
return "Unverfügbar";
}
if (state === "not_tracked") {
return "Nicht getrackt";
}
return "Unbekannt";
}
function extractHostInfoFromStatusPage(html: string, host: string): AllDebridHostInfo | null {
const wanted = normalizeHostName(host);
const rowRegex = /<tr class=['"]g1['"]>\s*<td[^>]*>[\s\S]*?<i[^>]*alt=['"]([^'"]+)['"][^>]*>[\s\S]*?<\/td>\s*<td[^>]*class=['"]comparatif_content['"][^>]*>[\s\S]*?<img[^>]*src=['"]([^'"]+)['"][^>]*>[\s\S]*?\((?:<span[^>]*data-fdate=['"](\d+)['"][^>]*><\/span>|([^<)]*))\)/gi;
for (let match = rowRegex.exec(html); match; match = rowRegex.exec(html)) {
const hostAlt = normalizeHostName(match[1] || "");
if (hostAlt !== wanted) {
continue;
}
const state = toHostStateFromIcon(match[2] || "");
const lastCheckedSeconds = Number(match[3] ?? NaN);
return {
host,
source: "web",
state,
statusLabel: toHostStatusLabel(state),
fetchedAt: Date.now(),
lastCheckedAt: Number.isFinite(lastCheckedSeconds) ? lastCheckedSeconds * 1000 : null,
quota: null,
quotaMax: null,
quotaType: "",
limitSimuDl: null,
note: "Quota und Simultan-Slots sind per Web-Login nicht öffentlich verfügbar."
};
}
return null;
}
export class AllDebridWebFallback {
private queue: Promise<unknown> = Promise.resolve();
private loginWindow: BrowserWindow | null = null;
private loginWindowPartition = "";
private getRememberSession: () => boolean;
public constructor(getRememberSession: () => boolean) {
this.getRememberSession = getRememberSession;
}
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000);
return this.runExclusive(async () => {
throwIfAborted(overallSignal);
if (!String(link || "").trim()) {
return null;
}
const initial = await this.generate(link, overallSignal);
if (initial.kind === "success") {
return initial.value;
}
return this.waitForLoginAndGenerate(link, overallSignal);
}, overallSignal);
}
public async openLoginWindow(): Promise<void> {
const window = await this.ensureLoginWindow();
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
public async getHostInfo(host: string): Promise<AllDebridHostInfo> {
const currentSession = session.fromPartition(this.getPartition());
const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, {
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Referer: ALLDEBRID_SERVICE_REFERER,
"User-Agent": ALLDEBRID_USER_AGENT
},
signal: withTimeoutSignal(undefined, 30_000)
});
const text = await response.text();
if (!response.ok) {
throw new Error(`AllDebrid Web Status HTTP ${response.status}`);
}
if (!/id=['"]statusContainer['"]/i.test(text)) {
throw new Error("AllDebrid Web-Status nicht verfügbar. Bitte zuerst im AllDebrid-Fenster einloggen.");
}
const info = extractHostInfoFromStatusPage(text, host);
if (!info) {
throw new Error(`AllDebrid Web-Status für ${host} nicht gefunden`);
}
return info;
}
public async clearSessions(): Promise<void> {
this.disposeLoginWindow();
for (const partition of [ALLDEBRID_PERSISTENT_PARTITION, ALLDEBRID_TRANSIENT_PARTITION]) {
const currentSession = session.fromPartition(partition);
try {
await currentSession.clearStorageData({
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
});
} catch {
}
try {
await currentSession.clearCache();
} catch {
}
}
}
public dispose(): void {
this.disposeLoginWindow();
}
private getPartition(): string {
return this.getRememberSession() ? ALLDEBRID_PERSISTENT_PARTITION : ALLDEBRID_TRANSIENT_PARTITION;
}
private disposeLoginWindow(): void {
const current = this.loginWindow;
this.loginWindow = null;
this.loginWindowPartition = "";
if (current && !current.isDestroyed()) {
current.close();
}
}
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
const queueWaitTimeoutMs = 90_000;
const guardedJob = async (): Promise<T> => {
throwIfAborted(signal);
const waited = Date.now() - queuedAt;
if (waited > queueWaitTimeoutMs) {
throw new Error(`AllDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
}
return job();
};
const run = this.queue.then(guardedJob, guardedJob);
const result = raceWithAbort(run, signal);
this.queue = result.then(() => undefined, () => undefined);
return result;
} }
if (!response.ok || payload.status !== "success") {
private async ensureLoginWindow(): Promise<BrowserWindow> { throw apiError(payload);
const partition = this.getPartition(); }
const existing = this.loginWindow; return payload;
if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) { }
return existing;
} function parsePin(payload: AllDebridApiPayload): AllDebridPin {
const data = asRecord(payload.data);
if (existing && !existing.isDestroyed()) { const pin = stringValue(data, "pin");
existing.close(); const check = stringValue(data, "check");
} const expiresIn = positiveSeconds(data, "expires_in");
const userUrl = stringValue(data, "user_url");
const window = new BrowserWindow({ if (!pin || !check || !expiresIn || !userUrl) {
width: 1120, throw new Error("AllDebrid PIN-Login: unvollständige PIN-Antwort");
height: 900, }
minWidth: 980, const parsedUrl = new URL(userUrl);
minHeight: 760, if (parsedUrl.protocol !== "https:" || parsedUrl.hostname !== "alldebrid.com") {
autoHideMenuBar: true, throw new Error("AllDebrid PIN-Login: ungültige Benutzer-URL");
title: "AllDebrid Web-Login", }
return { pin, check, expiresIn, userUrl };
}
export class AllDebridWebFallback {
private loginWindow: BrowserWindow | null = null;
private loginController: AbortController | null = null;
private opening: Promise<void> | null = null;
private removeCallerAbortListener: (() => void) | null = null;
private readonly getRememberSession: () => boolean;
private readonly onAuthenticated: AllDebridPinLoginHandler;
private readonly onLoginFailed: AllDebridPinLoginErrorHandler;
public constructor(
getRememberSession: () => boolean,
onAuthenticated: AllDebridPinLoginHandler = () => {},
onLoginFailed: AllDebridPinLoginErrorHandler = () => {}
) {
this.getRememberSession = getRememberSession;
this.onAuthenticated = onAuthenticated;
this.onLoginFailed = onLoginFailed;
}
public async openLoginWindow(signal?: AbortSignal): Promise<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 opening = this.startPinLogin(signal);
this.opening = opening;
try {
await opening;
} finally {
if (this.opening === opening) {
this.opening = null;
}
}
}
public async clearSessions(): Promise<void> {
this.cancelLoginFlow(true);
for (const partition of [ALLDEBRID_PERSISTENT_PARTITION, ALLDEBRID_TRANSIENT_PARTITION]) {
const currentSession = session.fromPartition(partition);
try {
await currentSession.clearStorageData({
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
});
} catch {
}
try {
await currentSession.clearCache();
} catch {
}
}
}
public dispose(): void {
this.cancelLoginFlow(true);
}
private getPartition(): string {
return this.getRememberSession() ? ALLDEBRID_PERSISTENT_PARTITION : ALLDEBRID_TRANSIENT_PARTITION;
}
private showWindow(window: BrowserWindow): void {
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
private async startPinLogin(signal?: AbortSignal): Promise<void> {
this.cancelLoginFlow(true);
throwIfAborted(signal);
const controller = new AbortController();
this.loginController = controller;
if (signal) {
const onAbort = (): void => this.cancelLoginFlow(true);
signal.addEventListener("abort", onAbort, { once: true });
this.removeCallerAbortListener = () => signal.removeEventListener("abort", onAbort);
}
try {
const payload = await requestPayload(ALLDEBRID_PIN_GET_URL, { method: "GET" }, controller.signal);
const pin = parsePin(payload);
throwIfAborted(controller.signal);
const window = this.createLoginWindow(controller);
await window.loadURL(pin.userUrl);
throwIfAborted(controller.signal);
this.showWindow(window);
void this.pollForActivation(pin, controller.signal).then(
(result) => this.completeLogin(controller, result),
(error) => this.failLogin(controller, error)
);
} catch (error) {
if (this.loginController === controller) {
this.cancelLoginFlow(true);
}
throw error;
}
}
private createLoginWindow(controller: AbortController): BrowserWindow {
const partition = this.getPartition();
const window = new BrowserWindow({
width: 1120,
height: 900,
minWidth: 980,
minHeight: 760,
autoHideMenuBar: true,
title: "AllDebrid PIN-Login",
webPreferences: createRemoteLoginWebPreferences(partition) webPreferences: createRemoteLoginWebPreferences(partition)
}); });
applyRemoteLoginSecurity(window, { applyRemoteLoginSecurity(window, {
providerHosts: ALLDEBRID_LOGIN_HOSTS, providerHosts: ALLDEBRID_LOGIN_HOSTS,
externalHosts: ALLDEBRID_LOGIN_HOSTS externalHosts: ALLDEBRID_LOGIN_HOSTS
}); });
window.setMenuBarVisibility(false); window.setMenuBarVisibility(false);
window.on("closed", () => { window.on("closed", () => {
if (this.loginWindow === window) { if (this.loginWindow !== window) {
this.loginWindow = null; return;
this.loginWindowPartition = ""; }
} this.loginWindow = null;
}); if (this.loginController === controller) {
this.loginWindow = window; controller.abort();
this.loginWindowPartition = partition; this.loginController = null;
await window.loadURL(ALLDEBRID_LOGIN_URL); this.clearCallerAbortListener();
return window; }
} });
this.loginWindow = window;
private async postForm( return window;
url: string, }
body: URLSearchParams,
referer: string, private async pollForActivation(pin: AllDebridPin, signal: AbortSignal): Promise<AllDebridPinLoginResult> {
signal?: AbortSignal const deadline = Date.now() + pin.expiresIn * 1000;
): Promise<{ response: Response; text: string }> { while (Date.now() < deadline) {
const currentSession = session.fromPartition(this.getPartition()); throwIfAborted(signal);
const response = await currentSession.fetch(url, { const body = new URLSearchParams({
method: "POST", check: pin.check,
headers: { pin: pin.pin
Accept: "application/json, text/javascript, */*; q=0.01", });
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", const payload = await requestPayload(ALLDEBRID_PIN_CHECK_URL, {
Origin: ALLDEBRID_BASE_URL, method: "POST",
Referer: referer, headers: {
"User-Agent": ALLDEBRID_USER_AGENT, "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
"X-Requested-With": "XMLHttpRequest" },
}, body: body.toString()
body: body.toString(), }, signal);
signal: withTimeoutSignal(signal, 30_000) const data = asRecord(payload.data);
}); if (data?.activated === true) {
const text = await response.text(); const apiKey = stringValue(data, "apikey");
return { response, text }; if (!apiKey) {
} throw new Error("AllDebrid PIN-Login: aktivierte Antwort ohne API-Key");
}
private async generate(link: string, signal?: AbortSignal): Promise<GenerateOutcome> { return { apiKey };
throwIfAborted(signal); }
const body = new URLSearchParams({ const serverExpiresIn = positiveSeconds(data, "expires_in");
link, if (!serverExpiresIn) {
nb: "0", break;
json: "true", }
pw: "" const remainingMs = Math.max(0, deadline - Date.now());
}); if (!remainingMs) {
const { response, text } = await this.postForm(ALLDEBRID_SERVICE_URL, body, ALLDEBRID_SERVICE_REFERER, signal); break;
if (!response.ok) { }
throw new Error(`AllDebrid Web HTTP ${response.status}`); 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;
const payload = parseJson(trimmed); }
if (!payload) { try {
throw new Error("AllDebrid Web lieferte keine JSON-Antwort"); await this.onAuthenticated(result);
} } catch (error) {
this.failLogin(controller, error);
const errorText = pickString(payload, ["error"]); return;
if (errorText) { }
if (errorText.toLowerCase() === "premium") { if (this.loginController === controller) {
throw new Error("AllDebrid Web: Premium erforderlich"); this.cancelLoginFlow(true);
} }
throw new Error(`AllDebrid Web: ${errorText}`); }
}
private failLogin(controller: AbortController, error: unknown): void {
const directUrl = pickString(payload, ["link"]); if (this.loginController !== controller || controller.signal.aborted) {
const fileName = pickString(payload, ["filename"]); return;
const fileSize = pickNumber(payload, ["filesize"]); }
if (directUrl) { const normalized = error instanceof Error ? error : new Error(String(error));
return { this.cancelLoginFlow(true);
kind: "success", this.onLoginFailed(normalized);
value: { }
directUrl,
fileName: fileName || filenameFromUrl(directUrl) || filenameFromUrl(link), private clearCallerAbortListener(): void {
fileSize, this.removeCallerAbortListener?.();
retriesUsed: 0 this.removeCallerAbortListener = null;
} }
};
} private cancelLoginFlow(closeWindow: boolean): void {
const controller = this.loginController;
const delayedId = payload.delayed; this.loginController = null;
if (delayedId !== undefined && delayedId !== null && delayedId !== false && String(delayedId).trim()) { controller?.abort();
const delayed = await this.waitForDelayedLink(String(delayedId).trim(), signal); this.clearCallerAbortListener();
return { const window = this.loginWindow;
kind: "success", this.loginWindow = null;
value: { if (closeWindow && window && !window.isDestroyed()) {
directUrl: delayed.link, window.close();
fileName: fileName || filenameFromUrl(delayed.link) || filenameFromUrl(link), }
fileSize: fileSize, }
retriesUsed: 0 }
}
};
}
if (Array.isArray(payload.streams) && payload.streams.length > 0) {
throw new Error("AllDebrid Web: Streaming-Auswahl wird derzeit nicht unterstützt");
}
throw new Error("AllDebrid Web: Antwort ohne Download-Link");
}
private async waitForDelayedLink(delayedId: string, signal?: AbortSignal): Promise<DelayedStatusPayload> {
for (let attempt = 1; attempt <= 120; attempt += 1) {
throwIfAborted(signal);
const body = new URLSearchParams({ id: delayedId });
const { response, text } = await this.postForm(ALLDEBRID_DELAYED_URL, body, ALLDEBRID_SERVICE_REFERER, signal);
if (!response.ok) {
throw new Error(`AllDebrid Web delayed HTTP ${response.status}`);
}
const payload = parseJson(text.trim());
const data = asRecord(payload?.data);
if (pickString(payload, ["status"]).toLowerCase() !== "success" || !data) {
throw new Error("AllDebrid Web: Delayed-Status ungültig");
}
const status = Number(data.status ?? NaN);
if (!Number.isFinite(status)) {
throw new Error("AllDebrid Web: Delayed-Status ohne Status");
}
if (status >= 2) {
const link = pickString(data, ["link"]);
if (!link) {
throw new Error("AllDebrid Web: Delayed-Link fehlt");
}
return {
status,
link,
timeLeft: Math.max(0, Number(data.time_left ?? 0) || 0)
};
}
const timeLeft = Math.max(0, Number(data.time_left ?? 0) || 0);
const delayMs = timeLeft > 0 ? Math.min(5_000, Math.max(1_500, timeLeft * 250)) : 2_000;
await sleepWithSignal(delayMs, signal);
}
throw new Error("AllDebrid Web: Delayed-Link Timeout");
}
private async waitForLoginAndGenerate(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
const window = await this.ensureLoginWindow();
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
const startedAt = Date.now();
while (Date.now() - startedAt < 10 * 60 * 1000) {
throwIfAborted(signal);
if (window.isDestroyed()) {
throw new Error("AllDebrid Web-Login abgebrochen");
}
const outcome = await this.generate(link, signal);
if (outcome.kind === "success") {
if (!window.isDestroyed()) {
window.close();
}
return outcome.value;
}
await sleepWithSignal(1_500, signal);
}
throw new Error("AllDebrid Web-Login Timeout");
}
}
+46 -12
View File
@@ -36,7 +36,7 @@ import { importDlcContainers } from "./container";
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants"; import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
import { DownloadManager } from "./download-manager"; import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; 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 { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
@@ -192,11 +192,14 @@ export class AppController {
login: this.settings.megaLogin, login: this.settings.megaLogin,
password: this.settings.megaPassword 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.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
this.manager = new DownloadManager(this.settings, session, this.storagePaths, { 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), 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), realDebridWebUnrestrict: (accountId: string, link: string, signal?: AbortSignal) => this.unrestrictRealDebridWebAccount(accountId, link, signal),
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
@@ -575,6 +578,9 @@ export class AppController {
} }
public async executeAccountCommand(command: AccountCommand): Promise<AccountCommandResult> { 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); const applied = applyAccountCommand(this.settings, command);
let checkedStatus: DebridAccountStatus | null = null; let checkedStatus: DebridAccountStatus | null = null;
const redactions = collectAccountStatusRedactionValues(applied.settings, command); const redactions = collectAccountStatusRedactionValues(applied.settings, command);
@@ -594,6 +600,9 @@ export class AppController {
if (!account) throw new Error("Account-Payload ist ungültig"); if (!account) throw new Error("Account-Payload ist ungültig");
checkedStatus = await checkRealDebridAccount(account); checkedStatus = await checkRealDebridAccount(account);
} }
if (command.action !== "delete" && applied.response.accountId && command.kind === "alldebrid-api") {
checkedStatus = await checkAllDebridAccount(applied.settings.allDebridToken);
}
if (checkedStatus) { if (checkedStatus) {
checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions); checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions);
} }
@@ -662,6 +671,17 @@ export class AppController {
} }
return status; 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() const key = input.secret?.trim()
? parseDebridLinkApiKeys(input.secret)[0] ? parseDebridLinkApiKeys(input.secret)[0]
: parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).find((entry) => entry.id === input.accountId); : parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).find((entry) => entry.id === input.accountId);
@@ -883,10 +903,27 @@ export class AppController {
} }
} }
public async openAllDebridLoginWindow(): Promise<void> { public async openAllDebridLoginWindow(): Promise<void> {
this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
await this.allDebridWebFallback.openLoginWindow(); 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> { public async importBestDebridCookies(filePath: string): Promise<number> {
const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath); const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath);
@@ -897,11 +934,8 @@ export class AppController {
return imported; return imported;
} }
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> { public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
if (this.settings.allDebridUseWebLogin) { const token = this.settings.allDebridToken.trim();
return this.allDebridWebFallback.getHostInfo(host);
}
const token = this.settings.allDebridToken.trim();
if (!token) { if (!token) {
throw new Error("AllDebrid ist nicht konfiguriert"); throw new Error("AllDebrid ist nicht konfiguriert");
} }
+22 -34
View File
@@ -608,16 +608,14 @@ interface ProviderUnrestrictedLink extends UnrestrictedLink {
providerLabel: string; providerLabel: string;
} }
export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => Promise<UnrestrictedLink | null>; 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 RealDebridWebUnrestrictor = (accountId: string, link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>; export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
interface DebridServiceOptions { interface DebridServiceOptions {
megaWebUnrestrict?: MegaWebUnrestrictor; megaWebUnrestrict?: MegaWebUnrestrictor;
allDebridWebUnrestrict?: AllDebridWebUnrestrictor; realDebridWebUnrestrict?: RealDebridWebUnrestrictor;
realDebridWebUnrestrict?: RealDebridWebUnrestrictor; bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
} }
function cloneSettings(settings: AppSettings): AppSettings { function cloneSettings(settings: AppSettings): AppSettings {
@@ -1005,14 +1003,16 @@ function parseError(status: number, responseText: string, payload: Record<string
return `HTTP ${status}`; return `HTTP ${status}`;
} }
function parseAllDebridError(payload: Record<string, unknown> | null): string { function parseAllDebridError(payload: Record<string, unknown> | null): string {
const errorValue = payload?.error; const errorValue = payload?.error;
if (typeof errorValue === "string" && errorValue.trim()) { if (typeof errorValue === "string" && errorValue.trim()) {
return errorValue.trim(); return errorValue.trim();
} }
const errorObj = asRecord(errorValue); 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 { function normalizeAllDebridHostKey(value: string): string {
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase(); return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
@@ -4431,11 +4431,7 @@ export class DebridService {
&& !getRealDebridAccountCooldown(account.id, now)); && !getRealDebridAccountCooldown(account.id, now));
} }
private shouldUseAllDebridWeb(settings: AppSettings): boolean { private shouldUseBestDebridWeb(settings: AppSettings): boolean {
return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict);
}
private shouldUseBestDebridWeb(settings: AppSettings): boolean {
return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict); return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict);
} }
@@ -4663,8 +4659,8 @@ export class DebridService {
if (effectiveProvider === "megadebrid-web") { if (effectiveProvider === "megadebrid-web") {
return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "web") && this.options.megaWebUnrestrict); return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "web") && this.options.megaWebUnrestrict);
} }
if (effectiveProvider === "alldebrid") { if (effectiveProvider === "alldebrid") {
return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim()); return Boolean(settings.allDebridToken.trim());
} }
if (effectiveProvider === "ddownload") { if (effectiveProvider === "ddownload") {
return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim()); return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim());
@@ -4815,16 +4811,8 @@ export class DebridService {
if (effectiveProvider === "megadebrid-web") { if (effectiveProvider === "megadebrid-web") {
return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal); return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal);
} }
if (effectiveProvider === "alldebrid") { if (effectiveProvider === "alldebrid") {
if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) { const adResult = await new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal);
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"; adResult.sourceLabel = "API";
return adResult; return adResult;
} }
+70 -157
View File
@@ -65,7 +65,7 @@ function releaseTlsSkip(): void {
} }
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifactsFromScope, removeSampleArtifactsFromScope } from "./cleanup"; import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifactsFromScope, removeSampleArtifactsFromScope } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; 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 { classifyExtractionError, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error"; 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_HOST_INFO_TTL_MS = 60000;
const ALLDEBRID_START_STAGGER_MS = 3000;
const ARCHIVE_SETTLE_MIN_DELAY_MS = 1500; const ARCHIVE_SETTLE_MIN_DELAY_MS = 1500;
const ARCHIVE_SETTLE_POLL_MS = 250; const ARCHIVE_SETTLE_POLL_MS = 250;
@@ -545,9 +543,8 @@ function retryLimitToMaxRetries(retryLimit: number): number {
type HistoryEntryCallback = (entry: HistoryEntry) => void; type HistoryEntryCallback = (entry: HistoryEntry) => void;
type DownloadManagerOptions = { type DownloadManagerOptions = {
megaWebUnrestrict?: MegaWebUnrestrictor; megaWebUnrestrict?: MegaWebUnrestrictor;
allDebridWebUnrestrict?: AllDebridWebUnrestrictor; realDebridWebUnrestrict?: RealDebridWebUnrestrictor;
realDebridWebUnrestrict?: RealDebridWebUnrestrictor;
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
invalidateMegaSession?: () => void; invalidateMegaSession?: () => void;
onHistoryEntry?: HistoryEntryCallback; onHistoryEntry?: HistoryEntryCallback;
@@ -814,6 +811,35 @@ function isPermanentLinkError(errorText: string): boolean {
|| text.includes("file was deleted"); || 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 { function isUnrestrictFailure(errorText: string): boolean {
const text = String(errorText || "").toLowerCase(); const text = String(errorText || "").toLowerCase();
return text.includes("unrestrict") || text.includes("debrid-link") || text.includes("debrid_link_") 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 allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
private providerStartReservations = new Map<string, number>();
private pacedStartReservationByItem = new Map<string, number>();
private lastStaleResetAt = 0; private lastStaleResetAt = 0;
private onHistoryEntryCallback?: HistoryEntryCallback; private onHistoryEntryCallback?: HistoryEntryCallback;
@@ -2413,7 +2436,6 @@ export class DownloadManager extends EventEmitter {
} }
this.debridService = new DebridService(settings, { this.debridService = new DebridService(settings, {
megaWebUnrestrict: options.megaWebUnrestrict, megaWebUnrestrict: options.megaWebUnrestrict,
allDebridWebUnrestrict: options.allDebridWebUnrestrict,
realDebridWebUnrestrict: options.realDebridWebUnrestrict, realDebridWebUnrestrict: options.realDebridWebUnrestrict,
bestDebridWebUnrestrict: options.bestDebridWebUnrestrict bestDebridWebUnrestrict: options.bestDebridWebUnrestrict
}); });
@@ -3670,10 +3692,8 @@ export class DownloadManager extends EventEmitter {
clearTimeout(this.successDigestTimer); clearTimeout(this.successDigestTimer);
this.successDigestTimer = null; this.successDigestTimer = null;
} }
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.providerStartReservations.clear(); this.retryStateByItem.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
@@ -7244,8 +7264,6 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear(); this.retryStateByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
@@ -7361,8 +7379,6 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear(); this.retryStateByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
@@ -7489,8 +7505,6 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear(); this.retryStateByItem.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
@@ -7522,8 +7536,6 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear(); this.retryStateByItem.clear();
this.http416FreshRestartByItem.clear(); this.http416FreshRestartByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
@@ -7606,24 +7618,12 @@ export class DownloadManager extends EventEmitter {
this.session.reconnectUntil = 0; this.session.reconnectUntil = 0;
this.session.reconnectReason = ""; this.session.reconnectReason = "";
if (hasScopedRun) { if (hasScopedRun) {
const paceKeys = new Set<string>();
for (const itemId of stoppedItemIds) { 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.retryAfterByItem.delete(itemId);
this.pacedStartReservationByItem.delete(itemId);
this.retryStateByItem.delete(itemId); this.retryStateByItem.delete(itemId);
} }
for (const paceKey of paceKeys) {
if (this.countFuturePacedStarts(paceKey, nowMs()) <= 0) {
this.providerStartReservations.delete(paceKey);
}
}
} else { } else {
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear(); this.retryStateByItem.clear();
} }
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
@@ -7762,11 +7762,9 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear(); this.runPackageIds.clear();
this.runScopeKind = null; this.runScopeKind = null;
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.providerStartReservations.clear(); this.nonResumableActive = 0;
this.pacedStartReservationByItem.clear();
this.nonResumableActive = 0;
this.session.summaryText = ""; this.session.summaryText = "";
this.emitState(true); this.emitState(true);
logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`); logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`);
@@ -10178,7 +10176,7 @@ export class DownloadManager extends EventEmitter {
return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim()); return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim());
} }
if (effectiveProvider === "alldebrid") { if (effectiveProvider === "alldebrid") {
return Boolean(this.settings.allDebridUseWebLogin || this.settings.allDebridToken.trim()); return Boolean(this.settings.allDebridToken.trim());
} }
if (effectiveProvider === "ddownload") { if (effectiveProvider === "ddownload") {
return Boolean(this.settings.ddownloadLogin.trim() && this.settings.ddownloadPassword.trim()); return Boolean(this.settings.ddownloadLogin.trim() && this.settings.ddownloadPassword.trim());
@@ -10313,38 +10311,6 @@ export class DownloadManager extends EventEmitter {
return count; 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 { private getProviderValidatingTaskCount(provider: DebridProvider, excludeItemId?: string): number {
let count = 0; let count = 0;
for (const active of this.activeTasks.values()) { for (const active of this.activeTasks.values()) {
@@ -10391,76 +10357,6 @@ export class DownloadManager extends EventEmitter {
return Number.MAX_SAFE_INTEGER; 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 { private getConfiguredAllDebridStartLimit(): number {
const configured = Math.floor(Number(this.settings.maxParallel || 1)); const configured = Math.floor(Number(this.settings.maxParallel || 1));
if (Number.isFinite(configured) && configured > 0) { 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> { private async getAllDebridHostInfoCached(hosterKey: string, signal?: AbortSignal, forceRefresh = false): Promise<AllDebridHostInfo | null> {
const normalizedHost = String(hosterKey || "").trim().toLowerCase(); const normalizedHost = String(hosterKey || "").trim().toLowerCase();
if (!normalizedHost || this.settings.allDebridUseWebLogin) { if (!normalizedHost) {
return null; return null;
} }
const token = this.settings.allDebridToken.trim(); const token = this.settings.allDebridToken.trim();
@@ -10872,7 +10768,6 @@ export class DownloadManager extends EventEmitter {
if (retryAfter > now) continue; if (retryAfter > now) continue;
if (item.status !== "queued" && item.status !== "reconnect_wait") continue; if (item.status !== "queued" && item.status !== "reconnect_wait") continue;
if (this.activeTasks.has(itemId)) continue; if (this.activeTasks.has(itemId)) continue;
if (this.delayPacedStartForItem(item, now)) continue;
if (this.shouldDelayStartForItem(item)) continue; if (this.shouldDelayStartForItem(item)) continue;
const candidate = { packageId, itemId }; const candidate = { packageId, itemId };
@@ -11123,9 +11018,8 @@ export class DownloadManager extends EventEmitter {
phaseDeadlineAt: item.updatedAt + getUnrestrictTimeoutMs() + 15_000, phaseDeadlineAt: item.updatedAt + getUnrestrictTimeoutMs() + 15_000,
generation: this.lifecycleGeneration generation: this.lifecycleGeneration
}; };
this.activeTasks.set(itemId, active); this.activeTasks.set(itemId, active);
this.notePacedStartForItem(item, nowMs()); this.emitState();
this.emitState();
void this.processItem(active).catch((err) => { void this.processItem(active).catch((err) => {
logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`); logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`);
@@ -11282,11 +11176,12 @@ export class DownloadManager extends EventEmitter {
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
this.recordProviderFailure(cooldownProvider); this.recordProviderFailure(cooldownProvider);
throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`); throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`);
} }
const errText = compactErrorText(unrestrictError); const errText = compactErrorText(unrestrictError);
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { const terminalAllDebridError = classifyAllDebridTerminalUnrestrictError(errText, cooldownProvider);
this.recordProviderFailure(cooldownProvider); if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText) && !terminalAllDebridError) {
if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) { this.recordProviderFailure(cooldownProvider);
if (isProviderBusyUnrestrictError(errText) || isTemporaryUnrestrictError(errText)) {
const busyCooldownMs = isTemporaryUnrestrictError(errText) const busyCooldownMs = isTemporaryUnrestrictError(errText)
? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000) ? Math.min(180000, 20000 + Number(active.unrestrictRetries || 0) * 10000)
: Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); : Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000);
@@ -11814,6 +11709,27 @@ export class DownloadManager extends EventEmitter {
return; 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)) { if (isPermanentLinkError(errorText)) {
logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`); logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`);
item.status = "failed"; item.status = "failed";
@@ -15501,7 +15417,6 @@ export class DownloadManager extends EventEmitter {
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1); this.itemCount = Math.max(0, this.itemCount - 1);
this.retryAfterByItem.delete(itemId); this.retryAfterByItem.delete(itemId);
this.pacedStartReservationByItem.delete(itemId);
this.retryStateByItem.delete(itemId); this.retryStateByItem.delete(itemId);
if (pkg.itemIds.length === 0) { if (pkg.itemIds.length === 0) {
this.removePackageFromSession(packageId, []); this.removePackageFromSession(packageId, []);
@@ -15566,10 +15481,8 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear(); this.runPackageIds.clear();
this.runScopeKind = null; this.runScopeKind = null;
this.runOutcomes.clear(); this.runOutcomes.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.providerStartReservations.clear(); this.retryStateByItem.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
+7 -4
View File
@@ -33,7 +33,8 @@ function singleAccount(
provider: DebridProvider, provider: DebridProvider,
identity: string, identity: string,
maskedIdentity: string, maskedIdentity: string,
hasSecret: boolean hasSecret: boolean,
status: DebridAccountStatus | null = null
): RendererAccount { ): RendererAccount {
return { return {
accountId: `svc-${provider}`, accountId: `svc-${provider}`,
@@ -46,7 +47,7 @@ function singleAccount(
dailyLimitBytes: settings.providerDailyLimitBytes[provider] || 0, dailyLimitBytes: settings.providerDailyLimitBytes[provider] || 0,
dailyUsageBytes: settings.providerDailyUsageBytes[provider] || 0, dailyUsageBytes: settings.providerDailyUsageBytes[provider] || 0,
totalUsageBytes: settings.providerTotalUsageBytes[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()) { if (settings.allDebridUseWebLogin || settings.allDebridToken.trim()) {
const status = safeStatus(settings.debridAccountStatuses["svc-alldebrid"], redactions);
accounts.push(singleAccount( accounts.push(singleAccount(
settings, settings,
settings.allDebridUseWebLogin ? "alldebrid-web" : "alldebrid-api", settings.allDebridUseWebLogin ? "alldebrid-web" : "alldebrid-api",
"alldebrid", "alldebrid",
"", status?.username || "",
settings.allDebridUseWebLogin ? "Browser-Login" : maskValue(settings.allDebridToken), settings.allDebridUseWebLogin ? "Browser-Login" : maskValue(settings.allDebridToken),
true true,
status
)); ));
} }
if (settings.ddownloadLogin.trim() && settings.ddownloadPassword) { if (settings.ddownloadLogin.trim() && settings.ddownloadPassword) {
+16 -6
View File
@@ -291,9 +291,13 @@ function normalizeDebridAccountStatuses(
megaIds: string[], megaIds: string[],
debridLinkIds: string[], debridLinkIds: string[],
realDebridIds: string[], realDebridIds: string[],
legacyRealDebridTargetId: string | null legacyRealDebridTargetId: string | null,
allDebridConfigured: boolean
): Record<string, DebridAccountStatus> { ): Record<string, DebridAccountStatus> {
const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds]); const allowed = new Set([...megaIds, ...debridLinkIds, ...realDebridIds]);
if (allDebridConfigured) {
allowed.add("svc-alldebrid");
}
const result: Record<string, DebridAccountStatus> = {}; const result: Record<string, DebridAccountStatus> = {};
if (value && typeof value === "object" && !Array.isArray(value)) { if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [storedKey, raw] of Object.entries(value as Record<string, unknown>)) { for (const [storedKey, raw] of Object.entries(value as Record<string, unknown>)) {
@@ -304,15 +308,20 @@ function normalizeDebridAccountStatuses(
if (storedKey === "svc-realdebrid" && result[key]) { if (storedKey === "svc-realdebrid" && result[key]) {
continue; continue;
} }
const entry = raw as Partial<DebridAccountStatus>; const entry = raw as Partial<DebridAccountStatus>;
if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") { if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") {
continue; continue;
} }
if (key === "svc-alldebrid" && entry.provider !== "alldebrid") {
continue;
}
const provider = entry.provider === "debridlink" const provider = entry.provider === "debridlink"
? "debridlink" ? "debridlink"
: entry.provider === "realdebrid" : entry.provider === "realdebrid"
? "realdebrid" ? "realdebrid"
: "megadebrid"; : entry.provider === "alldebrid"
? "alldebrid"
: "megadebrid";
let username = typeof entry.username === "string" ? entry.username : undefined; let username = typeof entry.username === "string" ? entry.username : undefined;
let email = typeof entry.email === "string" ? entry.email : undefined; let email = typeof entry.email === "string" ? entry.email : undefined;
if (provider === "debridlink" && !username && email && !email.includes("@")) { if (provider === "debridlink" && !username && email && !email.includes("@")) {
@@ -542,8 +551,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
megaDebridPreferApi, megaDebridPreferApi,
bestToken: asText(settings.bestToken), bestToken: asText(settings.bestToken),
bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin), bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin),
allDebridToken: asText(settings.allDebridToken), allDebridToken: asText(settings.allDebridToken),
allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin), allDebridUseWebLogin: Boolean(settings.allDebridUseWebLogin && asText(settings.allDebridToken)),
ddownloadLogin: asText(settings.ddownloadLogin), ddownloadLogin: asText(settings.ddownloadLogin),
ddownloadPassword: asText(settings.ddownloadPassword), ddownloadPassword: asText(settings.ddownloadPassword),
oneFichierApiKey: asText(settings.oneFichierApiKey), oneFichierApiKey: asText(settings.oneFichierApiKey),
@@ -657,7 +666,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
megaDebridAccountIds, megaDebridAccountIds,
debridLinkApiKeyIds, debridLinkApiKeyIds,
realDebridAccountIds, realDebridAccountIds,
legacyRealDebridTargetId legacyRealDebridTargetId,
Boolean(asText(settings.allDebridToken))
), ),
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
dailyStartEnabled: settings.dailyStartEnabled !== undefined ? Boolean(settings.dailyStartEnabled) : defaults.dailyStartEnabled, dailyStartEnabled: settings.dailyStartEnabled !== undefined ? Boolean(settings.dailyStartEnabled) : defaults.dailyStartEnabled,
+23 -7
View File
@@ -465,7 +465,7 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
serviceLabel: "AllDebrid", serviceLabel: "AllDebrid",
title: "AllDebrid Web-Login", title: "AllDebrid Web-Login",
modeLabel: "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", kind: "ddownload-login",
@@ -565,7 +565,7 @@ function getAccountCredentialLabel(kind: AccountKind): string {
case "bestdebrid-web": case "bestdebrid-web":
case "realdebrid-web": case "realdebrid-web":
case "alldebrid-web": case "alldebrid-web":
return "Login gespeichert"; return "Geschützter API-Zugang";
case "realdebrid-api": case "realdebrid-api":
case "bestdebrid-api": case "bestdebrid-api":
case "alldebrid-api": case "alldebrid-api":
@@ -2576,7 +2576,7 @@ export function App(): ReactElement {
}); });
} }
} else { } else {
const serviceAccountId = null; const serviceAccountId = entry.service === "alldebrid" ? "svc-alldebrid" : null;
rows.push({ rows.push({
rowKey: `svc-${entry.service}`, rowKey: `svc-${entry.service}`,
entry, entry,
@@ -2990,6 +2990,12 @@ export function App(): ReactElement {
showToast("Real-Debrid Login-Fenster geöffnet", 2200); showToast("Real-Debrid Login-Fenster geöffnet", 2200);
return; return;
} }
if (dialogSnapshot.kind === "alldebrid-web") {
await window.rd.openAllDebridLogin();
closeAccountDialog();
showToast("AllDebrid PIN-Login geöffnet", 2200);
return;
}
const command = buildAccountCreateCommand(dialogSnapshot); const command = buildAccountCreateCommand(dialogSnapshot);
if (!command) throw new Error("Account-Payload ist ungültig"); if (!command) throw new Error("Account-Payload ist ungültig");
const result = await window.rd.createAccount(command); const result = await window.rd.createAccount(command);
@@ -3153,7 +3159,15 @@ export function App(): ReactElement {
...settingsDraft, ...settingsDraft,
disabledProviders: nextDisabledProviders 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( showToast(
nextDisabledProviders.includes(provider) nextDisabledProviders.includes(provider)
? `${entry.serviceLabel} deaktiviert` ? `${entry.serviceLabel} deaktiviert`
@@ -3246,7 +3260,8 @@ export function App(): ReactElement {
const checkAccountTableRow = (row: AccountTableRow): void => { const checkAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null); 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 kind = row.entry.kind;
const accountId = row.accountId; const accountId = row.accountId;
void performQuickAction(async () => { void performQuickAction(async () => {
@@ -5725,9 +5740,10 @@ export function App(): ReactElement {
const editSnapshot = accountEditDialog; const editSnapshot = accountEditDialog;
void performQuickAction(async () => { void performQuickAction(async () => {
if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink" 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 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({ const status = await window.rd.checkAccountCredentials({
kind, kind,
accountId: editSnapshot.target.type === "mega" accountId: editSnapshot.target.type === "mega"
+1 -1
View File
@@ -431,7 +431,7 @@ export interface AccountCommandResult {
} }
export interface AccountCredentialCheckInput { 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; accountId?: string;
identity?: string; identity?: string;
secret?: string; secret?: string;
+71 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest"; 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 type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
import { getDebridLinkApiKeyId, type DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys"; import { getDebridLinkApiKeyId, type DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
import type { AppSettings } from "../src/shared/types"; import type { AppSettings } from "../src/shared/types";
@@ -179,8 +179,78 @@ describe("checkRealDebridAccount", () => {
}); });
}); });
}); });
describe("checkAllDebridAccount", () => {
it("reads identity and premium expiry from the official user endpoint", async () => {
const premiumUntilSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
mockFetchOnce(200, {
status: "success",
data: {
user: {
username: "all-user",
email: "all-user@example.test",
isPremium: true,
premiumUntil: premiumUntilSec
}
}
});
const status = await checkAllDebridAccount("all-api-key", undefined, NOW);
expect(status).toMatchObject({
accountId: ALL_DEBRID_STATUS_ID,
provider: "alldebrid",
valid: true,
isPremium: true,
premiumUntilMs: premiumUntilSec * 1000,
username: "all-user",
email: "all-user@example.test"
});
expect(fetch).toHaveBeenCalledWith("https://api.alldebrid.com/v4/user", expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer all-api-key" })
}));
});
it("reports authentication errors without accepting the API key", async () => {
mockFetchOnce(401, {
status: "error",
error: { code: "AUTH_BAD_APIKEY", message: "The auth apikey is invalid" }
});
const status = await checkAllDebridAccount("bad-key", undefined, NOW);
expect(status).toMatchObject({
accountId: ALL_DEBRID_STATUS_ID,
provider: "alldebrid",
valid: false,
isPremium: false,
message: "Ungültiger API-Key"
});
});
});
describe("checkAllDebridAccounts", () => { 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", () => { it("discards a late Real-Debrid result after its account was removed", () => {
const removedId = "rda_removedAfterCheck"; 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 }; 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); 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) => { it.each([
expect(validateAccountCredentialCheckInput({ kind, accountId: "svc-realdebrid" })).toEqual({ ["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, kind,
accountId: "svc-realdebrid", accountId,
identity: undefined, identity: undefined,
secret: undefined secret: undefined
}); });
@@ -265,6 +270,26 @@ describe("write-only account commands", () => {
expect(JSON.stringify(replaced.response)).not.toContain(secret); 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", () => { it("replaces a Mega-Debrid account while preserving sibling accounts and mode-specific state", () => {
const firstId = getMegaDebridAccountId("first@example.test"); const firstId = getMegaDebridAccountId("first@example.test");
const oldId = getMegaDebridAccountId("second@example.test"); const oldId = getMegaDebridAccountId("second@example.test");
+146 -126
View File
@@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { const {
mockFromPartition, mockFromPartition,
mockSession, mockSession,
mockFetch,
mockBrowserWindowCtor, mockBrowserWindowCtor,
mockLoadURL, mockLoadURL,
mockShow, mockShow,
@@ -12,7 +11,6 @@ const {
mockSetWindowOpenHandler, mockSetWindowOpenHandler,
mockSetPermissionRequestHandler mockSetPermissionRequestHandler
} = vi.hoisted(() => { } = vi.hoisted(() => {
const fetch = vi.fn();
const clearStorageData = vi.fn(); const clearStorageData = vi.fn();
const clearCache = vi.fn(); const clearCache = vi.fn();
const fromPartition = vi.fn(); const fromPartition = vi.fn();
@@ -31,6 +29,9 @@ const {
show, show,
focus, focus,
close: vi.fn(() => { close: vi.fn(() => {
if (destroyed) {
return;
}
destroyed = true; destroyed = true;
windowEvents.closed?.(); windowEvents.closed?.();
}), }),
@@ -57,11 +58,9 @@ const {
return { return {
mockFromPartition: fromPartition, mockFromPartition: fromPartition,
mockSession: { mockSession: {
fetch,
clearStorageData, clearStorageData,
clearCache clearCache
}, },
mockFetch: fetch,
mockBrowserWindowCtor: BrowserWindowCtor, mockBrowserWindowCtor: BrowserWindowCtor,
mockLoadURL: loadURL, mockLoadURL: loadURL,
mockShow: show, mockShow: show,
@@ -84,21 +83,59 @@ vi.mock("electron", () => ({
import { AllDebridWebFallback } from "../src/main/all-debrid-web"; 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(() => { beforeEach(() => {
mockFromPartition.mockReturnValue(mockSession); mockFromPartition.mockReturnValue(mockSession);
vi.stubGlobal("fetch", vi.fn());
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks(); vi.clearAllMocks();
mockFromPartition.mockReturnValue(mockSession); mockFromPartition.mockReturnValue(mockSession);
}); });
it("opens the AllDebrid login window with the shared restrictive browser boundary", async () => { it("opens the official PIN URL and reports the API key after activation", async () => {
const fallback = new AllDebridWebFallback(() => true); 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(); 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).toHaveBeenCalledTimes(1);
expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
webPreferences: { webPreferences: {
@@ -112,128 +149,111 @@ describe("alldebrid-web", () => {
})); }));
expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1); expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1);
expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1); expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1);
expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/register/?from=de"); expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/pin/?pin=ABCD");
expect(mockShow).toHaveBeenCalled(); expect(mockShow).toHaveBeenCalledTimes(1);
expect(mockFocus).toHaveBeenCalled(); expect(mockFocus).toHaveBeenCalledTimes(1);
}); expect(authenticated).not.toHaveBeenCalled();
it("uses an existing AllDebrid Web session to unrestrict without opening a login window", async () => { await vi.advanceTimersByTimeAsync(5_000);
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ await vi.waitFor(() => expect(authenticated).toHaveBeenCalledWith({ apiKey: "all-debrid-api-key" }));
link: "https://alldebrid.direct/session-file.bin",
filename: "session-file.bin",
filesize: 9876
}), { status: 200 }));
const fallback = new AllDebridWebFallback(() => true);
const result = await fallback.unrestrict("https://rapidgator.net/file/session"); expect(fetchMock.mock.calls[1]).toEqual([
"https://api.alldebrid.com/v4/pin/check",
expect(result).toEqual({ expect.objectContaining({
directUrl: "https://alldebrid.direct/session-file.bin", method: "POST",
fileName: "session-file.bin", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
fileSize: 9876, body: "check=check-token&pin=ABCD"
retriesUsed: 0 })
});
expect(mockBrowserWindowCtor).not.toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://alldebrid.com/service.php");
expect(mockFetch.mock.calls[0]?.[1]).toEqual(expect.objectContaining({
method: "POST",
body: "link=https%3A%2F%2Frapidgator.net%2Ffile%2Fsession&nb=0&json=true&pw="
}));
});
it("releases an aborted caller while the active web request ignores its signal", async () => {
let rejectRequest!: (error: Error) => void;
mockFetch
.mockReturnValueOnce(new Promise<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))
]); ]);
expect(authenticated).toHaveBeenCalledTimes(1);
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(mockClose).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; const controller = Object.create(AppController.prototype) as any;
controller.settings = settings; controller.settings = settings;
controller.storagePaths = createStoragePaths(dir); controller.storagePaths = createStoragePaths(dir);
controller.manager = { setSettings: vi.fn() }; controller.manager = { setSettings: vi.fn(), applyDebridAccountStatuses: vi.fn() };
controller.audit = vi.fn(); controller.audit = vi.fn();
controller.overlayLiveUsageCounters = vi.fn(); controller.overlayLiveUsageCounters = vi.fn();
controller.pruneRealDebridWebFallbacks = vi.fn(); controller.pruneRealDebridWebFallbacks = vi.fn();
@@ -93,3 +93,81 @@ describe("AppController daily start settings", () => {
expect(controller.getSettings().scheduledStartEpochMs).toBe(scheduledStartEpochMs); 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()"); 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", () => { 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 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"); const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
+54 -24
View File
@@ -1045,7 +1045,7 @@ describe("debrid service", () => {
expect(calls).toBe(1); expect(calls).toBe(1);
}); });
it("does not retry AllDebrid auth failures (403)", async () => { it("does not retry AllDebrid auth failures (403)", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
allDebridToken: "ad-token", allDebridToken: "ad-token",
@@ -1071,8 +1071,32 @@ describe("debrid service", () => {
const service = new DebridService(settings); const service = new DebridService(settings);
await expect(service.unrestrictLink("https://hoster.example/file/no-retry-ad")).rejects.toThrow(); await expect(service.unrestrictLink("https://hoster.example/file/no-retry-ad")).rejects.toThrow();
expect(calls).toBe(1); expect(calls).toBe(1);
}); });
it("preserves the official AllDebrid error code alongside its message", async () => {
const settings = {
...defaultSettings(),
allDebridToken: "ad-token",
providerOrder: [] as const,
providerPrimary: "alldebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response(JSON.stringify({
status: "error",
error: {
code: "NO_SERVER",
message: "Servers are not allowed on this endpoint"
}
}), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch;
const service = new DebridService(settings);
await expect(service.unrestrictLink("https://rapidgator.net/file/no-server"))
.rejects.toThrow(/NO_SERVER: Servers are not allowed/);
});
it("supports AllDebrid unlock", async () => { it("supports AllDebrid unlock", async () => {
const settings = { const settings = {
@@ -1321,7 +1345,7 @@ describe("debrid service", () => {
expect(info[0].hostStateLabel).toBe("Offline"); 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 = { const settings = {
...defaultSettings(), ...defaultSettings(),
allDebridToken: "ad-token", allDebridToken: "ad-token",
@@ -1333,26 +1357,32 @@ describe("debrid service", () => {
autoProviderFallback: false autoProviderFallback: false
}; };
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 })); const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
globalThis.fetch = fetchSpy as unknown as typeof fetch; const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("api.alldebrid.com/v4/link/unlock")) {
const allDebridWeb = vi.fn(async () => ({ return new Response(JSON.stringify({
fileName: "from-web.rar", status: "success",
directUrl: "https://df4ea4.debrid.it/dl/example/from-web.rar", data: {
fileSize: 1234, link: "https://df4ea4.debrid.it/dl/example/from-api.rar",
retriesUsed: 0 filename: "from-api.rar",
})); filesize: 1234
}
const service = new DebridService(settings, { allDebridWebUnrestrict: allDebridWeb }); }), { status: 200, headers: { "Content-Type": "application/json" } });
const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html"); }
expect(result.provider).toBe("alldebrid"); return new Response("not-found", { status: 404 });
expect(result.directUrl).toContain("debrid.it/dl/"); });
expect(result.fileSize).toBe(1234); globalThis.fetch = fetchSpy as unknown as typeof fetch;
expect(allDebridWeb).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledTimes(0); const service = new DebridService(settings);
}); const result = await service.unrestrictLink("https://rapidgator.net/file/example.part4.rar.html");
expect(result.provider).toBe("alldebrid");
it("treats AllDebrid web mode as not configured when callback is unavailable", async () => { expect(result.directUrl).toContain("debrid.it/dl/");
expect(result.fileSize).toBe(1234);
expect(result.sourceLabel).toBe("API");
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("treats AllDebrid browser authorization without an API key as not configured", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
allDebridToken: "", allDebridToken: "",
+178 -72
View File
@@ -140,8 +140,6 @@ describe("selected item run scope", () => {
internal.retryAfterByItem.set(itemIds[1], 200); internal.retryAfterByItem.set(itemIds[1], 200);
internal.retryStateByItem.set(itemIds[0], { freshRetryUsed: true, resumeHardResetUsed: false }); internal.retryStateByItem.set(itemIds[0], { freshRetryUsed: true, resumeHardResetUsed: false });
internal.retryStateByItem.set(itemIds[1], { freshRetryUsed: false, resumeHardResetUsed: true }); 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"); internal.standalonePackageResults.add("foreign-package:1");
manager.stop(); manager.stop();
@@ -152,8 +150,6 @@ describe("selected item run scope", () => {
expect(internal.retryAfterByItem.get(itemIds[1])).toBe(200); expect(internal.retryAfterByItem.get(itemIds[1])).toBe(200);
expect(internal.retryStateByItem.has(itemIds[0])).toBe(false); expect(internal.retryStateByItem.has(itemIds[0])).toBe(false);
expect(internal.retryStateByItem.has(itemIds[1])).toBe(true); 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.standalonePackageResults.has("foreign-package:1")).toBe(true);
expect(internal.suppressedPackageResults.has("foreign-package:1")).toBe(false); 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); 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-cooldowns-"));
tempDirs.push(root); tempDirs.push(root);
const { manager, itemIds } = createSelectedItemManager(root); const { manager, itemIds } = createSelectedItemManager(root);
@@ -192,15 +188,11 @@ describe("selected item run scope", () => {
internal.session.paused = true; internal.session.paused = true;
const now = Date.now(); const now = Date.now();
internal.retryAfterByItem.set(itemIds[0], now + 11_000); 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 }); internal.providerFailures.set("provider-key", { count: 1, lastFailAt: now, cooldownUntil: now + 14_000 });
manager.togglePause(); manager.togglePause();
expect(internal.retryAfterByItem.get(itemIds[0])).toBe(now + 11_000); 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.providerFailures.get("provider-key")?.cooldownUntil).toBe(now + 14_000);
expect(internal.findNextQueuedItem()).toBeNull(); 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> { async function removeDirWithRetries(dir: string): Promise<void> {
let lastError: unknown = null; let lastError: unknown = null;
for (let attempt = 1; attempt <= 5; attempt += 1) { 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
const binary = Buffer.alloc(2 * 1024 * 1024, 6); const binary = Buffer.alloc(2 * 1024 * 1024, 6);
@@ -9447,7 +9575,7 @@ describe("download manager", () => {
} }
}, 35000); }, 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
const chunk = Buffer.alloc(256 * 1024, 9); 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`; const directUrl3 = `http://127.0.0.1:${address.port}/ad-web-3`;
try { 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( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
allDebridToken: "ad-token", allDebridToken: "ad-token",
allDebridUseWebLogin: true,
providerOrder: [], providerOrder: [],
providerPrimary: "alldebrid", providerPrimary: "alldebrid",
providerSecondary: "none", providerSecondary: "none",
@@ -9510,15 +9642,7 @@ describe("download manager", () => {
maxParallel: 3 maxParallel: 3
}, },
emptySession(), emptySession(),
createStoragePaths(path.join(root, "state")), 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
})
}
); );
manager.addPackages([{ name: "ad-web-parallel", links: [link1, link2, link3] }]); manager.addPackages([{ name: "ad-web-parallel", links: [link1, link2, link3] }]);
@@ -10285,7 +10409,7 @@ describe("download manager", () => {
await new Promise((resolve) => setTimeout(resolve, 150)); 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
const chunk = Buffer.alloc(256 * 1024, 9); 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`); const links = Array.from({ length: totalLinks }, (_, index) => `https://rapidgator.net/file/web-${index + 1}/sample.part${index + 1}.rar.html`);
try { 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( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
allDebridToken: "ad-token", allDebridToken: "ad-token",
allDebridUseWebLogin: true,
providerOrder: [], providerOrder: [],
providerPrimary: "alldebrid", providerPrimary: "alldebrid",
providerSecondary: "none", providerSecondary: "none",
@@ -10344,19 +10476,7 @@ describe("download manager", () => {
maxParallel: 5 maxParallel: 5
}, },
emptySession(), emptySession(),
createStoragePaths(path.join(root, "state")), 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
};
}
}
); );
manager.addPackages([{ name: "ad-web-visibility", links }]); manager.addPackages([{ name: "ad-web-visibility", links }]);
@@ -10364,18 +10484,15 @@ describe("download manager", () => {
await waitFor(() => { await waitFor(() => {
const items = Object.values(manager.getSnapshot().session.items); const items = Object.values(manager.getSnapshot().session.items);
const countdownItems = items.filter((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || "")); return items.filter((item) => item.status === "downloading" || item.status === "validating").length === 5;
return countdownItems.length === 5;
}, 10000); }, 10000);
const items = Object.values(manager.getSnapshot().session.items); const items = Object.values(manager.getSnapshot().session.items);
const activeCount = items.filter((item) => item.status === "downloading" || item.status === "validating").length; 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 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(activeCount).toBe(5);
expect(countdownItems.length).toBe(5); expect(countdownItems).toHaveLength(0);
expect(uniqueCountdowns.size).toBe(1);
manager.stop(); manager.stop();
await waitFor(() => !manager.getSnapshot().session.running, 15000); await waitFor(() => !manager.getSnapshot().session.running, 15000);
@@ -10385,7 +10502,7 @@ describe("download manager", () => {
} }
}, 20000); }, 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
const binary = Buffer.alloc(512 * 1024, 5); const binary = Buffer.alloc(512 * 1024, 5);
@@ -10469,19 +10586,11 @@ describe("download manager", () => {
manager.addPackages([{ name: "ad-paced", links: [link1, link2, link3] }]); manager.addPackages([{ name: "ad-paced", links: [link1, link2, link3] }]);
await manager.start(); await manager.start();
const managerInternals = manager as unknown as { await waitFor(() => {
retryAfterByItem: Map<string, number>; const items = Object.values(manager.getSnapshot().session.items);
}; return items.filter((item) => item.status === "downloading" || item.status === "validating").length === 3;
await waitFor(() => managerInternals.retryAfterByItem.size >= 3, 5000); }, 1000);
expect(Object.values(manager.getSnapshot().session.items).some((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || ""))).toBe(false);
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);
manager.stop(); manager.stop();
await waitFor(() => !manager.getSnapshot().session.running, 15000); await waitFor(() => !manager.getSnapshot().session.running, 15000);
@@ -10491,7 +10600,7 @@ describe("download manager", () => {
} }
}, 20000); }, 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
const shortBinary = Buffer.alloc(64 * 1024, 7); const shortBinary = Buffer.alloc(64 * 1024, 7);
@@ -10537,11 +10646,18 @@ describe("download manager", () => {
]; ];
try { 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( const manager = new DownloadManager(
{ {
...defaultSettings(), ...defaultSettings(),
allDebridToken: "ad-token", allDebridToken: "ad-token",
allDebridUseWebLogin: true,
providerOrder: [], providerOrder: [],
providerPrimary: "alldebrid", providerPrimary: "alldebrid",
providerSecondary: "none", providerSecondary: "none",
@@ -10554,18 +10670,7 @@ describe("download manager", () => {
maxParallel: 3 maxParallel: 3
}, },
emptySession(), emptySession(),
createStoragePaths(path.join(root, "state")), 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
};
}
}
); );
manager.addPackages([{ name: "ad-topup", links }]); manager.addPackages([{ name: "ad-topup", links }]);
@@ -10579,9 +10684,10 @@ describe("download manager", () => {
await waitFor(() => { await waitFor(() => {
const items = Object.values(manager.getSnapshot().session.items); const items = Object.values(manager.getSnapshot().session.items);
const completedCount = items.filter((item) => item.status === "completed").length; const completedCount = items.filter((item) => item.status === "completed").length;
const countdownItems = items.filter((item) => /^AllDebrid Start in [123]s$/.test(item.fullStatus || "")); const downloadingCount = items.filter((item) => item.status === "downloading").length;
return completedCount >= 1 && countdownItems.length === 1; return completedCount >= 1 && downloadingCount === 3;
}, 12000); }, 12000);
expect(Object.values(manager.getSnapshot().session.items).some((item) => /^AllDebrid Start in \d+s$/.test(item.fullStatus || ""))).toBe(false);
manager.stop(); manager.stop();
await waitFor(() => !manager.getSnapshot().session.running, 15000); 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 { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
import type { AppSettings, RendererAccountKind } from "../src/shared/types"; import type { AppSettings, RendererAccountKind } from "../src/shared/types";
const NOW = 1_700_000_000_000;
const SECRETS = { const SECRETS = {
token: "fixture-rd-token-7vQ2", token: "fixture-rd-token-7vQ2",
megaPassword: "fixture-mega-password-8kM3", megaPassword: "fixture-mega-password-8kM3",
@@ -81,6 +83,36 @@ describe("renderer state serialization", () => {
expect(JSON.stringify(state)).not.toContain(secondToken); expect(JSON.stringify(state)).not.toContain(secondToken);
expect(realDebridRows[0].accountId).not.toBe(`rda_${crypto.createHash("sha256").update(firstToken).digest("hex").slice(0, 32)}`); 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 }) => { it.each(ACCOUNT_FIXTURES)("serializes $kind without its representative secret", ({ kind, secret, settings }) => {
const state = createRendererState({ ...defaultSettings(), ...settings }); const state = createRendererState({ ...defaultSettings(), ...settings });
+46 -10
View File
@@ -1007,16 +1007,23 @@ describe("settings storage", () => {
expect(normalized.debridAccountStatuses[key.id].email).toBeUndefined(); 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); expect(defaultSettings().allDebridUseWebLogin).toBe(false);
const normalizedEnabled = normalizeSettings({ const legacyWebOnly = normalizeSettings({
...defaultSettings(), ...defaultSettings(),
allDebridUseWebLogin: 1 as unknown as boolean allDebridUseWebLogin: 1 as unknown as boolean
}); });
expect(normalizedEnabled.allDebridUseWebLogin).toBe(true); expect(legacyWebOnly.allDebridUseWebLogin).toBe(false);
const normalizedDisabled = normalizeSettings({ const authorizedWeb = normalizeSettings({
...defaultSettings(),
allDebridToken: "fixture-pin-issued-key",
allDebridUseWebLogin: 1 as unknown as boolean
});
expect(authorizedWeb.allDebridUseWebLogin).toBe(true);
const normalizedDisabled = normalizeSettings({
...defaultSettings(), ...defaultSettings(),
allDebridUseWebLogin: 0 as unknown as boolean allDebridUseWebLogin: 0 as unknown as boolean
}); });
@@ -1034,6 +1041,35 @@ describe("settings storage", () => {
expect(normalized.historyRetentionMode).toBe("permanent"); 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", () => { it("loads legacy history without inventing structured durations", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir); tempDirs.push(dir);