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