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