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