real-debrid-downloader/src/main/debrid.ts
Sucukdeluxe 3ed3877ac9 chore: remove all source code comments and internal artifacts
Strip every comment from the source (parsed with the TypeScript compiler so
strings, template literals, regex literals and JSX are never touched), and drop
internal/working artifacts that do not belong in the public repository
(design mockups, internal analysis docs, a stray backup file and an old log).
No functional change: build is green, the full test suite passes.
2026-06-06 04:53:54 +02:00

3810 lines
138 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 { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
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";
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
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"]);
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"]);
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;
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();
}
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);
}
}
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);
}
}
}
export function pruneExpiredDebridLinkRuntimeState(now = Date.now()): number {
let removed = 0;
const grace = 60 * 60 * 1000;
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;
}
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;
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"
};
}
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) {
setDebridLinkKeyCooldownState(keyId, cooldownMs, message, category);
return;
}
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
clearDebridLinkKeyHostCooldownState(keyId, hoster);
return;
}
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
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 });
}
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"
};
}
type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
const megaDebridEmptyResponseStreaks = new Map<string, number>();
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
megaDebridEmptyResponseStreaks.set(accountId, streak);
return streak;
}
export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
megaDebridEmptyResponseStreaks.delete(accountId);
}
export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridAccountCooldowns.clear();
megaDebridEmptyResponseStreaks.clear();
}
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");
}
export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis Neustart gesperrt"): void {
setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
}
function clearMegaDebridAccountCooldownState(accountId: string): void {
megaDebridAccountCooldowns.delete(accountId);
}
function setMegaDebridAccountCooldownState(
accountId: string,
cooldownMs: number,
message: string,
category: MegaDebridCooldownCategory,
untilRestart = false
): void {
if (untilRestart) {
megaDebridAccountCooldowns.set(accountId, {
until: Number.MAX_SAFE_INTEGER,
message,
category,
untilRestart: true
});
return;
}
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; untilRestart: boolean } | 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,
untilRestart: detail.untilRestart === true
};
}
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, account?: { login: string; password: string }) => 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)
);
}
export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] {
return getMegaDebridAccountList(settings).filter(
(entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs)
);
}
function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] {
const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
if (multiAccounts.length > 0) {
return multiAccounts;
}
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;
}
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);
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 {
}
try {
reader.releaseLock();
} catch {
}
}
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 { }
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 { }
return "";
}
if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) {
try { await response.body?.cancel(); } catch { }
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"
};
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 };
}
const fileName = filenameFromRapidgatorUrlPath(link);
return { online: true, fileName, fileSize: null };
}
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
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;
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
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 { }
return { online: false, fileName: "", fileSize: null };
}
if (!response.ok) {
try { await response.body?.cancel(); } catch { }
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 { }
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;
private static cachedApiTokens = new Map<string, { token: string; at: number }>();
private static pendingConnects = new Map<string, Promise<string | null>>();
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);
}
}
}
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;
const cached = MegaDebridClient.cachedApiTokens.get(key);
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
return cached.token;
}
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) {
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 || "").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, { login: this.login, password: this.password }).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";
}
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError) || MEGA_DEBRID_NO_SERVER_RE.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);
}
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;
let parkedUntilRestartSeen = false;
const totalAccounts = accounts.length;
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
const linkShort = String(link || "").slice(0, 80);
for (let idx = 0; idx < accounts.length; idx += 1) {
const account = accounts[idx];
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;
}
const cooldownKey = `${account.id}:${mode}`;
const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
if (accountCooldownState) {
const untilStr = accountCooldownState.untilRestart
? "Neustart"
: new Date(accountCooldownState.until).toLocaleTimeString();
const reasonText = accountCooldownState.untilRestart
? "Tageslimit erreicht — bis Neustart gesperrt"
: `Cooldown bis ${untilStr}`;
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (${reasonText}), 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 (accountCooldownState.untilRestart) {
parkedUntilRestartSeen = true;
} else if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
earliestCooldownUntil = accountCooldownState.until;
}
continue;
}
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);
clearMegaDebridEmptyResponseStreak(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}`);
let parkUntilRestart = false;
let parkMessage = failure.message;
if (failure.limitSignal) {
const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
parkUntilRestart = true;
parkMessage = `Tageslimit erreicht (${streak}x kein Server/leere Antwort) — bis Neustart gesperrt`;
}
} else {
clearMegaDebridEmptyResponseStreak(cooldownKey);
}
if (parkUntilRestart) {
setMegaDebridAccountCooldownState(cooldownKey, 0, parkMessage, "quota", true);
} else 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 = parkUntilRestart
? ", bis Neustart gesperrt"
: failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: "";
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}: ${parkUntilRestart ? parkMessage : failure.message}${cooldownInfo}, pruefe naechsten Account (${nextLabel})`);
logAccountRotation("WARN", providerName, rotationLabel, "FAILED", {
reason: parkUntilRestart ? parkMessage : failure.message,
elapsedMs,
category: parkUntilRestart ? "quota" : failure.category,
cooldownSec: parkUntilRestart || failure.cooldownMs <= 0 ? 0 : Math.ceil(failure.cooldownMs / 1000),
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(" | ")}`);
}
if (parkedUntilRestartSeen) {
throw new Error(`Mega-Debrid: Alle Accounts am Tageslimit (bis Neustart gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
}
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
}
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
}
private static classifyAccountFailure(
error: unknown
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
}
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"
};
}
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" };
}
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: `Quota/Limit erreicht (${errorText})`,
category: "quota"
};
}
if (MEGA_DEBRID_NO_SERVER_RE.test(errorText)) {
return {
fatal: false,
cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
message: "Kein Server fuer diesen Hoster (Tageslimit/Hoster nicht verfuegbar)",
category: "quota",
limitSignal: true
};
}
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"
};
}
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",
limitSignal: true
};
}
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
return {
fatal: false,
cooldownMs: 30_000,
message: errorText || "temporaerer Fehler",
category: "temporary"
};
}
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;
}
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);
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
const apiKey = this.apiKeys[keyIdx];
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;
}
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;
}
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) {
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") {
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) {
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}`);
}
const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError);
consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0;
if (consecutiveTransportFailures >= 2) {
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)`);
}
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;
}
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 (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)) {
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)) {
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)) {
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
};
}
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)) {
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"
};
}
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"
};
}
}
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");
}
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));
}
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> {
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("; ");
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)
});
try { await loginRes.text(); } catch { }
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");
if (!this.cookies) {
await this.webLogin(signal);
}
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)
});
if (filePageRes.status >= 300 && filePageRes.status < 400) {
const directUrl = filePageRes.headers.get("location") || "";
try { await filePageRes.text(); } catch { }
if (directUrl) {
return {
fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
directUrl,
fileSize: null,
retriesUsed: attempt - 1,
skipTlsVerify: true
};
}
}
const html = await filePageRes.text();
if (/File Not Found|file was removed|file was banned/i.test(html)) {
throw new Error("DDownload: Datei nicht gefunden");
}
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);
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 { }
if (directUrl) {
return {
fileName: fileName || filenameFromUrl(directUrl),
directUrl,
fileSize: null,
retriesUsed: attempt - 1,
skipTlsVerify: true
};
}
}
const dlHtml = await dlRes.text();
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
};
}
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;
}
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);
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 = "";
}
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);
}
MegaDebridClient.pruneCachedTokensNotIn(nextLogins);
for (const prevAcc of prevAccounts) {
const loginKey = prevAcc.login.toLowerCase();
if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) {
MegaDebridClient.clearCachedApiToken(prevAcc.login);
}
}
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;
}
}
}
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;
}
}
});
}
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);
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}`);
}
} 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)`);
}
}
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;
}
}
}
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;
}
}
}
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;
}
}