real-debrid-downloader/src/main/debrid.ts
Sucukdeluxe 6a90eb500e v1.7.155 Mega.nz Filename-Pre-Resolve via Public API
UX: Beim Hinzufuegen von mega.nz Links wurden bisher nur die opaken
URL-Fragmente angezeigt ("pZl1wBRQ" etc.). Echte Filenames kamen erst
beim Mega-Debrid Unrestrict-Call, d.h. unmittelbar vor Download-Start.

Fix: Neuer src/main/mega-public-api.ts holt Filename + Groesse direkt
von Mega's Public API (g.api.mega.co.nz/cs) ohne Mega-Debrid-Quota
anzufassen. AES-128-CBC Decryption der Attribute mit dem Key aus
dem URL-Fragment.

resolveFilenames (debrid.ts) ruft den neuen Resolver fuer alle
erkannten mega.nz Links auf (concurrency 4). Auf Fehler/Rate-Limit
fallback auf den bestehenden Unrestrict-Pfad.

19 neue Tests fuer URL-Parser, AES-Decryption, Mocked-Fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 01:00:08 +02:00

3924 lines
148 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { logger } from "./logger";
import { logAccountRotation } from "./account-rotation-log";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
const API_TIMEOUT_MS = 30000;
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4";
const ALL_DEBRID_API_BASE_V41 = "https://api.alldebrid.com/v4.1";
const MEGA_DEBRID_API_BASE = "https://www.mega-debrid.eu/api.php";
const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
/** Truly key-wide quota errors: the whole key is exhausted regardless of host. */
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
/** Per-(key, host) quota errors: only this host is exhausted for this key — the
* key remains usable for other hosters. */
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
/** Backward-compat union — includes BOTH key-wide and per-host quota codes.
* Use this only for "is it a quota error of any kind?" checks; for behavior
* branches use the more specific sets above. */
const DEBRID_LINK_QUOTA_ERRORS = new Set([...DEBRID_LINK_KEY_QUOTA_ERRORS, ...DEBRID_LINK_HOST_QUOTA_ERRORS]);
const DEBRID_LINK_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]);
const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]);
const DEBRID_LINK_RETRYABLE_ERRORS = new Set(["internalError", "server_error"]);
const DEBRID_LINK_PROVIDER_WIDE_ERRORS = new Set(["notDebrid"]);
/** Errors where the key can't handle this link — skip to next key immediately, no retries */
const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([
"disabledServerHost",
"notFreeHost",
"serverNotAllowed",
"freeServerOverload",
"maintenanceHost",
"noServerHost",
"fileNotAvailable"
]);
const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "badFilePassword", "fileNotFound", "hostNotValid"]);
/** Per-key cooldown cache: keyId → expiry timestamp. Parallel items skip keys that recently failed. */
const debridLinkKeyCooldowns = new Map<string, number>();
type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory };
type DebridLinkRuntimeState = DebridLinkHostLimitInfo["state"];
type DebridLinkRuntimeStatus = {
state: DebridLinkRuntimeState;
detail: string;
updatedAt: number;
};
const debridLinkKeyCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
const debridLinkKeyRuntimeStatuses = new Map<string, DebridLinkRuntimeStatus>();
const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000; // 2 min cooldown per failed key
const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000;
const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000;
export function resetDebridLinkRuntimeStateForTests(): void {
debridLinkKeyCooldowns.clear();
debridLinkKeyCooldownDetails.clear();
debridLinkKeyRuntimeStatuses.clear();
debridLinkKeyHostCooldowns.clear();
debridLinkKeyHostCooldownDetails.clear();
}
/** Drop all Debrid-Link cooldown/runtime entries for key IDs that are no
* longer in the active key set. Called when settings change so removed
* keys don't keep blocking the system if they're re-added later. */
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
for (const keyId of debridLinkKeyCooldowns.keys()) {
if (!activeKeyIds.has(keyId)) {
debridLinkKeyCooldowns.delete(keyId);
debridLinkKeyCooldownDetails.delete(keyId);
}
}
for (const keyId of debridLinkKeyRuntimeStatuses.keys()) {
if (!activeKeyIds.has(keyId)) {
debridLinkKeyRuntimeStatuses.delete(keyId);
}
}
// Per-(key, host) cooldown keys have format `${keyId}|${hoster}` — drop any
// whose keyId is no longer in the active set so removed keys don't keep
// memory state around if they're re-added later.
for (const stateKey of debridLinkKeyHostCooldowns.keys()) {
const sepIdx = stateKey.indexOf("|");
const keyId = sepIdx >= 0 ? stateKey.slice(0, sepIdx) : stateKey;
if (!activeKeyIds.has(keyId)) {
debridLinkKeyHostCooldowns.delete(stateKey);
debridLinkKeyHostCooldownDetails.delete(stateKey);
}
}
}
/** Periodic cleanup of expired Debrid-Link cooldown/runtime entries.
* Without this, module-level Maps grow unbounded over 24/7 operation.
* Removes entries whose cooldown expired more than 1 hour ago. */
export function pruneExpiredDebridLinkRuntimeState(now = Date.now()): number {
let removed = 0;
const grace = 60 * 60 * 1000; // keep 1h grace for debugging
for (const [keyId, until] of debridLinkKeyCooldowns) {
if (until + grace < now) {
debridLinkKeyCooldowns.delete(keyId);
debridLinkKeyCooldownDetails.delete(keyId);
removed += 1;
}
}
for (const [keyId, status] of debridLinkKeyRuntimeStatuses) {
if (!debridLinkKeyCooldowns.has(keyId) && now - status.updatedAt > grace) {
debridLinkKeyRuntimeStatuses.delete(keyId);
removed += 1;
}
}
for (const [stateKey, until] of debridLinkKeyHostCooldowns) {
if (until + grace < now) {
debridLinkKeyHostCooldowns.delete(stateKey);
debridLinkKeyHostCooldownDetails.delete(stateKey);
removed += 1;
}
}
return removed;
}
export function primeDebridLinkRuntimeCooldownForTests(keyId: string, cooldownMs: number, message = "Debrid-Link Key im Cooldown"): void {
setDebridLinkKeyCooldownState(keyId, cooldownMs, message, "temporary");
}
export function getDebridLinkKeyRuntimeStateForTests(keyId: string): DebridLinkRuntimeState | null {
const status = debridLinkKeyRuntimeStatuses.get(keyId);
return status ? status.state : null;
}
function clearDebridLinkKeyCooldownState(keyId: string): void {
debridLinkKeyCooldowns.delete(keyId);
debridLinkKeyCooldownDetails.delete(keyId);
}
function setDebridLinkKeyRuntimeStatus(keyId: string, state: DebridLinkRuntimeState, detail: string): void {
debridLinkKeyRuntimeStatuses.set(keyId, {
state,
detail: String(detail || "").trim(),
updatedAt: Date.now()
});
}
function getDebridLinkKeyRuntimeStatus(keyId: string): DebridLinkRuntimeStatus | null {
return debridLinkKeyRuntimeStatuses.get(keyId) || null;
}
function mapDebridLinkCooldownCategoryToRuntimeState(category: DebridLinkCooldownCategory): DebridLinkRuntimeState {
if (category === "invalid") {
return "invalid";
}
if (category === "quota") {
return "quota";
}
if (category === "rate_limit") {
return "rate_limit";
}
return "cooldown";
}
function setDebridLinkKeyCooldownState(
keyId: string,
cooldownMs: number,
message: string,
category: DebridLinkCooldownCategory
): void {
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
clearDebridLinkKeyCooldownState(keyId);
return;
}
// Cooldown set: max-wins. When 8 parallel items hit floodDetected on the
// same key, each computes its own retry-after and calls setDebridLinkKey
// CooldownState. Without max-wins, the LAST setter could shorten the
// cooldown (e.g. one item got a 1h Retry-After header, another got the
// default 2 min — without max-wins the 2 min would overwrite the 1h).
// Quota and rate_limit categories take priority over generic temporary
// cooldowns regardless of duration to preserve the more-specific signal.
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
const existingUntil = Number(debridLinkKeyCooldowns.get(keyId) || 0);
const existingDetail = debridLinkKeyCooldownDetails.get(keyId);
const newIsStrongCategory = category === "rate_limit" || category === "quota" || category === "invalid";
const existingIsStrongCategory = existingDetail
? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid")
: false;
// Keep existing if it's still active and either lasts longer or has a stronger category
if (existingUntil > Date.now()) {
if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) {
return;
}
if (existingIsStrongCategory && !newIsStrongCategory) {
return;
}
}
debridLinkKeyCooldowns.set(keyId, newUntil);
debridLinkKeyCooldownDetails.set(keyId, { message, category });
setDebridLinkKeyRuntimeStatus(keyId, mapDebridLinkCooldownCategoryToRuntimeState(category), message);
}
function getDebridLinkKeyCooldownState(
keyId: string,
now = Date.now()
): { until: number; remainingMs: number; message: string; category: DebridLinkCooldownCategory } | null {
const until = Number(debridLinkKeyCooldowns.get(keyId) || 0);
if (!until) {
return null;
}
if (until <= now) {
clearDebridLinkKeyCooldownState(keyId);
return null;
}
const detail = debridLinkKeyCooldownDetails.get(keyId);
return {
until,
remainingMs: until - now,
message: detail?.message || "Debrid-Link Key im Cooldown",
category: detail?.category || "temporary"
};
}
/** Per-(key, host) cooldown cache. When a key hits maxLinkHost / maxDataHost
* for a specific host, only that combination should be blocked — the key
* itself stays usable for other hosters. Map key format: `${keyId}|${hoster}`. */
const debridLinkKeyHostCooldowns = new Map<string, number>();
const debridLinkKeyHostCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
function makeDebridLinkKeyHostCooldownKey(keyId: string, hoster: string): string {
return `${keyId}|${hoster.toLowerCase()}`;
}
function clearDebridLinkKeyHostCooldownState(keyId: string, hoster: string): void {
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
debridLinkKeyHostCooldowns.delete(stateKey);
debridLinkKeyHostCooldownDetails.delete(stateKey);
}
function setDebridLinkKeyHostCooldownState(
keyId: string,
hoster: string,
cooldownMs: number,
message: string,
category: DebridLinkCooldownCategory
): void {
if (!hoster) {
// Fall back to key-wide cooldown when we can't determine the hoster — better
// a slightly broader block than letting the key thrash on the same failure.
setDebridLinkKeyCooldownState(keyId, cooldownMs, message, category);
return;
}
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
clearDebridLinkKeyHostCooldownState(keyId, hoster);
return;
}
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
// Same max-wins semantics as setDebridLinkKeyCooldownState — parallel items
// hitting maxDataHost on the same (key, host) shouldn't shorten an existing
// longer cooldown. Strong categories (quota / rate_limit / invalid) win over
// generic temporary regardless of duration.
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
const existingUntil = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
const existingDetail = debridLinkKeyHostCooldownDetails.get(stateKey);
const newIsStrongCategory = category === "rate_limit" || category === "quota" || category === "invalid";
const existingIsStrongCategory = existingDetail
? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid")
: false;
if (existingUntil > Date.now()) {
if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) {
return;
}
if (existingIsStrongCategory && !newIsStrongCategory) {
return;
}
}
debridLinkKeyHostCooldowns.set(stateKey, newUntil);
debridLinkKeyHostCooldownDetails.set(stateKey, { message, category });
// Intentionally NOT updating setDebridLinkKeyRuntimeStatus here — the key
// is still healthy for other hosters, only this (key, host) is blocked.
}
function getDebridLinkKeyHostCooldownState(
keyId: string,
hoster: string,
now = Date.now()
): { until: number; remainingMs: number; message: string; category: DebridLinkCooldownCategory } | null {
if (!hoster) {
return null;
}
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
const until = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
if (!until) {
return null;
}
if (until <= now) {
debridLinkKeyHostCooldowns.delete(stateKey);
debridLinkKeyHostCooldownDetails.delete(stateKey);
return null;
}
const detail = debridLinkKeyHostCooldownDetails.get(stateKey);
return {
until,
remainingMs: until - now,
message: detail?.message || "Debrid-Link Key fuer Host im Cooldown",
category: detail?.category || "quota"
};
}
/** Per-account cooldown cache for Mega-Debrid: accountId → expiry timestamp. */
type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; // 2 min cooldown per failed account
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridAccountCooldowns.clear();
}
/** Periodic cleanup of expired Mega-Debrid cooldown entries. */
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
let removed = 0;
const grace = 60 * 60 * 1000;
for (const [id, detail] of megaDebridAccountCooldowns) {
if (detail.until + grace < now) {
megaDebridAccountCooldowns.delete(id);
removed += 1;
}
}
return removed;
}
export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldownMs: number, message = "Mega-Debrid Account im Cooldown"): void {
setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
}
function clearMegaDebridAccountCooldownState(accountId: string): void {
megaDebridAccountCooldowns.delete(accountId);
}
function setMegaDebridAccountCooldownState(
accountId: string,
cooldownMs: number,
message: string,
category: MegaDebridCooldownCategory
): void {
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
clearMegaDebridAccountCooldownState(accountId);
return;
}
megaDebridAccountCooldowns.set(accountId, {
until: Date.now() + Math.max(1000, Math.floor(cooldownMs)),
message,
category
});
}
export function getMegaDebridAccountCooldownState(
accountId: string,
now = Date.now()
): { until: number; remainingMs: number; message: string; category: MegaDebridCooldownCategory } | null {
const detail = megaDebridAccountCooldowns.get(accountId);
if (!detail) {
return null;
}
if (detail.until <= now) {
clearMegaDebridAccountCooldownState(accountId);
return null;
}
return {
until: detail.until,
remainingMs: detail.until - now,
message: detail.message,
category: detail.category
};
}
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
const PROVIDER_LABELS: Record<DebridProvider, string> = {
realdebrid: "Real-Debrid",
megadebrid: "Mega-Debrid",
"megadebrid-api": "Mega-Debrid API",
"megadebrid-web": "Mega-Debrid Web",
bestdebrid: "BestDebrid",
alldebrid: "AllDebrid",
ddownload: "DDownload",
onefichier: "1Fichier",
debridlink: "Debrid-Link",
linksnappy: "LinkSnappy"
};
function extractHosterFromUrl(url: string): string {
try {
const host = new URL(url).hostname.replace(/^www\./, "").toLowerCase();
const parts = host.split(".");
return parts.length >= 2 ? parts[parts.length - 2] : host;
} catch {
return "";
}
}
interface ProviderUnrestrictedLink extends UnrestrictedLink {
provider: DebridProvider;
providerLabel: string;
}
export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type RealDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
interface DebridServiceOptions {
megaWebUnrestrict?: MegaWebUnrestrictor;
allDebridWebUnrestrict?: AllDebridWebUnrestrictor;
realDebridWebUnrestrict?: RealDebridWebUnrestrictor;
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
}
function cloneSettings(settings: AppSettings): AppSettings {
return {
...settings,
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })),
debridLinkDisabledKeyIds: [...(settings.debridLinkDisabledKeyIds || [])],
providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) },
providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) },
providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) },
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) },
debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) },
megaDebridDisabledAccountIds: [...(settings.megaDebridDisabledAccountIds || [])],
megaDebridAccountDailyLimitBytes: { ...(settings.megaDebridAccountDailyLimitBytes || {}) },
megaDebridAccountDailyUsageBytes: { ...(settings.megaDebridAccountDailyUsageBytes || {}) },
megaDebridAccountTotalUsageBytes: { ...(settings.megaDebridAccountTotalUsageBytes || {}) }
};
}
export function isDebridLinkApiKeyDisabled(settings: AppSettings, keyId: string): boolean {
return (settings.debridLinkDisabledKeyIds || []).includes(keyId);
}
export function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = Date.now()) {
return parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter(
(entry) => !isDebridLinkApiKeyDisabled(settings, entry.id) && !isDebridLinkApiKeyDailyLimitReached(settings, entry.id, epochMs)
);
}
/** Returns Mega-Debrid accounts that are not disabled and not daily-limited. */
export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] {
return getMegaDebridAccountList(settings).filter(
(entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs)
);
}
/** Resolves the full list of Mega-Debrid accounts from settings (multi-account or legacy single). */
function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] {
// Multi-account format: newline-separated "login:password" pairs in megaCredentials
const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
if (multiAccounts.length > 0) {
return multiAccounts;
}
// Backward compat: single legacy megaLogin/megaPassword
if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) {
return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim());
}
return [];
}
function hasMegaDebridCredentials(settings: AppSettings): boolean {
return getMegaDebridAccountList(settings).length > 0;
}
function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean {
if (mode === "api") {
return settings.megaDebridApiEnabled
|| (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
}
return settings.megaDebridWebEnabled
|| (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi);
}
function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider): DebridProvider {
if (provider !== "megadebrid") {
return provider;
}
if (isMegaDebridModeEnabled(settings, "api") && !isMegaDebridModeEnabled(settings, "web")) {
return "megadebrid-api";
}
if (isMegaDebridModeEnabled(settings, "web") && !isMegaDebridModeEnabled(settings, "api")) {
return "megadebrid-web";
}
return settings.megaDebridPreferApi ? "megadebrid-api" : "megadebrid-web";
}
type BestDebridRequest = {
url: string;
useAuthHeader: boolean;
};
function canonicalLink(link: string): string {
try {
const parsed = new URL(link);
return `${parsed.host.toLowerCase()}${parsed.pathname}${parsed.search}`;
} catch {
return link.trim().toLowerCase();
}
}
function shouldRetryStatus(status: number): boolean {
return status === 429 || status >= 500;
}
function retryDelay(attempt: number): number {
return Math.min(5000, 400 * 2 ** attempt);
}
function parseRetryAfterMs(value: string | null): number {
const text = String(value || "").trim();
if (!text) {
return 0;
}
// Cap at 1 hour — floodDetected can mandate "retry after 1 hour"
const maxRetryMs = 60 * 60 * 1000;
const asSeconds = Number(text);
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
return Math.min(maxRetryMs, Math.floor(asSeconds * 1000));
}
const asDate = Date.parse(text);
if (Number.isFinite(asDate)) {
return Math.min(maxRetryMs, Math.max(0, asDate - Date.now()));
}
return 0;
}
function retryDelayForResponse(response: Response, attempt: number): number {
if (response.status !== 429) {
return retryDelay(attempt);
}
const fromHeader = parseRetryAfterMs(response.headers.get("retry-after"));
return fromHeader > 0 ? fromHeader : retryDelay(attempt);
}
function readHttpStatusFromErrorText(text: string): number {
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
return match ? Number(match[1]) : 0;
}
function isRetryableErrorText(text: string): boolean {
const status = readHttpStatusFromErrorText(text);
if (status === 429 || status >= 500) {
return true;
}
const lower = String(text || "").toLowerCase();
return lower.includes("timeout")
|| lower.includes("network")
|| lower.includes("fetch failed")
|| lower.includes("aborted")
|| lower.includes("econnreset")
|| lower.includes("enotfound")
|| lower.includes("etimedout")
|| lower.includes("html statt json");
}
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(ms);
return;
}
if (signal.aborted) {
throw new Error("aborted:debrid");
}
await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
signal.removeEventListener("abort", onAbort);
resolve();
}, Math.max(0, ms));
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal.removeEventListener("abort", onAbort);
reject(new Error("aborted:debrid"));
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
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 parseJson(text: string): unknown {
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
function parseJsonSafe(text: string): Record<string, unknown> | null {
const parsed = parseJson(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
return parsed 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 parseError(status: number, responseText: string, payload: Record<string, unknown> | null): string {
const fromPayload = pickString(payload, ["response_text", "error", "message", "detail", "error_description"]);
if (fromPayload) {
return fromPayload;
}
const compact = compactErrorText(responseText);
if (compact && compact !== "Unbekannter Fehler") {
return compact;
}
return `HTTP ${status}`;
}
function parseAllDebridError(payload: Record<string, unknown> | null): string {
const errorValue = payload?.error;
if (typeof errorValue === "string" && errorValue.trim()) {
return errorValue.trim();
}
const errorObj = asRecord(errorValue);
return pickString(errorObj, ["message", "code"]) || "AllDebrid API error";
}
function normalizeAllDebridHostKey(value: string): string {
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
}
function toAllDebridHostState(value: unknown): AllDebridHostInfo["state"] {
if (value === true) {
return "up";
}
if (value === false) {
return "down";
}
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "up" || normalized === "online" || normalized === "available") {
return "up";
}
if (normalized === "down" || normalized === "offline" || normalized === "unavailable") {
return "down";
}
if (normalized === "not_tracked" || normalized === "not tracked") {
return "not_tracked";
}
return "unknown";
}
function toAllDebridHostStatusLabel(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 normalizeDebridLinkHostKey(value: string): string {
return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
}
function parseDebridLinkSuccess(payload: Record<string, unknown> | null): boolean {
if (!payload) {
return false;
}
if (typeof payload.success === "boolean") {
return payload.success;
}
return pickString(payload, ["result"]).toUpperCase() === "OK";
}
function parseDebridLinkHosters(payload: Record<string, unknown> | null): Record<string, unknown>[] {
const value = asRecord(payload?.value);
const hosters = value?.hosters ?? payload?.hosters;
if (Array.isArray(hosters)) {
return hosters.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry))).map((entry) => entry as Record<string, unknown>);
}
return [];
}
function findDebridLinkHostEntry(payload: Record<string, unknown> | null, host: string): Record<string, unknown> | null {
const wanted = normalizeDebridLinkHostKey(host);
for (const entry of parseDebridLinkHosters(payload)) {
const name = normalizeDebridLinkHostKey(pickString(entry, ["name", "host"]));
if (name === wanted) {
return entry;
}
}
return null;
}
function parseDebridLinkErrorCode(payload: Record<string, unknown> | null): string {
return pickString(payload, ["error", "ERR"]);
}
function parseDebridLinkErrorDescription(payload: Record<string, unknown> | null): string {
return pickString(payload, ["error_description", "message", "detail", "response_text", "error", "ERR"]);
}
function looksLikeHtmlResponse(contentType: string, body: string): boolean {
const type = String(contentType || "").toLowerCase();
if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
return true;
}
return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
}
function parsePositiveNumber(value: unknown): number | null {
const numeric = Number(value ?? NaN);
if (!Number.isFinite(numeric) || numeric <= 0) {
return null;
}
return Math.floor(numeric);
}
function parseDebridLinkNextResetMs(payload: Record<string, unknown> | null): number {
const value = asRecord(payload?.value);
const nextReset = value?.nextResetSeconds;
const nextResetRecord = asRecord(nextReset);
const seconds = parsePositiveNumber(nextReset)
?? parsePositiveNumber(nextResetRecord?.current)
?? parsePositiveNumber(nextResetRecord?.value);
if (!seconds) {
return 0;
}
return Math.min(24 * 60 * 60 * 1000, seconds * 1000);
}
function parseDebridLinkLinkEntries(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) {
return value
.map((entry) => asRecord(entry))
.filter((entry): entry is Record<string, unknown> => Boolean(entry));
}
const entry = asRecord(value);
return entry ? [entry] : [];
}
class DebridLinkApiError extends Error {
public readonly status: number;
public readonly code: string;
public readonly retryAfterMs: number;
public readonly payload: Record<string, unknown> | null;
public constructor(
status: number,
code: string,
description: string,
retryAfterMs: number,
payload: Record<string, unknown> | null
) {
super(description || code || `HTTP ${status || 0}`);
this.name = "DebridLinkApiError";
this.status = status;
this.code = code;
this.retryAfterMs = retryAfterMs;
this.payload = payload;
}
}
function toDebridLinkKeyStateLabel(state: DebridLinkHostLimitInfo["state"]): string {
if (state === "ready") {
return "Bereit";
}
if (state === "cooldown") {
return "Cooldown";
}
if (state === "invalid") {
return "Ungueltig";
}
if (state === "quota") {
return "Quota";
}
if (state === "rate_limit") {
return "Rate-Limit";
}
if (state === "error") {
return "Fehler";
}
return "Unbekannt";
}
function toDebridLinkHostStateLabel(state: DebridLinkHostLimitInfo["hostState"]): string {
if (state === "up") {
return "Online";
}
if (state === "down") {
return "Offline";
}
return "Unbekannt";
}
function shouldRetryDebridLinkApiError(error: DebridLinkApiError, attempt: number, maxAttempts: number): boolean {
if (attempt >= maxAttempts) {
return false;
}
if (error.status === 429 || error.status >= 500) {
return true;
}
return DEBRID_LINK_RETRYABLE_ERRORS.has(error.code);
}
function retryDelayForDebridLinkApiError(error: DebridLinkApiError, attempt: number): number {
if (error.retryAfterMs > 0) {
return error.retryAfterMs;
}
return retryDelay(attempt);
}
async function requestDebridLinkPayloadWithKey(
apiKey: { token: string },
method: "GET" | "POST" | "DELETE",
apiPath: string,
body: Record<string, unknown> | undefined,
signal?: AbortSignal,
maxAttempts = REQUEST_RETRIES
): Promise<Record<string, unknown>> {
let lastTransportError = "";
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${apiKey.token}`,
"User-Agent": DEBRID_USER_AGENT
};
let payloadBody: string | undefined;
if (method !== "GET" && method !== "DELETE" && body) {
headers["Content-Type"] = "application/json";
payloadBody = JSON.stringify(body);
}
const response = await fetch(`${DEBRID_LINK_API_BASE}${apiPath}`, {
method,
headers,
body: payloadBody,
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const responseText = await response.text();
const payload = parseJsonSafe(responseText);
if (!payload) {
const description = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText)
? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})`
: compactErrorText(responseText) || `Debrid-Link lieferte kein JSON (HTTP ${response.status})`;
const error = new DebridLinkApiError(
response.status,
"requestError",
description,
parseRetryAfterMs(response.headers.get("retry-after")),
null
);
if (shouldRetryDebridLinkApiError(error, attempt, maxAttempts)) {
await sleepWithSignal(retryDelayForDebridLinkApiError(error, attempt), signal);
continue;
}
throw error;
}
if (!response.ok || !parseDebridLinkSuccess(payload)) {
const error = new DebridLinkApiError(
response.status,
parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`,
parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`,
parseRetryAfterMs(response.headers.get("retry-after")),
payload
);
if (shouldRetryDebridLinkApiError(error, attempt, maxAttempts)) {
await sleepWithSignal(retryDelayForDebridLinkApiError(error, attempt), signal);
continue;
}
throw error;
}
return payload;
} catch (error) {
if (error instanceof DebridLinkApiError) {
throw error;
}
lastTransportError = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) {
throw error;
}
if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) {
throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen");
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen");
}
async function fetchDebridLinkPublicHostInfo(
host: string,
signal?: AbortSignal
): Promise<Pick<DebridLinkHostLimitInfo, "hostState" | "hostStateLabel" | "hostNote">> {
const hostLabel = host.trim() || "rapidgator";
try {
const response = await fetch(`${DEBRID_LINK_API_BASE}/downloader/hosts?keys=name,status,domains`, {
method: "GET",
headers: {
Accept: "application/json",
"User-Agent": DEBRID_USER_AGENT
},
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const responseText = await response.text();
const payload = parseJsonSafe(responseText);
if (!response.ok || !payload || !parseDebridLinkSuccess(payload)) {
throw new Error(parseError(response.status, responseText, payload));
}
const entries = Array.isArray(payload.value)
? payload.value.map((entry) => asRecord(entry)).filter((entry): entry is Record<string, unknown> => Boolean(entry))
: [];
const wanted = normalizeDebridLinkHostKey(hostLabel);
const hostEntry = entries.find((entry) => {
const name = normalizeDebridLinkHostKey(pickString(entry, ["name"]));
if (name === wanted) {
return true;
}
const domains = Array.isArray(entry.domains) ? entry.domains.map((value) => normalizeDebridLinkHostKey(String(value || ""))) : [];
return domains.some((domain) => domain === wanted);
});
if (!hostEntry) {
return {
hostState: "unknown",
hostStateLabel: toDebridLinkHostStateLabel("unknown"),
hostNote: `${hostLabel} nicht in /downloader/hosts gefunden.`
};
}
const statusValue = Number(hostEntry.status ?? NaN);
const hostState: DebridLinkHostLimitInfo["hostState"] = Number.isFinite(statusValue)
? (statusValue >= 1 ? "up" : "down")
: "unknown";
return {
hostState,
hostStateLabel: toDebridLinkHostStateLabel(hostState),
hostNote: hostState === "down"
? `${hostLabel} ist laut Debrid-Link /downloader/hosts aktuell offline.`
: `${hostLabel} ist laut Debrid-Link /downloader/hosts erreichbar.`
};
} catch (error) {
return {
hostState: "unknown",
hostStateLabel: toDebridLinkHostStateLabel("unknown"),
hostNote: `Hoststatus konnte nicht geladen werden: ${compactErrorText(error)}`
};
}
}
async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: string; token: string }, host: string, signal?: AbortSignal): Promise<DebridLinkHostLimitInfo> {
let lastError = "";
const hostLabel = host.trim() || "rapidgator";
const endpoints = [`${DEBRID_LINK_API_BASE}/downloader/limits/all`, `${DEBRID_LINK_API_BASE}/downloader/limits`];
for (const endpoint of endpoints) {
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
try {
const response = await fetch(endpoint, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey.token}`,
"User-Agent": DEBRID_USER_AGENT
},
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const text = await response.text();
const payload = parseJsonSafe(text);
if (response.status === 404 && endpoint.endsWith("/all")) {
break;
}
if (!response.ok) {
const reason = parseError(response.status, text, payload);
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
throw new Error(reason);
}
if (!payload) {
throw new Error("Debrid-Link Limits Antwort ist kein JSON-Objekt");
}
if (!parseDebridLinkSuccess(payload)) {
throw new Error(pickString(payload, ["error_description", "error", "message"]) || "Debrid-Link Limits fehlgeschlagen");
}
const hostEntry = findDebridLinkHostEntry(payload, hostLabel);
if (!hostEntry) {
if (endpoint.endsWith("/all")) {
break;
}
return {
keyId: apiKey.id,
keyLabel: apiKey.label,
host: hostLabel,
fetchedAt: Date.now(),
trafficCurrentBytes: null,
trafficMaxBytes: null,
linksCurrent: null,
linksMax: null,
note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.`,
state: "unknown",
stateLabel: toDebridLinkKeyStateLabel("unknown"),
stateDetail: "",
cooldownUntil: null,
cooldownRemainingMs: 0,
lastCheckedAt: Date.now(),
hostState: "unknown",
hostStateLabel: toDebridLinkHostStateLabel("unknown"),
hostNote: ""
};
}
const daySize = asRecord(hostEntry.daySize);
const dayCount = asRecord(hostEntry.dayCount);
return {
keyId: apiKey.id,
keyLabel: apiKey.label,
host: pickString(hostEntry, ["name", "host"]) || hostLabel,
fetchedAt: Date.now(),
trafficCurrentBytes: pickNumber(daySize, ["current"]),
trafficMaxBytes: pickNumber(daySize, ["value", "max"]),
linksCurrent: pickNumber(dayCount, ["current"]),
linksMax: pickNumber(dayCount, ["value", "max"]),
note: "",
state: "ready",
stateLabel: toDebridLinkKeyStateLabel("ready"),
stateDetail: "API erreichbar",
cooldownUntil: null,
cooldownRemainingMs: 0,
lastCheckedAt: Date.now(),
hostState: "unknown",
hostStateLabel: toDebridLinkHostStateLabel("unknown"),
hostNote: ""
};
} catch (error) {
lastError = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
break;
}
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break;
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
}
throw new Error(String(lastError || `Debrid-Link Limits für ${apiKey.label} fehlgeschlagen`).replace(/^Error:\s*/i, ""));
}
async function fetchDebridLinkHostLimitForKeyDetailed(
apiKey: { id: string; label: string; token: string },
host: string,
publicHostInfo: Pick<DebridLinkHostLimitInfo, "hostState" | "hostStateLabel" | "hostNote">,
signal?: AbortSignal
): Promise<DebridLinkHostLimitInfo> {
const hostLabel = host.trim() || "rapidgator";
const runtimeStatus = getDebridLinkKeyRuntimeStatus(apiKey.id);
const buildInfo = (overrides: Partial<DebridLinkHostLimitInfo>): DebridLinkHostLimitInfo => ({
keyId: apiKey.id,
keyLabel: apiKey.label,
host: hostLabel,
fetchedAt: Date.now(),
trafficCurrentBytes: null,
trafficMaxBytes: null,
linksCurrent: null,
linksMax: null,
note: "",
state: runtimeStatus?.state || "unknown",
stateLabel: toDebridLinkKeyStateLabel(runtimeStatus?.state || "unknown"),
stateDetail: runtimeStatus?.detail || "",
cooldownUntil: null,
cooldownRemainingMs: 0,
lastCheckedAt: runtimeStatus?.updatedAt || null,
hostState: publicHostInfo.hostState,
hostStateLabel: publicHostInfo.hostStateLabel,
hostNote: publicHostInfo.hostNote,
...overrides
});
const cooldownState = getDebridLinkKeyCooldownState(apiKey.id);
if (cooldownState) {
const state = mapDebridLinkCooldownCategoryToRuntimeState(cooldownState.category);
return buildInfo({
state,
stateLabel: toDebridLinkKeyStateLabel(state),
stateDetail: cooldownState.message,
cooldownUntil: cooldownState.until,
cooldownRemainingMs: cooldownState.remainingMs,
lastCheckedAt: runtimeStatus?.updatedAt || Date.now(),
note: cooldownState.message
});
}
for (const apiPath of ["/downloader/limits/all", "/downloader/limits"]) {
try {
const payload = await requestDebridLinkPayloadWithKey(apiKey, "GET", apiPath, undefined, signal);
const hostEntry = findDebridLinkHostEntry(payload, hostLabel);
if (!hostEntry) {
if (apiPath.endsWith("/all")) {
continue;
}
clearDebridLinkKeyCooldownState(apiKey.id);
setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "API erreichbar");
return buildInfo({
state: "ready",
stateLabel: toDebridLinkKeyStateLabel("ready"),
stateDetail: "API erreichbar",
lastCheckedAt: Date.now(),
note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.`
});
}
const daySize = asRecord(hostEntry.daySize);
const dayCount = asRecord(hostEntry.dayCount);
clearDebridLinkKeyCooldownState(apiKey.id);
setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "API erreichbar");
return buildInfo({
host: pickString(hostEntry, ["name", "host"]) || hostLabel,
trafficCurrentBytes: pickNumber(daySize, ["current"]),
trafficMaxBytes: pickNumber(daySize, ["value", "max"]),
linksCurrent: pickNumber(dayCount, ["current"]),
linksMax: pickNumber(dayCount, ["value", "max"]),
state: "ready",
stateLabel: toDebridLinkKeyStateLabel("ready"),
stateDetail: "API erreichbar",
lastCheckedAt: Date.now(),
note: ""
});
} catch (error) {
if (error instanceof DebridLinkApiError && error.status === 404 && apiPath.endsWith("/all")) {
continue;
}
const checkedAt = Date.now();
if (error instanceof DebridLinkApiError) {
const code = String(error.code || "").trim() || `HTTP ${error.status}`;
const description = error.message || code;
if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) {
const detail = `API-Key ungueltig oder deaktiviert (${code}: ${description})`;
setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_INVALID_KEY_COOLDOWN_MS, detail, "invalid");
const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt);
return buildInfo({
state: "invalid",
stateLabel: toDebridLinkKeyStateLabel("invalid"),
stateDetail: detail,
cooldownUntil: nextCooldown?.until || null,
cooldownRemainingMs: nextCooldown?.remainingMs || 0,
lastCheckedAt: checkedAt,
note: detail
});
}
if (DEBRID_LINK_RATE_LIMIT_ERRORS.has(code) || error.status === 429) {
const detail = `API-Rate-Limit erreicht (${code}: ${description})`;
setDebridLinkKeyCooldownState(apiKey.id, error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS, detail, "rate_limit");
const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt);
return buildInfo({
state: "rate_limit",
stateLabel: toDebridLinkKeyStateLabel("rate_limit"),
stateDetail: detail,
cooldownUntil: nextCooldown?.until || null,
cooldownRemainingMs: nextCooldown?.remainingMs || 0,
lastCheckedAt: checkedAt,
note: detail
});
}
if (DEBRID_LINK_QUOTA_ERRORS.has(code)) {
const detail = `Quota erreicht (${code}: ${description})`;
setDebridLinkKeyCooldownState(apiKey.id, parseDebridLinkNextResetMs(error.payload) || DEBRID_LINK_KEY_COOLDOWN_MS, detail, "quota");
const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt);
return buildInfo({
state: "quota",
stateLabel: toDebridLinkKeyStateLabel("quota"),
stateDetail: detail,
cooldownUntil: nextCooldown?.until || null,
cooldownRemainingMs: nextCooldown?.remainingMs || 0,
lastCheckedAt: checkedAt,
note: detail
});
}
const detail = `${code}: ${description}`;
setDebridLinkKeyRuntimeStatus(apiKey.id, "error", detail);
return buildInfo({
state: "error",
stateLabel: toDebridLinkKeyStateLabel("error"),
stateDetail: detail,
lastCheckedAt: checkedAt,
note: detail
});
}
const detail = compactErrorText(error).replace(/^Error:\s*/i, "") || `Debrid-Link Limits fuer ${apiKey.label} fehlgeschlagen`;
setDebridLinkKeyRuntimeStatus(apiKey.id, "error", detail);
return buildInfo({
state: "error",
stateLabel: toDebridLinkKeyStateLabel("error"),
stateDetail: detail,
lastCheckedAt: checkedAt,
note: detail
});
}
}
return buildInfo({
state: "unknown",
stateLabel: toDebridLinkKeyStateLabel("unknown"),
stateDetail: `Keine Limits fuer ${apiKey.label} gefunden`,
lastCheckedAt: Date.now(),
note: `Keine Limits fuer ${apiKey.label} gefunden`
});
}
function uniqueProviderOrder(order: readonly DebridProvider[]): DebridProvider[] {
const seen = new Set<DebridProvider>();
const result: DebridProvider[] = [];
for (const provider of order) {
if (seen.has(provider)) {
continue;
}
seen.add(provider);
result.push(provider);
}
return result;
}
function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvider, tertiary: DebridFallbackProvider): DebridProvider[] {
const order: DebridProvider[] = [primary];
if (secondary !== "none") {
order.push(secondary);
}
if (tertiary !== "none") {
order.push(tertiary);
}
return uniqueProviderOrder(order);
}
function isRapidgatorLink(link: string): boolean {
try {
const hostname = new URL(link).hostname.toLowerCase();
return hostname === "rapidgator.net"
|| hostname.endsWith(".rapidgator.net")
|| hostname === "rg.to"
|| hostname.endsWith(".rg.to")
|| hostname === "rapidgator.asia"
|| hostname.endsWith(".rapidgator.asia");
} catch {
return false;
}
}
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function looksLikeFileName(value: string): boolean {
return /\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub)$/i.test(value);
}
export function normalizeResolvedFilename(value: string): string {
const candidate = decodeHtmlEntities(String(value || ""))
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.replace(/^['"]+|['"]+$/g, "")
.replace(/^download\s+file\s+/i, "")
.replace(/\s*[-|]\s*rapidgator.*$/i, "")
.trim();
if (!candidate || candidate.length > 260 || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) {
return "";
}
return candidate;
}
export function filenameFromRapidgatorUrlPath(link: string): string {
try {
const parsed = new URL(link);
const pathParts = parsed.pathname.split("/").filter(Boolean);
for (let index = pathParts.length - 1; index >= 0; index -= 1) {
const raw = safeDecode(pathParts[index]).replace(/\.html?$/i, "").trim();
const normalized = normalizeResolvedFilename(raw);
if (normalized) {
return normalized;
}
}
return "";
} catch {
return "";
}
}
export function extractRapidgatorFilenameFromHtml(html: string): string {
const patterns = [
/<meta[^>]+(?:property=["']og:title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+property=["']og:title["'])/i,
/<meta[^>]+(?:name=["']title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+name=["']title["'])/i,
/<title>([^<]{1,260})<\/title>/i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]{1,260})\s*</i,
/(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]{1,260})/i
];
for (const pattern of patterns) {
const match = html.match(pattern);
// Some patterns have multiple capture groups for attribute-order independence;
// pick the first non-empty group.
const raw = match?.[1] || match?.[2] || "";
const normalized = normalizeResolvedFilename(raw);
if (normalized) {
return normalized;
}
}
return "";
}
async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
if (items.length === 0) {
return;
}
const size = Math.max(1, Math.min(concurrency, items.length));
let index = 0;
let firstError: unknown = null;
const next = (): T | undefined => {
if (firstError || index >= items.length) {
return undefined;
}
const item = items[index];
index += 1;
return item;
};
const runners = Array.from({ length: size }, async () => {
let current = next();
while (current !== undefined) {
try {
await worker(current);
} catch (error) {
if (!firstError) {
firstError = error;
}
}
current = next();
}
});
await Promise.all(runners);
if (firstError) {
throw firstError;
}
}
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
if (!signal) {
return AbortSignal.timeout(timeoutMs);
}
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
}
async function readResponseTextLimited(response: Response, maxBytes: number, signal?: AbortSignal): Promise<string> {
const body = response.body;
if (!body) {
return "";
}
const reader = body.getReader();
const chunks: Buffer[] = [];
let readBytes = 0;
try {
while (readBytes < maxBytes) {
if (signal?.aborted) {
throw new Error("aborted:debrid");
}
const { done, value } = await reader.read();
if (done || !value || value.byteLength === 0) {
break;
}
const remaining = maxBytes - readBytes;
const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value;
chunks.push(Buffer.from(slice));
readBytes += slice.byteLength;
}
} finally {
try {
await reader.cancel();
} catch {
// ignore
}
try {
reader.releaseLock();
} catch {
// ignore
}
}
return Buffer.concat(chunks).toString("utf8");
}
async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Promise<string> {
if (!isRapidgatorLink(link)) {
return "";
}
const fromUrl = filenameFromRapidgatorUrlPath(link);
if (fromUrl) {
return fromUrl;
}
if (signal?.aborted) {
throw new Error("aborted:debrid");
}
for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) {
try {
const response = await fetch(link, {
method: "GET",
headers: {
"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",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
},
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
if (!response.ok) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
return "";
}
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
const contentLength = Number(response.headers.get("content-length") || NaN);
if (contentType
&& !contentType.includes("text/html")
&& !contentType.includes("application/xhtml")
&& !contentType.includes("text/plain")
&& !contentType.includes("text/xml")
&& !contentType.includes("application/xml")) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
return "";
}
if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
return "";
}
const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);
const fromHtml = extractRapidgatorFilenameFromHtml(html);
if (fromHtml) {
return fromHtml;
}
return "";
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
if (attempt >= REQUEST_RETRIES + 2 || !isRetryableErrorText(errorText)) {
return "";
}
}
if (attempt < REQUEST_RETRIES + 2) {
await sleepWithSignal(retryDelay(attempt), signal);
}
}
return "";
}
export interface RapidgatorCheckResult {
online: boolean;
fileName: string;
fileSize: string | null;
}
const RG_FILE_ID_RE = /\/file\/([a-z0-9]{32}|\d+)/i;
const RG_FILE_NOT_FOUND_RE = />\s*404\s*File not found/i;
const RG_FILESIZE_RE = /File\s*size:\s*<strong>([^<>"]+)<\/strong>/i;
export async function checkRapidgatorOnline(
link: string,
signal?: AbortSignal
): Promise<RapidgatorCheckResult | null> {
if (!isRapidgatorLink(link)) {
return null;
}
const fileIdMatch = link.match(RG_FILE_ID_RE);
if (!fileIdMatch) {
return null;
}
const fileId = fileIdMatch[1];
const headers = {
"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",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
};
// Fast path: HEAD request (no body download, much faster)
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
try {
if (signal?.aborted) throw new Error("aborted:debrid");
const response = await fetch(link, {
method: "HEAD",
redirect: "follow",
headers,
signal: withTimeoutSignal(signal, 15000)
});
if (response.status === 404) {
return { online: false, fileName: "", fileSize: null };
}
if (response.ok) {
const finalUrl = response.url || link;
if (!finalUrl.includes(fileId)) {
return { online: false, fileName: "", fileSize: null };
}
// HEAD 200 + URL still contains file ID → online
const fileName = filenameFromRapidgatorUrlPath(link);
return { online: true, fileName, fileSize: null };
}
// Non-OK, non-404: retry or give up
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
// HEAD inconclusive — fall through to GET
break;
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
break; // fall through to GET
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
// Slow path: GET request (downloads HTML, more thorough)
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
try {
if (signal?.aborted) throw new Error("aborted:debrid");
const response = await fetch(link, {
method: "GET",
redirect: "follow",
headers,
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
if (response.status === 404) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
return { online: false, fileName: "", fileSize: null };
}
if (!response.ok) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
return null;
}
const finalUrl = response.url || link;
if (!finalUrl.includes(fileId)) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
return { online: false, fileName: "", fileSize: null };
}
const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);
if (RG_FILE_NOT_FOUND_RE.test(html)) {
return { online: false, fileName: "", fileSize: null };
}
const fileName = extractRapidgatorFilenameFromHtml(html) || filenameFromRapidgatorUrlPath(link);
const sizeMatch = html.match(RG_FILESIZE_RE);
const fileSize = sizeMatch ? sizeMatch[1].trim() : null;
return { online: true, fileName, fileSize };
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
return null;
}
}
if (attempt <= REQUEST_RETRIES) {
await sleepWithSignal(retryDelay(attempt), signal);
}
}
return null;
}
function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] {
const linkParam = encodeURIComponent(link);
const safeToken = String(token || "").trim();
const useAuthHeader = Boolean(safeToken);
return [
{
url: `${BEST_DEBRID_API_BASE}/generateLink?link=${linkParam}`,
useAuthHeader
}
];
}
class MegaDebridClient {
private megaWebUnrestrict?: MegaWebUnrestrictor;
private login: string;
private password: string;
private mode: "api" | "web";
private allowApiFallback: boolean;
/** Per-account API token cache: login (lowercase) → { token, timestamp } */
private static cachedApiTokens = new Map<string, { token: string; at: number }>();
/** Per-account pending connect deduplication: login (lowercase) → promise */
private static pendingConnects = new Map<string, Promise<string | null>>();
/** Clear cached tokens for accounts whose login is no longer in the given set.
* Called when settings change so removed accounts don't keep stale tokens. */
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
const keep = new Set<string>();
for (const login of activeLogins) {
keep.add(String(login || "").toLowerCase());
}
for (const login of MegaDebridClient.cachedApiTokens.keys()) {
if (!keep.has(login)) {
MegaDebridClient.cachedApiTokens.delete(login);
}
}
for (const login of MegaDebridClient.pendingConnects.keys()) {
if (!keep.has(login)) {
MegaDebridClient.pendingConnects.delete(login);
}
}
}
/** Force-clear the API token for a specific login (e.g. when its password
* changes — same login, but cached token is now invalid for new password). */
public static clearCachedApiToken(login: string): void {
const key = String(login || "").toLowerCase();
MegaDebridClient.cachedApiTokens.delete(key);
MegaDebridClient.pendingConnects.delete(key);
}
public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) {
this.login = login;
this.password = password;
this.mode = mode;
this.allowApiFallback = allowApiFallback;
this.megaWebUnrestrict = megaWebUnrestrict;
}
private get cacheKey(): string {
return this.login.trim().toLowerCase();
}
private async connectApi(signal?: AbortSignal): Promise<string | null> {
const key = this.cacheKey;
// Return cached token if fresh (max 20 min)
const cached = MegaDebridClient.cachedApiTokens.get(key);
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
return cached.token;
}
// Deduplicate parallel connectUser calls — only one in-flight request per account
const pending = MegaDebridClient.pendingConnects.get(key);
if (pending) {
return pending;
}
const promise = this.doConnectApi(signal).finally(() => {
MegaDebridClient.pendingConnects.delete(key);
});
MegaDebridClient.pendingConnects.set(key, promise);
return promise;
}
private clearTokenCache(): void {
MegaDebridClient.cachedApiTokens.delete(this.cacheKey);
}
private async doConnectApi(signal?: AbortSignal): Promise<string | null> {
const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`;
const response = await fetch(url, {
headers: { "User-Agent": DEBRID_USER_AGENT },
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const text = await response.text();
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
this.clearTokenCache();
}
return null;
}
const payload = parseJsonSafe(text);
if (!payload || payload.response_code !== "ok") {
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
this.clearTokenCache();
}
return null;
}
const token = String(payload.token || "").trim();
if (!token) {
return null;
}
MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() });
return token;
}
private async unrestrictViaApi(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
const token = await this.connectApi(signal);
if (!token) {
return null;
}
const url = `${MEGA_DEBRID_API_BASE}?action=getLink&token=${encodeURIComponent(token)}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": DEBRID_USER_AGENT
},
body: new URLSearchParams({ link }),
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const text = await response.text();
if (!response.ok) {
// Token might be invalid, clear cache
if (response.status === 401 || response.status === 403) {
this.clearTokenCache();
}
return null;
}
const payload = parseJsonSafe(text);
if (!payload || payload.response_code !== "ok") {
// Token expired — clear cache for next attempt
if (payload && String(payload.response_code || "").includes("token")) {
this.clearTokenCache();
}
const errorText = String(payload?.response_text || "").trim();
if (errorText) {
throw new Error(`Mega-Debrid API: ${errorText}`);
}
return null;
}
const directUrl = String(payload.debridLink || "").trim();
if (!directUrl) {
return null;
}
const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
return {
directUrl,
fileName,
fileSize: null,
retriesUsed: 0,
sourceLabel: "API"
};
}
private async unrestrictViaWeb(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
if (!this.megaWebUnrestrict) {
throw new Error("Mega-Web-Fallback nicht verfügbar");
}
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
if (signal?.aborted) {
throw new Error("aborted:debrid");
}
const web = await this.megaWebUnrestrict(link, signal).catch((error) => {
lastError = compactErrorText(error);
return null;
});
if (signal?.aborted) {
throw new Error("aborted:debrid");
}
if (web?.directUrl) {
web.retriesUsed = attempt - 1;
web.sourceLabel = "Web";
return web;
}
if (web && !web.directUrl) {
throw new Error("Mega-Web Antwort ohne Download-Link");
}
if (!lastError) {
lastError = "Mega-Web Antwort leer";
}
// Don't retry permanent hoster errors (dead link, file removed, etc.)
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError)) {
break;
}
if (attempt < REQUEST_RETRIES) {
await sleepWithSignal(retryDelay(attempt), signal);
}
}
throw new Error(String(lastError || "Mega-Web Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
}
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
if (this.mode === "api" && this.login.trim() && this.password.trim()) {
try {
const apiResult = await this.unrestrictViaApi(link, signal);
if (apiResult) {
logger.info(`Mega-Debrid (API) unrestrict OK: ${apiResult.fileName}`);
return apiResult;
}
throw new Error("Mega-Debrid API: Login oder Unrestrict fehlgeschlagen");
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
if (!this.allowApiFallback) {
throw error;
}
logger.warn(`Mega-Debrid API fehlgeschlagen, versuche Web-Fallback: ${errorText}`);
}
return this.unrestrictViaWeb(link, signal);
}
return this.unrestrictViaWeb(link, signal);
}
/**
* Multi-account rotation for Mega-Debrid, following the same pattern as Debrid-Link multi-key rotation.
* Iterates through all configured accounts, skipping disabled/daily-limited/cooldown accounts.
* On success: clears cooldown, returns result with sourceAccountId/sourceAccountLabel.
* On failure: classifies error, sets cooldown, tries next account.
*/
public static async unrestrictWithAccounts(
settings: AppSettings,
mode: "api" | "web",
allowApiFallback: boolean,
link: string,
megaWebUnrestrict: MegaWebUnrestrictor | undefined,
signal?: AbortSignal
): Promise<UnrestrictedLink> {
const accounts = getMegaDebridAccountList(settings);
if (accounts.length === 0) {
throw new Error("Mega-Debrid: Kein Account konfiguriert");
}
if (getAvailableMegaDebridAccounts(settings).length === 0) {
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar (deaktiviert oder am Tageslimit)");
}
const failures: string[] = [];
let usableAccountSeen = false;
const cooldownFailures: string[] = [];
let earliestCooldownUntil = 0;
const totalAccounts = accounts.length;
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
const linkShort = String(link || "").slice(0, 80);
// Always start from first account — use first available, skip disabled/limited/cooldown.
for (let idx = 0; idx < accounts.length; idx += 1) {
const account = accounts[idx];
// Always show account number — even with 1 account — so user can tell at a
// glance which account is in play. Format: "(Account 2/3, fa**david@...)"
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
if (isMegaDebridAccountDisabled(settings, account.id)) {
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
continue;
}
if (isMegaDebridAccountDailyLimitReached(settings, account.id)) {
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Account`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
continue;
}
// Cooldown key includes mode so API failures don't block Web attempts
const cooldownKey = `${account.id}:${mode}`;
const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
if (accountCooldownState) {
const untilStr = new Date(accountCooldownState.until).toLocaleTimeString();
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (Cooldown bis ${untilStr}), pruefe naechsten Account`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", {
reason: accountCooldownState.message,
category: accountCooldownState.category,
until: untilStr
});
cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`);
if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
earliestCooldownUntil = accountCooldownState.until;
}
continue;
}
// CLEAR per-account TEST log line BEFORE the network call, so the user
// can always see exactly which account is currently being tested for
// link generation — even if the call hangs or times out.
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
const testStartedAt = Date.now();
usableAccountSeen = true;
try {
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
const result = await client.unrestrictLink(link, signal);
clearMegaDebridAccountCooldownState(cooldownKey);
const elapsedMs = Date.now() - testStartedAt;
logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
elapsedMs,
fileName: result.fileName || "",
link: linkShort
});
return {
...result,
sourceLabel: `${result.sourceLabel ? `${result.sourceLabel} ` : ""}${account.label}`,
sourceAccountId: account.id,
sourceAccountLabel: account.label
};
} catch (error) {
const failure = MegaDebridClient.classifyAccountFailure(error);
const elapsedMs = Date.now() - testStartedAt;
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
if (failure.cooldownMs > 0) {
setMegaDebridAccountCooldownState(cooldownKey, failure.cooldownMs, failure.message, failure.category);
} else {
clearMegaDebridAccountCooldownState(cooldownKey);
}
if (failure.fatal) {
logAccountRotation("ERROR", providerName, rotationLabel, "FATAL", {
elapsedMs,
reason: failure.message,
category: failure.category,
link: linkShort
});
throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`);
}
const cooldownInfo = failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: "";
// Find the next account that will be tried (for clearer log)
let nextLabel = "ENDE";
for (let nextIdx = idx + 1; nextIdx < accounts.length; nextIdx += 1) {
const nextAcc = accounts[nextIdx];
if (!isMegaDebridAccountDisabled(settings, nextAcc.id) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) {
nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`;
break;
}
}
logger.warn(`Mega-Debrid${accountLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Account (${nextLabel})`);
logAccountRotation("WARN", providerName, rotationLabel, "FAILED", {
elapsedMs,
reason: failure.message,
category: failure.category,
cooldownSec: failure.cooldownMs > 0 ? Math.ceil(failure.cooldownMs / 1000) : 0,
next: nextLabel,
link: linkShort
});
}
}
if (!usableAccountSeen) {
if (cooldownFailures.length > 0 && earliestCooldownUntil > Date.now()) {
const retryMs = Math.max(1000, earliestCooldownUntil - Date.now() + 1000);
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
}
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
}
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
}
/**
* Classify error from a single Mega-Debrid account attempt.
* Returns whether the error is fatal (stop all accounts) and how long to cool down.
*/
private static classifyAccountFailure(
error: unknown
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory } {
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Abort — don't retry other accounts
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
}
// Auth/login failures — long cooldown, try next account
if (/login|password|auth|credentials|unauthorized|forbidden/i.test(errorText) || /connectUser/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS,
message: `ungueltiger Account (${errorText})`,
category: "invalid"
};
}
// Permanent hoster errors — fatal, don't try other accounts
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
}
// Quota/limit errors — cooldown, try next account
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: `Quota/Limit erreicht (${errorText})`,
category: "quota"
};
}
// Rate limit
if (/rate.?limit|too.?many|429/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: `Rate-Limit (${errorText})`,
category: "rate_limit"
};
}
// Mega-Web "Antwort leer" / empty body — server frequently returns transient
// empty responses that recover within seconds. A 2-minute cooldown for this
// is way too long because the account is fundamentally healthy. Use a short
// 20s cooldown so the next download attempt can use this account again.
if (/antwort\s+leer|empty\s+response|leere\s+antwort/i.test(errorText)) {
return {
fatal: false,
cooldownMs: 20_000,
message: errorText || "Mega-Web transient empty response",
category: "temporary"
};
}
// Temporary/transport errors — short cooldown, try next account.
// Plain network blips deserve a much shorter cooldown than 2 min.
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
return {
fatal: false,
cooldownMs: 30_000,
message: errorText || "temporaerer Fehler",
category: "temporary"
};
}
// Unknown errors — short cooldown, try next account (non-fatal)
return {
fatal: false,
cooldownMs: 30_000,
message: errorText || "unbekannter Fehler",
category: "temporary"
};
}
}
class BestDebridClient {
private token: string;
public constructor(token: string) {
this.token = token;
}
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
const requests = buildBestDebridRequests(link, this.token);
let lastError = "";
for (const request of requests) {
try {
return await this.tryRequest(request, link, signal);
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
lastError = errorText;
}
}
throw new Error(lastError || "BestDebrid Unrestrict fehlgeschlagen");
}
private async tryRequest(request: BestDebridRequest, originalLink: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
try {
const headers: Record<string, string> = {
"User-Agent": DEBRID_USER_AGENT
};
if (request.useAuthHeader) {
headers.Authorization = `Bearer ${this.token}`;
}
const response = await fetch(request.url, {
method: "GET",
headers,
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const text = await response.text();
const parsed = parseJson(text);
const payload = Array.isArray(parsed) ? asRecord(parsed[0]) : asRecord(parsed);
if (!response.ok) {
const reason = parseError(response.status, text, payload);
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
throw new Error(reason);
}
const directUrl = pickString(payload, ["download", "debridLink", "link"]);
if (directUrl) {
let parsedDirect: URL;
try {
parsedDirect = new URL(directUrl);
} catch {
throw new Error("BestDebrid Antwort enthält keine gültige Download-URL");
}
if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") {
throw new Error(`BestDebrid Antwort enthält ungültiges Download-URL-Protokoll (${parsedDirect.protocol})`);
}
const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink);
const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]);
return {
fileName,
directUrl,
fileSize,
retriesUsed: attempt - 1
};
}
const message = pickString(payload, ["response_text", "message", "error"]);
if (message) {
throw new Error(message);
}
throw new Error("BestDebrid Antwort ohne Download-Link");
} catch (error) {
lastError = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
break;
}
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break;
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
throw new Error(String(lastError || "BestDebrid Request fehlgeschlagen").replace(/^Error:\s*/i, ""));
}
}
class AllDebridClient {
private token: string;
public constructor(token: string) {
this.token = token;
}
public async getLinkInfos(links: string[], signal?: AbortSignal): Promise<Map<string, string>> {
const result = new Map<string, string>();
const canonicalToInput = new Map<string, string>();
const uniqueLinks: string[] = [];
for (const link of links) {
const trimmed = link.trim();
if (!trimmed) {
continue;
}
const canonical = canonicalLink(trimmed);
if (canonicalToInput.has(canonical)) {
continue;
}
canonicalToInput.set(canonical, trimmed);
uniqueLinks.push(trimmed);
}
for (let index = 0; index < uniqueLinks.length; index += 32) {
if (signal?.aborted) {
throw new Error("aborted:debrid");
}
const chunk = uniqueLinks.slice(index, index + 32);
const body = new URLSearchParams();
for (const link of chunk) {
body.append("link[]", link);
}
let payload: Record<string, unknown> | null = null;
let chunkResolved = false;
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
let response: Response;
let text = "";
try {
response = await fetch(`${ALL_DEBRID_API_BASE}/link/infos`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": DEBRID_USER_AGENT
},
body,
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
text = await response.text();
payload = asRecord(parseJson(text));
if (!response.ok) {
const reason = parseError(response.status, text, payload);
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
throw new Error(reason);
}
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text);
if (looksHtml) {
throw new Error("AllDebrid lieferte HTML statt JSON");
}
if (!payload) {
throw new Error("AllDebrid Antwort ist kein JSON-Objekt");
}
const status = pickString(payload, ["status"]);
if (status && status.toLowerCase() === "error") {
throw new Error(parseAllDebridError(payload));
}
chunkResolved = true;
break;
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
throw error;
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
if (!chunkResolved || !payload) {
throw new Error("AllDebrid Link-Infos konnten nicht geladen werden");
}
const data = asRecord(payload?.data);
const infos = Array.isArray(data?.infos) ? data.infos : [];
const hasAnyLinkedInfo = infos.some((entry) => {
const info = asRecord(entry);
return Boolean(pickString(info, ["link"]));
});
const allowPositionalFallback = infos.length === chunk.length && !hasAnyLinkedInfo;
for (let i = 0; i < infos.length; i += 1) {
const info = asRecord(infos[i]);
if (!info) {
continue;
}
const fileName = pickString(info, ["filename", "fileName"]);
if (!fileName) {
continue;
}
const responseLink = pickString(info, ["link"]);
const byResponse = canonicalToInput.get(canonicalLink(responseLink));
const byIndex = chunk.length === 1
? chunk[0]
: allowPositionalFallback
? chunk[i]
: "";
const original = byResponse || byIndex;
if (!original) {
continue;
}
result.set(original, fileName);
}
}
return result;
}
public async getHostInfo(host: string, signal?: AbortSignal): Promise<AllDebridHostInfo> {
const wanted = normalizeAllDebridHostKey(host);
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
try {
const response = await fetch(`${ALL_DEBRID_API_BASE_V41}/user/hosts`, {
method: "GET",
headers: {
Authorization: `Bearer ${this.token}`,
"User-Agent": DEBRID_USER_AGENT
},
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const text = await response.text();
const payload = asRecord(parseJson(text));
if (!response.ok) {
const reason = parseError(response.status, text, payload);
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
throw new Error(reason);
}
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text);
if (looksHtml) {
throw new Error("AllDebrid lieferte HTML statt JSON");
}
if (!payload) {
throw new Error("AllDebrid Antwort ist kein JSON-Objekt");
}
const status = pickString(payload, ["status"]);
if (status && status.toLowerCase() === "error") {
throw new Error(parseAllDebridError(payload));
}
const data = asRecord(payload.data);
const hosts = asRecord(data?.hosts);
if (!hosts) {
throw new Error("AllDebrid Antwort ohne Host-Liste");
}
let hostEntry = asRecord(hosts[host]) || asRecord(hosts[wanted]);
if (!hostEntry) {
for (const entry of Object.values(hosts)) {
const candidate = asRecord(entry);
const candidateName = normalizeAllDebridHostKey(pickString(candidate, ["name"]));
if (candidateName === wanted) {
hostEntry = candidate;
break;
}
}
}
if (!hostEntry) {
throw new Error(`AllDebrid Host ${host} nicht gefunden`);
}
const state = toAllDebridHostState(hostEntry.status);
const quota = pickNumber(hostEntry, ["quota"]);
const quotaMax = pickNumber(hostEntry, ["quotaMax"]);
const limitSimuDl = pickNumber(hostEntry, ["limitSimuDl"]);
const quotaType = pickString(hostEntry, ["quotaType"]);
const note = quota === null && quotaMax === null && limitSimuDl === null
? "AllDebrid liefert für diesen Host aktuell keine Quota- oder Slot-Daten."
: "";
return {
host: pickString(hostEntry, ["name"]) || host,
source: "api",
state,
statusLabel: toAllDebridHostStatusLabel(state),
fetchedAt: Date.now(),
lastCheckedAt: null,
quota,
quotaMax,
quotaType,
limitSimuDl,
note
};
} catch (error) {
lastError = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
break;
}
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break;
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
throw new Error(String(lastError || "AllDebrid Host-Info fehlgeschlagen").replace(/^Error:\s*/i, ""));
}
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
try {
const response = await fetch(`${ALL_DEBRID_API_BASE}/link/unlock`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": DEBRID_USER_AGENT
},
body: new URLSearchParams({ link }),
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const text = await response.text();
const payload = asRecord(parseJson(text));
if (!response.ok) {
const reason = parseError(response.status, text, payload);
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
throw new Error(reason);
}
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text);
if (looksHtml) {
throw new Error("AllDebrid lieferte HTML statt JSON");
}
if (!payload) {
throw new Error("AllDebrid Antwort ist kein JSON-Objekt");
}
const status = pickString(payload, ["status"]);
if (status && status.toLowerCase() === "error") {
throw new Error(parseAllDebridError(payload));
}
const data = asRecord(payload?.data);
const directUrl = pickString(data, ["link"]);
if (!directUrl) {
throw new Error("AllDebrid Antwort ohne Download-Link");
}
let parsedDirect: URL;
try {
parsedDirect = new URL(directUrl);
} catch {
throw new Error("AllDebrid Antwort enthält keine gültige Download-URL");
}
if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") {
throw new Error(`AllDebrid Antwort enthält ungültiges Download-URL-Protokoll (${parsedDirect.protocol})`);
}
return {
fileName: pickString(data, ["filename"]) || filenameFromUrl(link),
directUrl,
fileSize: pickNumber(data, ["filesize"]),
retriesUsed: attempt - 1
};
} catch (error) {
lastError = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
break;
}
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break;
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
throw new Error(String(lastError || "AllDebrid Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
}
}
export async function fetchAllDebridHostInfo(token: string, host = "rapidgator", signal?: AbortSignal): Promise<AllDebridHostInfo> {
return new AllDebridClient(token).getHostInfo(host, signal);
}
export async function fetchDebridLinkHostLimits(apiKeysRaw: string, host = "rapidgator", signal?: AbortSignal): Promise<DebridLinkHostLimitInfo[]> {
const apiKeys = parseDebridLinkApiKeys(apiKeysRaw);
if (apiKeys.length === 0) {
throw new Error("Debrid-Link ist nicht konfiguriert");
}
const publicHostInfo = await fetchDebridLinkPublicHostInfo(host, signal);
const results: DebridLinkHostLimitInfo[] = [];
for (const apiKey of apiKeys) {
results.push(await fetchDebridLinkHostLimitForKeyDetailed(apiKey, host, publicHostInfo, signal));
}
return results;
}
// ── Debrid-Link Client ──
class DebridLinkClient {
private apiKeys: ReturnType<typeof parseDebridLinkApiKeys>;
public constructor(apiKeysRaw: string) {
this.apiKeys = parseDebridLinkApiKeys(apiKeysRaw);
}
public async unrestrictLink(link: string, settings: AppSettings, signal?: AbortSignal): Promise<UnrestrictedLink> {
if (this.apiKeys.length === 0) {
throw new Error("Debrid-Link: Kein API-Key konfiguriert");
}
if (getAvailableDebridLinkApiKeys(settings).length === 0) {
throw new Error("Debrid-Link: Kein aktiver API-Key verfuegbar (deaktiviert oder am Tageslimit)");
}
const failures: string[] = [];
let usableKeySeen = false;
const cooldownFailures: string[] = [];
let earliestCooldownUntil = 0;
const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = [];
let consecutiveTransportFailures = 0;
const totalKeys = this.apiKeys.length;
const providerName = "Debrid-Link";
const linkShort = String(link || "").slice(0, 80);
const linkHoster = extractHosterFromUrl(link);
// Always start from first key — use first available, skip disabled/limited/cooldown.
// This ensures all parallel items use the same key until it's actually exhausted.
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
const apiKey = this.apiKeys[keyIdx];
// Always show key number — even with 1 key — so user can tell at a
// glance which key is in play. Format: "(Key 2/3, abc***xyz)"
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
continue;
}
if (isDebridLinkApiKeyDailyLimitReached(settings, apiKey.id)) {
logger.info(`Debrid-Link${keyLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Key`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
continue;
}
const keyCooldownState = getDebridLinkKeyCooldownState(apiKey.id);
if (keyCooldownState) {
const untilStr = new Date(keyCooldownState.until).toLocaleTimeString();
logger.info(`Debrid-Link${keyLabel}: uebersprungen (Cooldown bis ${untilStr}), pruefe naechsten Key`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", {
reason: keyCooldownState.message,
category: keyCooldownState.category,
until: untilStr
});
cooldownFailures.push(`Debrid-Link${keyLabel}: ${keyCooldownState.message}`);
if (!earliestCooldownUntil || keyCooldownState.until < earliestCooldownUntil) {
earliestCooldownUntil = keyCooldownState.until;
}
continue;
}
// Per-(key, host) cooldown — set when a previous attempt for THIS host
// returned maxLinkHost / maxDataHost. The key itself is healthy for other
// hosters, so we only skip it for this specific link.
const hostCooldownState = linkHoster ? getDebridLinkKeyHostCooldownState(apiKey.id, linkHoster) : null;
if (hostCooldownState) {
const untilStr = new Date(hostCooldownState.until).toLocaleTimeString();
logger.info(`Debrid-Link${keyLabel}: uebersprungen (Host-Cooldown ${linkHoster} bis ${untilStr}), pruefe naechsten Key`);
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_HOST_COOLDOWN", {
reason: hostCooldownState.message,
category: hostCooldownState.category,
host: linkHoster,
until: untilStr
});
cooldownFailures.push(`Debrid-Link${keyLabel}: ${hostCooldownState.message}`);
if (!earliestCooldownUntil || hostCooldownState.until < earliestCooldownUntil) {
earliestCooldownUntil = hostCooldownState.until;
}
continue;
}
// CLEAR per-key TEST log line BEFORE the network call, so the user
// can always see exactly which key is currently being tested for
// link generation — even if the call hangs or times out.
logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
const testStartedAt = Date.now();
usableKeySeen = true;
try {
const result = await this.unrestrictWithKey(apiKey, link, signal);
clearDebridLinkKeyCooldownState(apiKey.id);
setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich");
const elapsedMs = Date.now() - testStartedAt;
logger.info(`Debrid-Link${keyLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
logAccountRotation("INFO", providerName, rotationLabel, "OK", {
elapsedMs,
fileName: result.fileName || "",
link: linkShort
});
return {
...result,
sourceLabel: apiKey.label,
sourceAccountId: apiKey.id,
sourceAccountLabel: apiKey.label
};
} catch (error) {
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
const elapsedMs = Date.now() - testStartedAt;
attemptedKeyFailures.push({
message: `Debrid-Link${keyLabel}: ${failure.message}`,
cooldownMs: failure.cooldownMs,
category: failure.category
});
failures.push(`Debrid-Link${keyLabel}: ${failure.message}`);
if (failure.cooldownMs > 0) {
if (failure.hostOnly) {
// Per-(key, host) quota — block only this combination, not the
// whole key. The key remains "ready" for other hosters. If the
// hoster couldn't be parsed from the URL, the helper falls back
// to a key-wide cooldown (better safe than thrashing).
setDebridLinkKeyHostCooldownState(
apiKey.id,
failure.hoster || "",
failure.cooldownMs,
failure.message,
failure.category || "quota"
);
} else {
setDebridLinkKeyCooldownState(apiKey.id, failure.cooldownMs, failure.message, failure.category || "temporary");
}
} else {
clearDebridLinkKeyCooldownState(apiKey.id);
if (failure.category === "invalid") {
setDebridLinkKeyRuntimeStatus(apiKey.id, "invalid", failure.message);
} else if (failure.category !== "skip") {
// "skip" means the LINK or HOST is unavailable (fileNotAvailable,
// disabledServerHost, notFreeHost, freeServerOverload, ...), NOT
// that the key is broken. The key responded normally — leave its
// runtime status alone so the UI doesn't flag it as errored.
setDebridLinkKeyRuntimeStatus(apiKey.id, "error", failure.message);
}
}
if (failure.fatal) {
logAccountRotation("ERROR", providerName, rotationLabel, "FATAL", {
elapsedMs,
reason: failure.message,
category: failure.category,
link: linkShort
});
throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
}
if (failure.providerWide) {
// Host-level issue (e.g. notDebrid) — rotating to other keys is pointless.
// Break immediately and apply a longer cooldown (5 min) to avoid burning all keys.
const providerWideCooldownMs = 5 * 60 * 1000;
logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`);
logAccountRotation("ERROR", providerName, rotationLabel, "PROVIDER_WIDE", {
elapsedMs,
reason: failure.message,
category: failure.category,
cooldownSec: Math.ceil(providerWideCooldownMs / 1000),
link: linkShort
});
throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`);
}
// Track consecutive transport failures (timeout/network) to detect cascades.
const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError);
consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0;
if (consecutiveTransportFailures >= 2) {
// 2+ keys timed out in a row — likely a server/network issue, not key-specific.
const cascadeCooldownMs = 3 * 60 * 1000;
logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`);
logAccountRotation("ERROR", providerName, rotationLabel, "TRANSPORT_CASCADE", {
elapsedMs,
consecutive: consecutiveTransportFailures,
cooldownSec: Math.ceil(cascadeCooldownMs / 1000),
link: linkShort
});
throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`);
}
// Find the next key that will be tried (for clearer log)
let nextLabel = "ENDE";
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
const nextKey = this.apiKeys[nextIdx];
if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) {
nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`;
break;
}
}
const cooldownInfo = failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: "";
logger.warn(`Debrid-Link${keyLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Key (${nextLabel})`);
logAccountRotation("WARN", providerName, rotationLabel, "FAILED", {
elapsedMs,
reason: failure.message,
category: failure.category,
cooldownSec: failure.cooldownMs > 0 ? Math.ceil(failure.cooldownMs / 1000) : 0,
next: nextLabel,
link: linkShort
});
}
}
if (!usableKeySeen) {
if (cooldownFailures.length > 0 && earliestCooldownUntil > Date.now()) {
const retryMs = Math.max(1000, earliestCooldownUntil - Date.now() + 1000);
throw new Error(`debrid_link_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
}
throw new Error("debrid_link_no_active_key:Debrid-Link: Kein aktiver API-Key verfuegbar");
}
if (attemptedKeyFailures.length > 0 && attemptedKeyFailures.every((entry) => entry.category === "invalid")) {
throw new Error(`debrid_link_invalid_all:${attemptedKeyFailures.map((entry) => entry.message).join(" | ")}`);
}
const cooldownOnlyFailures = attemptedKeyFailures.filter((entry) => entry.cooldownMs > 0);
if (attemptedKeyFailures.length > 0 && cooldownOnlyFailures.length === attemptedKeyFailures.length) {
const retryMs = Math.max(1000, Math.min(...cooldownOnlyFailures.map((entry) => Math.max(1000, entry.cooldownMs))) + 1000);
throw new Error(`debrid_link_cooldown:${retryMs}:${cooldownOnlyFailures.map((entry) => entry.message).join(" | ")}`);
}
throw new Error(failures.join(" | ") || "Debrid-Link: Kein aktiver API-Key verfuegbar");
}
private async unrestrictWithKey(
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
link: string,
signal?: AbortSignal
): Promise<UnrestrictedLink> {
const payload = await this.requestPayload(apiKey, "POST", "/downloader/add", { url: link }, signal);
const entry = await this.resolveDownloaderEntry(apiKey, payload.value, link, signal);
const directUrl = pickString(entry, ["downloadUrl"]);
const expired = Boolean(entry.expired === true);
if (!directUrl || expired) {
throw new Error("Debrid-Link: Keine gueltige Download-URL in Antwort");
}
return {
fileName: pickString(entry, ["name"]) || filenameFromUrl(directUrl) || filenameFromUrl(link),
directUrl,
fileSize: pickNumber(entry, ["size"]),
retriesUsed: 0
};
}
private async requestPayload(
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
method: "GET" | "POST" | "DELETE",
apiPath: string,
body: Record<string, unknown> | undefined,
signal?: AbortSignal,
maxAttempts = REQUEST_RETRIES
): Promise<Record<string, unknown>> {
return requestDebridLinkPayloadWithKey(apiKey, method, apiPath, body, signal, maxAttempts);
}
private shouldRetryApiError(error: DebridLinkApiError, attempt: number, maxAttempts: number): boolean {
if (attempt >= maxAttempts) {
return false;
}
if (error.status === 429 || error.status >= 500) {
return true;
}
return DEBRID_LINK_RETRYABLE_ERRORS.has(error.code);
}
private retryDelayForApiError(error: DebridLinkApiError, attempt: number): number {
if (error.retryAfterMs > 0) {
return error.retryAfterMs;
}
return retryDelay(attempt);
}
private async resolveDownloaderEntry(
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
rawValue: unknown,
originalLink: string,
signal?: AbortSignal
): Promise<Record<string, unknown>> {
const entries = parseDebridLinkLinkEntries(rawValue);
if (entries.length === 0) {
throw new Error("Debrid-Link: Keine Daten in Antwort");
}
const matchingEntries = entries.filter((entry) => {
const url = pickString(entry, ["url"]);
return url ? canonicalLink(url) === canonicalLink(originalLink) : false;
});
const chosen = matchingEntries.length === 1
? matchingEntries[0]
: entries.length === 1
? entries[0]
: null;
if (!chosen) {
throw new Error(`Debrid-Link: Link lieferte ${entries.length} Dateien statt einer Einzeldatei`);
}
const needsRefresh = !pickString(chosen, ["downloadUrl"]) || chosen.expired === true;
if (!needsRefresh) {
return chosen;
}
const id = pickString(chosen, ["id"]);
if (!id) {
return chosen;
}
// Poll up to 5 times with 2s delay — Debrid-Link sometimes needs a few
// seconds to generate the download URL after /downloader/add.
const maxPolls = 5;
for (let poll = 0; poll < maxPolls; poll++) {
if (signal?.aborted) {
throw new Error("aborted");
}
if (poll > 0) {
await sleepWithSignal(2000, signal);
}
const refreshed = await this.fetchDownloaderEntry(apiKey, id, signal);
if (refreshed) {
const url = pickString(refreshed, ["downloadUrl"]);
const expired = refreshed.expired === true;
if (url && !expired) {
return refreshed;
}
}
}
// Return last fetched entry (caller will detect missing URL and throw)
return (await this.fetchDownloaderEntry(apiKey, id, signal)) || chosen;
}
private async fetchDownloaderEntry(
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
id: string,
signal?: AbortSignal
): Promise<Record<string, unknown> | null> {
const query = new URLSearchParams({ ids: id });
const payload = await this.requestPayload(apiKey, "GET", `/downloader/list?${query.toString()}`, undefined, signal);
const entries = parseDebridLinkLinkEntries(payload.value);
if (entries.length === 0) {
return null;
}
return entries.find((entry) => pickString(entry, ["id"]) === id) || entries[0] || null;
}
private async fetchQuotaCooldownMs(
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
signal?: AbortSignal
): Promise<number> {
try {
const payload = await this.requestPayload(apiKey, "GET", "/downloader/limits", undefined, signal, 1);
return parseDebridLinkNextResetMs(payload) || DEBRID_LINK_KEY_COOLDOWN_MS;
} catch {
return DEBRID_LINK_KEY_COOLDOWN_MS;
}
}
private async classifyKeyFailure(
error: unknown,
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
link: string,
signal?: AbortSignal
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean; hostOnly?: boolean; hoster?: string }> {
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
if (error instanceof DebridLinkApiError) {
const code = String(error.code || "").trim() || `HTTP ${error.status}`;
const description = error.message || code;
if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) {
return {
fatal: false,
cooldownMs: DEBRID_LINK_INVALID_KEY_COOLDOWN_MS,
message: `ungueltiger oder deaktivierter API-Key (${code}: ${description})`,
category: "invalid"
};
}
if (DEBRID_LINK_RATE_LIMIT_ERRORS.has(code) || error.status === 429) {
return {
fatal: false,
cooldownMs: error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS,
message: `API-Rate-Limit erreicht (${code}: ${description})`,
category: "rate_limit"
};
}
if (DEBRID_LINK_HOST_QUOTA_ERRORS.has(code)) {
// Per-(key, host) quota — only this host is exhausted for this key.
// The key remains usable for other hosters, so we mark the failure
// hostOnly and let the rotation loop apply a per-(key, host) cooldown.
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
const hosterRaw = extractHosterFromUrl(link);
const hosterLabel = hosterRaw || "host";
return {
fatal: false,
cooldownMs,
message: `Quota erreicht fuer ${hosterLabel} (${code}: ${description})`,
category: "quota",
hostOnly: true,
hoster: hosterRaw
};
}
if (DEBRID_LINK_KEY_QUOTA_ERRORS.has(code)) {
// Key-wide quota — whole key is exhausted, blocks all hosters.
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
return {
fatal: false,
cooldownMs,
message: `Quota erreicht (${code}: ${description})`,
category: "quota"
};
}
if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) {
// notDebrid = host-level issue — affects ALL keys equally, do NOT rotate.
return {
fatal: false,
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
message: `Link kann aktuell nicht generiert werden (${code}: ${description})`,
category: "temporary",
providerWide: true
};
}
if (DEBRID_LINK_SKIP_KEY_ERRORS.has(code)) {
return {
fatal: false,
cooldownMs: 0,
message: `Key kann Link aktuell nicht verarbeiten (${code}: ${description})`,
category: "skip"
};
}
if (DEBRID_LINK_FATAL_LINK_ERRORS.has(code)) {
return {
fatal: true,
cooldownMs: 0,
message: description,
category: "temporary"
};
}
if (DEBRID_LINK_RETRYABLE_ERRORS.has(code) || error.status >= 500) {
return {
fatal: false,
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
message: `temporärer API-Fehler (${code}: ${description})`
};
}
return {
fatal: true,
cooldownMs: 0,
message: description
};
}
// Treat missing/expired download URLs as temporary — the server may need
// more time or another key might succeed immediately.
if (/keine gueltige download-url/i.test(errorText)) {
return {
fatal: false,
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
message: errorText || "Download-URL nicht verfuegbar",
category: "temporary"
};
}
if (isRetryableErrorText(errorText) || /debrid-link.*(json|html)/i.test(errorText)) {
// Distinguish a single transient transport error (timeout, network blip,
// ECONNRESET) from a real API/server problem. Single timeouts shouldn't
// park a key for 2 full minutes — that just delays parallel work for
// no reason. Use a short 15s cooldown for transport, full 2min only
// for things that look like server-side faults (5xx HTML pages, etc).
const isTransport = /timeout|network|fetch failed|aborted|econnreset|enotfound|etimedout|socket/i.test(errorText)
&& !(error instanceof DebridLinkApiError);
return {
fatal: false,
cooldownMs: isTransport ? 15_000 : DEBRID_LINK_KEY_COOLDOWN_MS,
message: errorText || "temporärer Transportfehler"
};
}
// HTTP 200 with success:false but no recognizable error code: don't kill
// the item permanently. Treat as a temporary blip — same key can be tried
// again after a short cooldown, or another key picked up.
if (errorText && /success.*false|kein.*json|empty.*response/i.test(errorText)) {
return {
fatal: false,
cooldownMs: 30_000,
message: errorText,
category: "temporary"
};
}
return {
fatal: true,
cooldownMs: 0,
message: errorText || "Unbekannter Debrid-Link-Fehler"
};
}
}
// ── LinkSnappy Client ──
class LinkSnappyClient {
private username: string;
private password: string;
private sessionCookies: string | null = null;
public constructor(username: string, password: string) {
this.username = username;
this.password = password;
}
private async authenticate(signal?: AbortSignal): Promise<void> {
const params = new URLSearchParams({ username: this.username, password: this.password });
const res = await fetch(`${LINKSNAPPY_API_BASE}/AUTHENTICATE?${params.toString()}`, {
signal: withTimeoutSignal(signal, API_TIMEOUT_MS),
redirect: "manual"
});
const cookies: string[] = [];
const setCookie = res.headers.getSetCookie?.() ?? [];
for (const sc of setCookie) {
const nameValue = sc.split(";")[0];
if (nameValue) cookies.push(nameValue);
}
const json = await res.json() as Record<string, unknown>;
if (json.status !== "OK") {
throw new Error(`LinkSnappy: Login fehlgeschlagen ${String(json.error || "Unbekannter Fehler")}`);
}
if (cookies.length > 0) {
this.sessionCookies = cookies.join("; ");
} else {
this.sessionCookies = `username=${encodeURIComponent(this.username)}; Auth=manual`;
}
logger.info("LinkSnappy: Authentifizierung erfolgreich");
}
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
if (!this.username || !this.password) {
throw new Error("LinkSnappy: Kein Login konfiguriert");
}
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
if (signal?.aborted) throw new Error("aborted:debrid");
try {
if (!this.sessionCookies) {
await this.authenticate(signal);
}
const genLinks = `{"link":"${encodeURIComponent(link)}","type":"","linkpass":""}`;
const url = `${LINKSNAPPY_API_BASE}/linkgen?genLinks=${genLinks}`;
const res = await fetch(url, {
headers: { Cookie: this.sessionCookies! },
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const json = await res.json() as Record<string, unknown>;
if (json.status === "ERROR" && json.error) {
const errorMsg = String(json.error);
if (/not logged in|session expired|unauthorized/i.test(errorMsg)) {
this.sessionCookies = null;
if (attempt < REQUEST_RETRIES) {
continue;
}
throw new Error(`LinkSnappy: ${errorMsg}`);
}
throw new Error(`LinkSnappy: ${errorMsg}`);
}
const links = json.links as Array<Record<string, unknown>> | undefined;
if (!links || links.length === 0) {
throw new Error("LinkSnappy: Keine Antwort-Daten");
}
const entry = links[0];
if (entry.status === "ERROR" || (entry.error && entry.status !== "OK")) {
const errText = String(entry.error);
if (/quota|limit/i.test(errText)) {
throw new Error(`LinkSnappy: Quota erreicht ${errText}`);
}
throw new Error(`LinkSnappy: ${errText}`);
}
let directUrl = String(entry.generated || "");
if (!directUrl) {
throw new Error("LinkSnappy: Keine Download-URL in Antwort");
}
// LinkSnappy liefert http:// URLs auf https:// upgraden (deren Server unterstützt beides)
if (directUrl.startsWith("http://")) {
directUrl = directUrl.replace("http://", "https://");
}
const fileName = String(entry.filename || "") || filenameFromUrl(directUrl) || filenameFromUrl(link);
const rawSize = entry.filesize;
let fileSize: number | null = null;
if (typeof rawSize === "number" && rawSize > 0) {
fileSize = rawSize;
} else if (typeof rawSize === "string") {
const parsed = parseFileSizeString(rawSize);
if (parsed > 0) fileSize = parsed;
}
logger.info(`LinkSnappy: Unrestrict OK → ${fileName || "?"}`);
return {
fileName,
directUrl,
fileSize,
retriesUsed: attempt - 1,
sourceLabel: "API"
};
} catch (error) {
lastError = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
throw error;
}
if (/fehlgeschlagen/i.test(lastError) && /Login/i.test(lastError)) {
throw error;
}
if (attempt < REQUEST_RETRIES) {
await sleepWithSignal(retryDelay(attempt), signal);
}
}
}
throw new Error(String(lastError || "LinkSnappy Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
}
}
function parseFileSizeString(s: string): number {
const match = s.trim().match(/^([\d.]+)\s*([KMGT]?)B?$/i);
if (!match) return 0;
const num = parseFloat(match[1]);
const unit = (match[2] || "").toUpperCase();
const multipliers: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
return Math.floor(num * (multipliers[unit] || 1));
}
// ── 1Fichier Client ──
class OneFichierClient {
private apiKey: string;
public constructor(apiKey: string) {
this.apiKey = apiKey;
}
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
if (!ONEFICHIER_URL_RE.test(link)) {
throw new Error("Kein 1Fichier-Link");
}
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
if (signal?.aborted) throw new Error("aborted:debrid");
try {
const res = await fetch(`${ONEFICHIER_API_BASE}/download/get_token.cgi`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`
},
body: JSON.stringify({ url: link, pretty: 1, cdn: 0 }),
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const json = await res.json() as Record<string, unknown>;
if (json.status === "KO" || json.error) {
const msg = String(json.message || json.error || "Unbekannter 1Fichier-Fehler");
throw new Error(msg);
}
const directUrl = String(json.url || "");
if (!directUrl) {
throw new Error("1Fichier: Keine Download-URL in Antwort");
}
return {
fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
directUrl,
fileSize: null,
retriesUsed: attempt - 1
};
} catch (error) {
lastError = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
throw error;
}
if (attempt < REQUEST_RETRIES) {
await sleepWithSignal(retryDelay(attempt), signal);
}
}
}
throw new Error(`1Fichier-Unrestrict fehlgeschlagen: ${lastError}`);
}
}
const DDOWNLOAD_URL_RE = /^https?:\/\/(?:www\.)?(?:ddownload\.com|ddl\.to)\/([a-z0-9]+)/i;
const DDOWNLOAD_WEB_BASE = "https://ddownload.com";
const DDOWNLOAD_WEB_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
class DdownloadClient {
private login: string;
private password: string;
private cookies: string = "";
public constructor(login: string, password: string) {
this.login = login;
this.password = password;
}
private async webLogin(signal?: AbortSignal): Promise<void> {
// Step 1: GET login page to extract form token
const loginPageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/login.html`, {
headers: { "User-Agent": DDOWNLOAD_WEB_UA },
redirect: "manual",
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const loginPageHtml = await loginPageRes.text();
const tokenMatch = loginPageHtml.match(/name="token" value="([^"]+)"/);
const pageCookies = (loginPageRes.headers.getSetCookie?.() || []).map((c: string) => c.split(";")[0]).join("; ");
// Step 2: POST login
const body = new URLSearchParams({
op: "login",
token: tokenMatch?.[1] || "",
rand: "",
redirect: "",
login: this.login,
password: this.password
});
const loginRes = await fetch(`${DDOWNLOAD_WEB_BASE}/`, {
method: "POST",
headers: {
"User-Agent": DDOWNLOAD_WEB_UA,
"Content-Type": "application/x-www-form-urlencoded",
...(pageCookies ? { Cookie: pageCookies } : {})
},
body: body.toString(),
redirect: "manual",
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
// Drain body
try { await loginRes.text(); } catch { /* ignore */ }
const setCookies = loginRes.headers.getSetCookie?.() || [];
const xfss = setCookies.find((c: string) => c.startsWith("xfss="));
const loginCookie = setCookies.find((c: string) => c.startsWith("login="));
if (!xfss) {
throw new Error("DDownload Login fehlgeschlagen (kein Session-Cookie)");
}
this.cookies = [loginCookie, xfss].filter((c): c is string => Boolean(c)).map((c) => c.split(";")[0]).join("; ");
}
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
const match = link.match(DDOWNLOAD_URL_RE);
if (!match) {
throw new Error("Kein DDownload-Link");
}
const fileCode = match[1];
let lastError = "";
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
try {
if (signal?.aborted) throw new Error("aborted:debrid");
// Login if no session yet
if (!this.cookies) {
await this.webLogin(signal);
}
// Step 1: GET file page to extract form fields
const filePageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, {
headers: {
"User-Agent": DDOWNLOAD_WEB_UA,
Cookie: this.cookies
},
redirect: "manual",
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
// Premium with direct downloads enabled → redirect immediately
if (filePageRes.status >= 300 && filePageRes.status < 400) {
const directUrl = filePageRes.headers.get("location") || "";
try { await filePageRes.text(); } catch { /* drain */ }
if (directUrl) {
return {
fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
directUrl,
fileSize: null,
retriesUsed: attempt - 1,
skipTlsVerify: true
};
}
}
const html = await filePageRes.text();
// Check for file not found
if (/File Not Found|file was removed|file was banned/i.test(html)) {
throw new Error("DDownload: Datei nicht gefunden");
}
// Extract form fields
const idVal = html.match(/name="id" value="([^"]+)"/)?.[1] || fileCode;
const randVal = html.match(/name="rand" value="([^"]+)"/)?.[1] || "";
const fileNameMatch = html.match(/class="file-info-name"[^>]*>([^<]+)</);
const fileName = fileNameMatch?.[1]?.trim() || filenameFromUrl(link);
// Step 2: POST download2 for premium download
const dlBody = new URLSearchParams({
op: "download2",
id: idVal,
rand: randVal,
referer: "",
method_premium: "1",
adblock_detected: "0"
});
const dlRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, {
method: "POST",
headers: {
"User-Agent": DDOWNLOAD_WEB_UA,
"Content-Type": "application/x-www-form-urlencoded",
Cookie: this.cookies,
Referer: `${DDOWNLOAD_WEB_BASE}/${fileCode}`
},
body: dlBody.toString(),
redirect: "manual",
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
if (dlRes.status >= 300 && dlRes.status < 400) {
const directUrl = dlRes.headers.get("location") || "";
try { await dlRes.text(); } catch { /* drain */ }
if (directUrl) {
return {
fileName: fileName || filenameFromUrl(directUrl),
directUrl,
fileSize: null,
retriesUsed: attempt - 1,
skipTlsVerify: true
};
}
}
const dlHtml = await dlRes.text();
// Try to find direct URL in response HTML
const directMatch = dlHtml.match(/https?:\/\/[a-z0-9]+\.(?:dstorage\.org|ddownload\.com|ddl\.to|ucdn\.to)[^\s"'<>]+/i);
if (directMatch) {
return {
fileName,
directUrl: directMatch[0],
fileSize: null,
retriesUsed: attempt - 1,
skipTlsVerify: true
};
}
// Check for error messages
const errMatch = dlHtml.match(/class="err"[^>]*>([^<]+)</i);
if (errMatch) {
throw new Error(`DDownload: ${errMatch[1].trim()}`);
}
throw new Error("DDownload: Kein Download-Link erhalten");
} catch (error) {
lastError = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
break;
}
// Re-login on auth errors
if (/login|session|cookie/i.test(lastError)) {
this.cookies = "";
}
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break;
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
throw new Error(String(lastError || "DDownload Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
}
}
export class DebridService {
private settings: AppSettings;
private options: DebridServiceOptions;
private cachedDdownloadClient: DdownloadClient | null = null;
private cachedDdownloadKey = "";
private cachedDebridLinkClient: DebridLinkClient | null = null;
private cachedDebridLinkKey = "";
private cachedLinkSnappyClient: LinkSnappyClient | null = null;
private cachedLinkSnappyKey = "";
public constructor(settings: AppSettings, options: DebridServiceOptions = {}) {
this.settings = cloneSettings(settings);
this.options = options;
}
public setSettings(next: AppSettings): void {
const prev = this.settings;
this.settings = cloneSettings(next);
// Invalidate cached provider clients whose credentials/keys changed.
// Without this, switching API keys or session-cookie-bound accounts
// (LinkSnappy, Ddownload) would keep using the previous Client instance
// — which holds the OLD session cookies — until the app is restarted.
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) {
this.cachedDebridLinkClient = null;
this.cachedDebridLinkKey = "";
}
if (prev.linkSnappyLogin !== next.linkSnappyLogin || prev.linkSnappyPassword !== next.linkSnappyPassword) {
this.cachedLinkSnappyClient = null;
this.cachedLinkSnappyKey = "";
}
if (prev.ddownloadLogin !== next.ddownloadLogin || prev.ddownloadPassword !== next.ddownloadPassword) {
this.cachedDdownloadClient = null;
this.cachedDdownloadKey = "";
}
// Mega-Debrid token cache (static, module-level): tokens are keyed by
// login (lowercase). When credentials change, drop tokens for logins
// that are no longer in the active account list, AND force-clear any
// login whose password changed. Otherwise stale tokens linger up to
// 20 minutes and the new credentials won't be tried until the cached
// token starts returning 401/403.
const prevAccounts = parseMegaDebridAccounts(prev.megaCredentials || "", prev.megaPassword || "");
const nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || "");
const nextLogins = new Set<string>();
const nextPasswordByLogin = new Map<string, string>();
for (const acc of nextAccounts) {
nextLogins.add(acc.login.toLowerCase());
nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password);
}
// Drop tokens for logins no longer present
MegaDebridClient.pruneCachedTokensNotIn(nextLogins);
// For logins still present but with a changed password, force-clear the token
for (const prevAcc of prevAccounts) {
const loginKey = prevAcc.login.toLowerCase();
if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) {
MegaDebridClient.clearCachedApiToken(prevAcc.login);
}
}
// Also prune module-level Debrid-Link cooldowns for keys that no longer exist —
// otherwise a key removed and re-added later would still show its old cooldown.
const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
}
private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {
if (this.cachedDebridLinkClient && this.cachedDebridLinkKey === apiKeysRaw) {
return this.cachedDebridLinkClient;
}
this.cachedDebridLinkClient = new DebridLinkClient(apiKeysRaw);
this.cachedDebridLinkKey = apiKeysRaw;
return this.cachedDebridLinkClient;
}
private getLinkSnappyClient(login: string, password: string): LinkSnappyClient {
const key = `${login}\0${password}`;
if (this.cachedLinkSnappyClient && this.cachedLinkSnappyKey === key) {
return this.cachedLinkSnappyClient;
}
this.cachedLinkSnappyClient = new LinkSnappyClient(login, password);
this.cachedLinkSnappyKey = key;
return this.cachedLinkSnappyClient;
}
private getDdownloadClient(login: string, password: string): DdownloadClient {
const key = `${login}\0${password}`;
if (this.cachedDdownloadClient && this.cachedDdownloadKey === key) {
return this.cachedDdownloadClient;
}
this.cachedDdownloadClient = new DdownloadClient(login, password);
this.cachedDdownloadKey = key;
return this.cachedDdownloadClient;
}
public async resolveFilenames(
links: string[],
onResolved?: (link: string, fileName: string) => void,
signal?: AbortSignal
): Promise<Map<string, string>> {
const settings = cloneSettings(this.settings);
const allDebridClient = new AllDebridClient(settings.allDebridToken);
const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link)));
if (unresolved.length === 0) {
return new Map<string, string>();
}
const clean = new Map<string, string>();
const reportResolved = (link: string, fileName: string): void => {
const normalized = fileName.trim();
if (!normalized || looksLikeOpaqueFilename(normalized) || normalized.toLowerCase() === "download.bin") {
return;
}
if (clean.get(link) === normalized) {
return;
}
clean.set(link, normalized);
onResolved?.(link, normalized);
};
const token = settings.allDebridToken.trim();
if (token) {
try {
const infos = await allDebridClient.getLinkInfos(unresolved, signal);
for (const [link, fileName] of infos.entries()) {
reportResolved(link, fileName);
}
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
// ignore and continue with host page fallback
}
}
// Mega.nz Pre-Resolve via Public API (kein Mega-Debrid-Quota-Verbrauch).
// Liefert echten Filename sobald Links in die Queue kommen, anstatt erst
// beim Unrestrict. Concurrency 4 — Mega's Public API ist tolerant gegen
// kleine Bursts.
const megaLinks = unresolved.filter((link) => !clean.has(link) && isMegaFileUrl(link));
if (megaLinks.length > 0) {
await runWithConcurrency(megaLinks, 4, async (link) => {
try {
const info = await resolveMegaFilename(link, signal);
if (info?.name) {
reportResolved(link, info.name);
}
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
// Schluck — Public API kann fehlen oder rate-limiten; faellt auf
// den normalen Mega-Debrid Unrestrict-Pfad zurueck.
}
});
}
const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link));
await runWithConcurrency(remaining, 6, async (link) => {
const fromPage = await resolveRapidgatorFilename(link, signal);
reportResolved(link, fromPage);
});
return clean;
}
private shouldUseRealDebridWeb(settings: AppSettings): boolean {
return Boolean(settings.realDebridUseWebLogin && this.options.realDebridWebUnrestrict);
}
private shouldUseAllDebridWeb(settings: AppSettings): boolean {
return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict);
}
private shouldUseBestDebridWeb(settings: AppSettings): boolean {
return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict);
}
private isProviderDailyLimited(settings: AppSettings, provider: DebridProvider): boolean {
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
if (effectiveProvider === "debridlink") {
const configuredKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
if (configuredKeys.length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
return true;
}
}
if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") {
const configuredAccounts = getMegaDebridAccountList(settings);
if (configuredAccounts.length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) {
return true;
}
}
return isProviderDailyLimitReached(settings, effectiveProvider);
}
private isProviderSelectableFor(settings: AppSettings, provider: DebridProvider): boolean {
return this.isProviderConfiguredFor(settings, provider) && !this.isProviderDailyLimited(settings, provider);
}
private formatProviderLimitMessage(settings: AppSettings, provider: DebridProvider): string {
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
return "Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft)";
}
if ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && getMegaDebridAccountList(settings).length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) {
return "Mega-Debrid nicht verfuegbar (alle aktiven Accounts deaktiviert oder ausgeschopft)";
}
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
}
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
// Hoster-Zuordnung: prüfe ob für diesen Hoster ein bestimmter Provider konfiguriert ist
const routing = settings.hosterRouting || {};
const hosterKey = extractHosterFromUrl(link);
if (hosterKey && routing[hosterKey]) {
const routedProvider = routing[hosterKey];
if (this.isProviderSelectableFor(settings, routedProvider)) {
logger.info(`Hoster-Zuordnung: ${hosterKey}${PROVIDER_LABELS[routedProvider]}`);
try {
const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal);
let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link, signal);
if (fromPage) fileName = fromPage;
}
return {
...result,
fileName,
provider: routedProvider,
providerLabel: PROVIDER_LABELS[routedProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
};
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
if (!settings.autoProviderFallback) {
throw new Error(`Hoster-Zuordnung fehlgeschlagen (${hosterKey}${PROVIDER_LABELS[routedProvider]}): ${errorText}`);
}
logger.warn(`Hoster-Zuordnung ${hosterKey}${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`);
// Fall through to normal provider chain
}
} else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) {
logger.info(`Hoster-Zuordnung ${hosterKey}${PROVIDER_LABELS[routedProvider]} übersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`);
} else {
logger.warn(`Hoster-Zuordnung ${hosterKey}${PROVIDER_LABELS[routedProvider]} übersprungen (Provider nicht konfiguriert/deaktiviert)`);
}
}
// 1Fichier is a direct file hoster. If the link is a 1fichier.com URL
// and the API key is configured, use 1Fichier directly before debrid providers.
if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) {
try {
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
return {
...result,
provider: "onefichier",
providerLabel: PROVIDER_LABELS["onefichier"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
};
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
// Fall through to normal provider chain
}
}
// DDownload is a direct file hoster, not a debrid service.
// If the link is a ddownload.com/ddl.to URL and the account is configured,
// use DDownload directly before trying any debrid providers.
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
try {
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
return {
...result,
provider: "ddownload",
providerLabel: PROVIDER_LABELS["ddownload"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
};
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
// Fall through to normal provider chain (debrid services may also support ddownload links)
}
}
// Dynamische Reihenfolge: providerOrder hat Vorrang, Fallback auf altes primary/secondary/tertiary
const order: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
? uniqueProviderOrder(settings.providerOrder)
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
const primary = order[0];
if (!settings.autoProviderFallback) {
if (!this.isProviderConfiguredFor(settings, primary)) {
throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`);
}
const selectedProvider = this.isProviderDailyLimited(settings, primary)
? order.find((provider) => provider !== primary && this.isProviderSelectableFor(settings, provider))
: primary;
if (!selectedProvider) {
throw new Error(this.formatProviderLimitMessage(settings, primary));
}
try {
const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal);
let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link, signal);
if (fromPage) {
fileName = fromPage;
}
}
return {
...result,
fileName,
provider: selectedProvider,
providerLabel: PROVIDER_LABELS[selectedProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
};
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[selectedProvider]}: ${errorText}`);
}
}
let configuredFound = false;
let limitReachedFound = false;
const attempts: string[] = [];
for (const provider of order) {
if (!this.isProviderConfiguredFor(settings, provider)) {
continue;
}
configuredFound = true;
if (this.isProviderDailyLimited(settings, provider)) {
limitReachedFound = true;
logger.info(`Provider-Kette: ${PROVIDER_LABELS[provider]} uebersprungen (${this.formatProviderLimitMessage(settings, provider)})`);
attempts.push(this.formatProviderLimitMessage(settings, provider));
continue;
}
try {
logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`);
const result = await this.unrestrictViaProvider(settings, provider, link, signal);
let fileName = result.fileName;
if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
const fromPage = await resolveRapidgatorFilename(link, signal);
if (fromPage) {
fileName = fromPage;
}
}
return {
...result,
fileName,
provider,
providerLabel: PROVIDER_LABELS[provider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
};
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
const nextProvider = order.slice(order.indexOf(provider) + 1).find((candidate) => this.isProviderSelectableFor(settings, candidate));
if (nextProvider) {
logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), Fallback auf ${PROVIDER_LABELS[nextProvider]}`);
} else {
logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), kein weiterer Provider verfuegbar`);
}
attempts.push(`${PROVIDER_LABELS[provider]}: ${compactErrorText(error)}`);
}
}
if (!configuredFound) {
throw new Error("Kein Debrid-Provider konfiguriert");
}
if (limitReachedFound && attempts.every((entry) => /Tageslimit erreicht$/i.test(entry))) {
throw new Error("Alle konfigurierten Provider haben ihr Tageslimit erreicht");
}
throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`);
}
private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean {
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
if ((settings.disabledProviders || []).includes(provider) || (settings.disabledProviders || []).includes(effectiveProvider)) return false;
if (effectiveProvider === "realdebrid") {
return Boolean(this.shouldUseRealDebridWeb(settings) || settings.token.trim());
}
if (effectiveProvider === "megadebrid-api") {
return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "api"));
}
if (effectiveProvider === "megadebrid-web") {
return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "web") && this.options.megaWebUnrestrict);
}
if (effectiveProvider === "alldebrid") {
return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim());
}
if (effectiveProvider === "ddownload") {
return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim());
}
if (effectiveProvider === "onefichier") {
return Boolean(settings.oneFichierApiKey.trim());
}
if (effectiveProvider === "debridlink") {
return Boolean(settings.debridLinkApiKeys.trim());
}
if (effectiveProvider === "linksnappy") {
return Boolean(settings.linkSnappyLogin.trim() && settings.linkSnappyPassword.trim());
}
return Boolean(this.shouldUseBestDebridWeb(settings) || settings.bestToken.trim());
}
private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
if (effectiveProvider === "realdebrid") {
if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) {
const result = await this.options.realDebridWebUnrestrict(link, signal);
if (!result) {
throw new Error("Real-Debrid-Web-Fallback nicht verfügbar");
}
result.sourceLabel = "Web";
return result;
}
const result = await new RealDebridClient(settings.token).unrestrictLink(link, signal);
result.sourceLabel = "API";
return result;
}
if (effectiveProvider === "megadebrid-api") {
return MegaDebridClient.unrestrictWithAccounts(settings, "api", provider === "megadebrid" && settings.megaDebridPreferApi, link, this.options.megaWebUnrestrict, signal);
}
if (effectiveProvider === "megadebrid-web") {
return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal);
}
if (effectiveProvider === "alldebrid") {
if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) {
const result = await this.options.allDebridWebUnrestrict(link, signal);
if (!result) {
throw new Error("AllDebrid-Web-Fallback nicht verfügbar");
}
result.sourceLabel = "Web";
return result;
}
const adResult = await new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal);
adResult.sourceLabel = "API";
return adResult;
}
if (effectiveProvider === "ddownload") {
return this.getDdownloadClient(settings.ddownloadLogin, settings.ddownloadPassword).unrestrictLink(link, signal);
}
if (effectiveProvider === "onefichier") {
return new OneFichierClient(settings.oneFichierApiKey).unrestrictLink(link, signal);
}
if (effectiveProvider === "debridlink") {
const dlResult = await this.getDebridLinkClient(settings.debridLinkApiKeys).unrestrictLink(link, settings, signal);
dlResult.sourceLabel = dlResult.sourceLabel || "API";
return dlResult;
}
if (effectiveProvider === "linksnappy") {
return this.getLinkSnappyClient(settings.linkSnappyLogin, settings.linkSnappyPassword).unrestrictLink(link, signal);
}
if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) {
const bdResult = await this.options.bestDebridWebUnrestrict(link, signal);
if (!bdResult) {
throw new Error("BestDebrid-Web-Fallback nicht verfügbar");
}
bdResult.sourceLabel = "Web";
return bdResult;
}
const bdResult = await new BestDebridClient(settings.bestToken).unrestrictLink(link, signal);
bdResult.sourceLabel = "API";
return bdResult;
}
}