diff --git a/docs/superpowers/plans/2026-03-23-mega-debrid-multi-account.md b/docs/superpowers/plans/2026-03-23-mega-debrid-multi-account.md new file mode 100644 index 0000000..3ca7cf3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-mega-debrid-multi-account.md @@ -0,0 +1,91 @@ +# Mega-Debrid Multi-Account Support + +> **For agentic workers:** Use superpowers:subagent-driven-development to implement this plan. + +**Goal:** Multiple Mega-Debrid accounts with automatic fallback when an account hits Fair-Use limits or errors. + +**Architecture:** Follow the existing Debrid-Link multi-key pattern. Store credentials as newline-separated `login:password` pairs. Account rotation uses linear iteration with cooldown/disable/daily-limit checks. + +**Tech Stack:** TypeScript, Electron, React + +--- + +### Task 1: Create mega-debrid-accounts.ts parser module + +**Files:** +- Create: `src/shared/mega-debrid-accounts.ts` + +- [ ] Create `MegaDebridAccountEntry` interface (id, login, password, index, label, maskedLogin) +- [ ] Create `parseMegaDebridAccounts(raw: string): MegaDebridAccountEntry[]` - split by newlines, parse `login:password` pairs, deduplicate by login, generate stable IDs via FNV-1a hash (`mda_` prefix) +- [ ] Create `getMegaDebridAccountId(login: string): string` +- [ ] Create `maskMegaDebridLogin(login: string): string` +- [ ] Create `getMegaDebridAccountLabel(index: number): string` - "Account 1", "Account 2" +- [ ] Create `serializeMegaDebridAccounts(accounts: {login: string, password: string}[]): string` - back to newline-separated format +- [ ] Backward compat: if raw string has no `:` separator, treat as legacy single-login (use megaPassword from settings) + +### Task 2: Extend AppSettings with multi-account fields + +**Files:** +- Modify: `src/shared/types.ts` + +- [ ] Replace `megaLogin: string` → `megaCredentials: string` (newline-separated `login:password` pairs) +- [ ] Keep `megaPassword: string` for backward compat (migration reads it once) +- [ ] Add `megaDebridDisabledAccountIds: string[]` +- [ ] Add `megaDebridAccountDailyLimitBytes: Record` +- [ ] Add `megaDebridAccountDailyUsageBytes: Record` +- [ ] Add `megaDebridAccountTotalUsageBytes: Record` + +### Task 3: Add per-account daily limit functions + +**Files:** +- Modify: `src/shared/provider-daily-limits.ts` + +- [ ] Add `getMegaDebridAccountDailyLimitBytes(settings, accountId)` +- [ ] Add `getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs)` +- [ ] Add `isMegaDebridAccountDailyLimitReached(settings, accountId, epochMs)` +- [ ] Add `addMegaDebridAccountDailyUsageBytes(settings, accountId, bytes, epochMs)` +- [ ] Add `addMegaDebridAccountTotalUsageBytes(settings, accountId, bytes)` +- [ ] Add `isMegaDebridAccountDisabled(settings, accountId)` + +### Task 4: Migrate storage from single to multi-account + +**Files:** +- Modify: `src/main/storage.ts` + +- [ ] In `normalizeSettings`: migrate old `megaLogin`+`megaPassword` → `megaCredentials` format (`login:password`) +- [ ] Normalize new fields with defaults + +### Task 5: Implement account rotation in debrid.ts + +**Files:** +- Modify: `src/main/debrid.ts` + +- [ ] Add in-memory cooldown cache for Mega accounts (like `debridLinkKeyCooldowns`) +- [ ] Update `hasMegaDebridCredentials()` to check `parseMegaDebridAccounts().length > 0` +- [ ] Update Mega-Debrid API unrestrict to iterate accounts (skip disabled/limited/cooldown) +- [ ] Update Mega-Debrid Web unrestrict to iterate accounts +- [ ] Return `sourceAccountId` and `sourceAccountLabel` on success +- [ ] On failure: classify error, apply cooldown, try next account + +### Task 6: Update download-manager usage tracking + +**Files:** +- Modify: `src/main/download-manager.ts` + +- [ ] Track per-account bytes for Mega-Debrid (like Debrid-Link key tracking) +- [ ] Update `isProviderDailyLimited` to check if ANY Mega account is available + +### Task 7: Update UI for multi-account management + +**Files:** +- Modify: `src/renderer/App.tsx` + +- [ ] Update Mega-Debrid account dialog: textarea for credentials (`login:password` per line) +- [ ] Display account list with masked logins, enable/disable toggle, per-account daily limits +- [ ] Update account summary display to show individual accounts + +### Task 8: Tests + +- [ ] Unit tests for `parseMegaDebridAccounts` (parse, deduplicate, legacy compat) +- [ ] Unit tests for per-account daily limits +- [ ] Run full test suite: `npx vitest run` diff --git a/src/main/constants.ts b/src/main/constants.ts index 8febc99..a9c0b66 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -21,8 +21,8 @@ export const WRITE_BUFFER_SIZE = 512 * 1024; // 512 KB write buffer (JDown export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment export const STREAM_HIGH_WATER_MARK = 512 * 1024; // 512 KB stream buffer — lower than before (2 MB) so backpressure triggers sooner when disk is slow -export const DISK_BUSY_THRESHOLD_MS = 300; // Internal detection threshold for disk backpressure -export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes +export const DISK_BUSY_THRESHOLD_MS = 300; // Internal detection threshold for disk backpressure +export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]); export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]); @@ -46,6 +46,7 @@ export function defaultSettings(): AppSettings { realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", + megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, @@ -93,32 +94,36 @@ export function defaultSettings(): AppSettings { speedLimitMode: "global", updateRepo: DEFAULT_UPDATE_REPO, autoUpdateCheck: true, - clipboardWatch: false, - minimizeToTray: false, - theme: "dark" as const, - collapseNewPackages: true, - historyRetentionMode: "permanent", - accountListShowDetailedDebridLinkKeys: false, - autoSortPackagesByProgress: true, - autoSkipExtracted: false, - hideExtractedItems: true, - confirmDeleteSelection: true, - totalDownloadedAllTime: 0, - totalCompletedFilesAllTime: 0, - totalRuntimeAllTimeMs: 0, - bandwidthSchedules: [], + clipboardWatch: false, + minimizeToTray: false, + theme: "dark" as const, + collapseNewPackages: true, + historyRetentionMode: "permanent", + accountListShowDetailedDebridLinkKeys: false, + autoSortPackagesByProgress: true, + autoSkipExtracted: false, + hideExtractedItems: true, + confirmDeleteSelection: true, + totalDownloadedAllTime: 0, + totalCompletedFilesAllTime: 0, + totalRuntimeAllTimeMs: 0, + bandwidthSchedules: [], columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], extractCpuPriority: "high", autoExtractWhenStopped: true, disabledProviders: [], - hosterRouting: {}, - providerDailyLimitBytes: {}, - providerDailyUsageBytes: {}, - providerTotalUsageBytes: {}, - debridLinkApiKeyDailyLimitBytes: {}, - debridLinkApiKeyDailyUsageBytes: {}, - debridLinkApiKeyTotalUsageBytes: {}, - providerDailyUsageDay: getProviderUsageDayKey(), - scheduledStartEpochMs: 0 - }; -} + hosterRouting: {}, + providerDailyLimitBytes: {}, + providerDailyUsageBytes: {}, + providerTotalUsageBytes: {}, + debridLinkApiKeyDailyLimitBytes: {}, + debridLinkApiKeyDailyUsageBytes: {}, + debridLinkApiKeyTotalUsageBytes: {}, + megaDebridDisabledAccountIds: [], + megaDebridAccountDailyLimitBytes: {}, + megaDebridAccountDailyUsageBytes: {}, + megaDebridAccountTotalUsageBytes: {}, + providerDailyUsageDay: getProviderUsageDayKey(), + scheduledStartEpochMs: 0 + }; +} diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 400439c..451260c 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1,25 +1,26 @@ -import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types"; -import { isDebridLinkApiKeyDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; -import { APP_VERSION, REQUEST_RETRIES } from "./constants"; -import { logger } from "./logger"; -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"; +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 { 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"; const DEBRID_LINK_QUOTA_ERRORS = new Set(["maxLink", "maxLinkHost", "maxData", "maxDataHost"]); const DEBRID_LINK_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]); const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]); @@ -36,7 +37,7 @@ const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([ "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. */ +/** 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 }; @@ -94,326 +95,407 @@ function getDebridLinkKeyCooldownState( }; } +/** 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(); +} + +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 })), + +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 || {}) } + 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) - ); -} - -function hasMegaDebridCredentials(settings: AppSettings): boolean { - return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim()); -} - -function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean { - if (mode === "api") { - return settings.megaDebridApiEnabled - || (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi); - } - return settings.megaDebridWebEnabled - || (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi); -} - -function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider): DebridProvider { - if (provider !== "megadebrid") { - return provider; - } - if (isMegaDebridModeEnabled(settings, "api") && !isMegaDebridModeEnabled(settings, "web")) { - return "megadebrid-api"; - } - if (isMegaDebridModeEnabled(settings, "web") && !isMegaDebridModeEnabled(settings, "api")) { - return "megadebrid-web"; - } - return settings.megaDebridPreferApi ? "megadebrid-api" : "megadebrid-web"; -} - -type BestDebridRequest = { - url: string; - useAuthHeader: boolean; -}; - -function canonicalLink(link: string): string { - try { - const parsed = new URL(link); - return `${parsed.host.toLowerCase()}${parsed.pathname}${parsed.search}`; - } catch { - return link.trim().toLowerCase(); - } -} - -function shouldRetryStatus(status: number): boolean { - return status === 429 || status >= 500; -} - -function retryDelay(attempt: number): number { - return Math.min(5000, 400 * 2 ** attempt); -} - -function parseRetryAfterMs(value: string | null): number { - const text = String(value || "").trim(); - if (!text) { - return 0; - } - - const asSeconds = Number(text); - if (Number.isFinite(asSeconds) && asSeconds >= 0) { - return Math.min(120000, Math.floor(asSeconds * 1000)); - } - - const asDate = Date.parse(text); - if (Number.isFinite(asDate)) { - return Math.min(120000, 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 []; -} - + +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; + } + + const asSeconds = Number(text); + if (Number.isFinite(asSeconds) && asSeconds >= 0) { + return Math.min(120000, Math.floor(asSeconds * 1000)); + } + + const asDate = Date.parse(text); + if (Number.isFinite(asDate)) { + return Math.min(120000, 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)) { @@ -497,43 +579,43 @@ class DebridLinkApiError extends 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); + 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; @@ -550,1066 +632,1237 @@ async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: strin note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.` }; } - - 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: "" - }; - } 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, "")); -} - + + 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: "" + }; + } 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, "")); +} + 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; - - private static cachedApiToken = ""; - - private static cachedApiTokenAt = 0; - - private static pendingConnect: Promise<string | null> | null = null; - - 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 async connectApi(signal?: AbortSignal): Promise<string | null> { - // Return cached token if fresh (max 20 min) - if (MegaDebridClient.cachedApiToken && Date.now() - MegaDebridClient.cachedApiTokenAt < 20 * 60 * 1000) { - return MegaDebridClient.cachedApiToken; - } - - // Deduplicate parallel connectUser calls — only one in-flight request at a time - if (MegaDebridClient.pendingConnect) { - return MegaDebridClient.pendingConnect; - } - - MegaDebridClient.pendingConnect = this.doConnectApi(signal).finally(() => { - MegaDebridClient.pendingConnect = null; - }); - return MegaDebridClient.pendingConnect; - } - - 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) { - return null; - } - const payload = parseJsonSafe(text); - if (!payload || payload.response_code !== "ok") { - return null; - } - const token = String(payload.token || "").trim(); - if (!token) { - return null; - } - MegaDebridClient.cachedApiToken = token; - MegaDebridClient.cachedApiTokenAt = 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) { - MegaDebridClient.cachedApiToken = ""; - MegaDebridClient.cachedApiTokenAt = 0; - } - 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")) { - MegaDebridClient.cachedApiToken = ""; - MegaDebridClient.cachedApiTokenAt = 0; - } - 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); - } -} - -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 results: DebridLinkHostLimitInfo[] = []; - for (const apiKey of apiKeys) { - results.push(await fetchDebridLinkHostLimitForKey(apiKey, host, 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) { + const seen = new Set<DebridProvider>(); + const result: DebridProvider[] = []; + for (const provider of order) { + if (seen.has(provider)) { + continue; + } + seen.add(provider); + result.push(provider); + } + return result; +} + +function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvider, tertiary: DebridFallbackProvider): DebridProvider[] { + const order: DebridProvider[] = [primary]; + if (secondary !== "none") { + order.push(secondary); + } + if (tertiary !== "none") { + order.push(tertiary); + } + return uniqueProviderOrder(order); +} + +function isRapidgatorLink(link: string): boolean { + try { + const hostname = new URL(link).hostname.toLowerCase(); + return hostname === "rapidgator.net" + || hostname.endsWith(".rapidgator.net") + || hostname === "rg.to" + || hostname.endsWith(".rg.to") + || hostname === "rapidgator.asia" + || hostname.endsWith(".rapidgator.asia"); + } catch { + return false; + } +} + +function decodeHtmlEntities(text: string): string { + return text + .replace(/&/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 = [ + /<meta[^>]+(?:property=["']og:title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+property=["']og:title["'])/i, + /<meta[^>]+(?:name=["']title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+name=["']title["'])/i, + /<title>([^<]{1,260})<\/title>/i, + /(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]{1,260})\s*</i, + /(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]{1,260})/i + ]; + + for (const pattern of patterns) { + const match = html.match(pattern); + // Some patterns have multiple capture groups for attribute-order independence; + // pick the first non-empty group. + const raw = match?.[1] || match?.[2] || ""; + const normalized = normalizeResolvedFilename(raw); + if (normalized) { + return normalized; + } + } + + return ""; +} + +async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> { + if (items.length === 0) { + return; + } + const size = Math.max(1, Math.min(concurrency, items.length)); + let index = 0; + let firstError: unknown = null; + const next = (): T | undefined => { + if (firstError || index >= items.length) { + return undefined; + } + const item = items[index]; + index += 1; + return item; + }; + const runners = Array.from({ length: size }, async () => { + let current = next(); + while (current !== undefined) { + try { + await worker(current); + } catch (error) { + if (!firstError) { + firstError = error; + } + } + current = next(); + } + }); + await Promise.all(runners); + if (firstError) { + throw firstError; + } +} + +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + if (!signal) { + return AbortSignal.timeout(timeoutMs); + } + return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); +} + +async function readResponseTextLimited(response: Response, maxBytes: number, signal?: AbortSignal): Promise<string> { + const body = response.body; + if (!body) { + return ""; + } + + const reader = body.getReader(); + const chunks: Buffer[] = []; + let readBytes = 0; + + try { + while (readBytes < maxBytes) { + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } + + const { done, value } = await reader.read(); + if (done || !value || value.byteLength === 0) { + break; + } + + const remaining = maxBytes - readBytes; + const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value; + chunks.push(Buffer.from(slice)); + readBytes += slice.byteLength; + } + } finally { + try { + await reader.cancel(); + } catch { + // ignore + } + try { + reader.releaseLock(); + } catch { + // ignore + } + } + + return Buffer.concat(chunks).toString("utf8"); +} + +async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Promise<string> { + if (!isRapidgatorLink(link)) { + return ""; + } + const fromUrl = filenameFromRapidgatorUrlPath(link); + if (fromUrl) { + return fromUrl; + } + + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } + + for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) { + try { + const response = await fetch(link, { + method: "GET", + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,de;q=0.8" + }, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + if (!response.ok) { + try { await response.body?.cancel(); } catch { /* drain socket */ } + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + return ""; + } + + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const contentLength = Number(response.headers.get("content-length") || NaN); + if (contentType + && !contentType.includes("text/html") + && !contentType.includes("application/xhtml") + && !contentType.includes("text/plain") + && !contentType.includes("text/xml") + && !contentType.includes("application/xml")) { + try { await response.body?.cancel(); } catch { /* drain socket */ } + return ""; + } + if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) { + try { await response.body?.cancel(); } catch { /* drain socket */ } + return ""; + } + + const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal); + const fromHtml = extractRapidgatorFilenameFromHtml(html); + if (fromHtml) { + return fromHtml; + } + return ""; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + if (attempt >= REQUEST_RETRIES + 2 || !isRetryableErrorText(errorText)) { + return ""; + } + } + + if (attempt < REQUEST_RETRIES + 2) { + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + return ""; +} + +export interface RapidgatorCheckResult { + online: boolean; + fileName: string; + fileSize: string | null; +} + +const RG_FILE_ID_RE = /\/file\/([a-z0-9]{32}|\d+)/i; +const RG_FILE_NOT_FOUND_RE = />\s*404\s*File not found/i; +const RG_FILESIZE_RE = /File\s*size:\s*<strong>([^<>"]+)<\/strong>/i; + +export async function checkRapidgatorOnline( + link: string, + signal?: AbortSignal +): Promise<RapidgatorCheckResult | null> { + if (!isRapidgatorLink(link)) { + return null; + } + + const fileIdMatch = link.match(RG_FILE_ID_RE); + if (!fileIdMatch) { + return null; + } + const fileId = fileIdMatch[1]; + const headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,de;q=0.8" + }; + + // Fast path: HEAD request (no body download, much faster) + for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) { + try { + if (signal?.aborted) throw new Error("aborted:debrid"); + + const response = await fetch(link, { + method: "HEAD", + redirect: "follow", + headers, + signal: withTimeoutSignal(signal, 15000) + }); + + if (response.status === 404) { + return { online: false, fileName: "", fileSize: null }; + } + + if (response.ok) { + const finalUrl = response.url || link; + if (!finalUrl.includes(fileId)) { + return { online: false, fileName: "", fileSize: null }; + } + // HEAD 200 + URL still contains file ID → online + const fileName = filenameFromRapidgatorUrlPath(link); + return { online: true, fileName, fileSize: null }; + } + + // Non-OK, non-404: retry or give up + if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + + // HEAD inconclusive — fall through to GET + break; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error; + if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) { + break; // fall through to GET + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + // Slow path: GET request (downloads HTML, more thorough) + for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) { + try { + if (signal?.aborted) throw new Error("aborted:debrid"); + + const response = await fetch(link, { + method: "GET", + redirect: "follow", + headers, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + + if (response.status === 404) { + try { await response.body?.cancel(); } catch { /* drain socket */ } + return { online: false, fileName: "", fileSize: null }; + } + + if (!response.ok) { + try { await response.body?.cancel(); } catch { /* drain socket */ } + if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + return null; + } + + const finalUrl = response.url || link; + if (!finalUrl.includes(fileId)) { + try { await response.body?.cancel(); } catch { /* drain socket */ } + return { online: false, fileName: "", fileSize: null }; + } + + const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal); + + if (RG_FILE_NOT_FOUND_RE.test(html)) { + return { online: false, fileName: "", fileSize: null }; + } + + const fileName = extractRapidgatorFilenameFromHtml(html) || filenameFromRapidgatorUrlPath(link); + const sizeMatch = html.match(RG_FILESIZE_RE); + const fileSize = sizeMatch ? sizeMatch[1].trim() : null; + + return { online: true, fileName, fileSize }; + } catch (error) { + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error; + if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) { + return null; + } + } + + if (attempt <= REQUEST_RETRIES) { + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + return null; +} + +function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] { + const linkParam = encodeURIComponent(link); + const safeToken = String(token || "").trim(); + const useAuthHeader = Boolean(safeToken); + return [ + { + url: `${BEST_DEBRID_API_BASE}/generateLink?link=${linkParam}`, + useAuthHeader + } + ]; +} + +class MegaDebridClient { + private megaWebUnrestrict?: MegaWebUnrestrictor; + + private login: string; + + private password: string; + + private mode: "api" | "web"; + + private allowApiFallback: boolean; + + /** Per-account API token cache: login (lowercase) → { token, timestamp } */ + private static cachedApiTokens = new Map<string, { token: string; at: number }>(); + + /** Per-account pending connect deduplication: login (lowercase) → promise */ + private static pendingConnects = new Map<string, Promise<string | null>>(); + + 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) { + return null; + } + const payload = parseJsonSafe(text); + if (!payload || payload.response_code !== "ok") { + 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 hasMultiple = accounts.length > 1; + + // 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]; + const accountLabel = hasMultiple ? ` (${account.label})` : ""; + + if (isMegaDebridAccountDisabled(settings, account.id)) { + logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`); + continue; + } + if (isMegaDebridAccountDailyLimitReached(settings, account.id)) { + logger.info(`Mega-Debrid${accountLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Account`); + continue; + } + // Cooldown key includes mode so API failures don't block Web attempts + const cooldownKey = `${account.id}:${mode}`; + const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey); + if (accountCooldownState) { + logger.info(`Mega-Debrid${accountLabel}: uebersprungen (Cooldown bis ${new Date(accountCooldownState.until).toLocaleTimeString()}), pruefe naechsten Account`); + cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`); + if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) { + earliestCooldownUntil = accountCooldownState.until; + } + continue; + } + + usableAccountSeen = true; + try { + const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict); + const result = await client.unrestrictLink(link, signal); + clearMegaDebridAccountCooldownState(cooldownKey); + logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK -> ${result.fileName || "?"}`); + return { + ...result, + sourceLabel: account.label, + sourceAccountId: account.id, + sourceAccountLabel: account.label + }; + } catch (error) { + const failure = MegaDebridClient.classifyAccountFailure(error); + 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) { + throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`); + } + const cooldownInfo = failure.cooldownMs > 0 + ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s` + : ""; + logger.warn(`Mega-Debrid${accountLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Account`); + } + } + + 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" + }; + } + + // Temporary/transport errors — short cooldown, try next account + if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) { + return { + fatal: false, + cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, + message: errorText || "temporaerer Fehler", + category: "temporary" + }; + } + + // Unknown errors — short cooldown, try next account (non-fatal) + return { + fatal: false, + cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, + 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 results: DebridLinkHostLimitInfo[] = []; + for (const apiKey of apiKeys) { + results.push(await fetchDebridLinkHostLimitForKey(apiKey, host, 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)"); } @@ -1617,20 +1870,20 @@ class DebridLinkClient { let usableKeySeen = false; const cooldownFailures: string[] = []; let earliestCooldownUntil = 0; - - // 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]; - const keyLabel = this.apiKeys.length > 1 ? ` (${apiKey.label})` : ""; - if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) { - logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`); - continue; - } - if (isDebridLinkApiKeyDailyLimitReached(settings, apiKey.id)) { - logger.info(`Debrid-Link${keyLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Key`); - continue; - } + + // 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]; + const keyLabel = this.apiKeys.length > 1 ? ` (${apiKey.label})` : ""; + if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) { + logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`); + continue; + } + if (isDebridLinkApiKeyDailyLimitReached(settings, apiKey.id)) { + logger.info(`Debrid-Link${keyLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Key`); + continue; + } const keyCooldownState = getDebridLinkKeyCooldownState(apiKey.id); if (keyCooldownState) { logger.info(`Debrid-Link${keyLabel}: uebersprungen (Cooldown bis ${new Date(keyCooldownState.until).toLocaleTimeString()}), pruefe naechsten Key`); @@ -1951,801 +2204,810 @@ class DebridLinkClient { }; } } - -// ── 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 sleep(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 sleep(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)"); - } + +// ── 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 sleep(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 sleep(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 { - this.settings = cloneSettings(next); - } - - 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; - } - } - 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)"; - } - 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 new MegaDebridClient(settings.megaLogin, settings.megaPassword, "api", provider === "megadebrid" && settings.megaDebridPreferApi, this.options.megaWebUnrestrict).unrestrictLink(link, signal); - } - if (effectiveProvider === "megadebrid-web") { - return new MegaDebridClient(settings.megaLogin, settings.megaPassword, "web", false, this.options.megaWebUnrestrict).unrestrictLink(link, 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; - } -} + + 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 { + this.settings = cloneSettings(next); + } + + 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; + } +} diff --git a/src/main/storage.ts b/src/main/storage.ts index e2d7641..9dd72c3 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; +import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts"; import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { defaultSettings } from "./constants"; @@ -281,6 +282,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings { const currentUsageDay = getProviderUsageDayKey(); const megaLogin = asText(settings.megaLogin); const megaPassword = asText(settings.megaPassword); + // Migrate legacy single-account to multi-account format + let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim(); + if (!megaCredentials && megaLogin && megaPassword) { + megaCredentials = `${megaLogin}:${megaPassword}`; + } + const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials); const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true; const hasMegaCreds = Boolean(megaLogin && megaPassword); const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined @@ -322,6 +329,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), megaLogin, megaPassword, + megaCredentials, megaDebridApiEnabled, megaDebridWebEnabled, megaDebridPreferApi, @@ -406,6 +414,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings { debridLinkApiKeyDailyLimitBytes, debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {}, debridLinkApiKeyTotalUsageBytes, + megaDebridDisabledAccountIds: normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds), + megaDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.megaDebridAccountDailyLimitBytes, megaDebridAccountIds), + megaDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay + ? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds) + : {}, + megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds), providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) }; @@ -454,6 +468,7 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings { realDebridUseWebLogin: settings.realDebridUseWebLogin, megaLogin: "", megaPassword: "", + megaCredentials: "", bestToken: "", bestDebridUseWebLogin: settings.bestDebridUseWebLogin, allDebridToken: "", diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 15cf42a..594bdb3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,6 @@ import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; import type { AllDebridHostInfo, AppSettings, @@ -232,8 +233,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [ serviceLabel: "Mega-Debrid", title: "Mega-Debrid API", modeLabel: "API", - pickerDescription: "Login nur über die API, ohne Web-Fallback.", - needsCredentials: true + pickerDescription: "Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account.", + needsToken: true }, { kind: "megadebrid-web", @@ -241,8 +242,8 @@ const ACCOUNT_OPTIONS: AccountOption[] = [ serviceLabel: "Mega-Debrid", title: "Mega-Debrid Web", modeLabel: "Web", - pickerDescription: "Login nur über Web, ohne API-Fallback.", - needsCredentials: true + pickerDescription: "Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account.", + needsToken: true }, { kind: "bestdebrid-api", @@ -404,9 +405,9 @@ function getAccountPickerFunctionLabel(option: AccountOption): string { case "alldebrid-web": return "Browser-Login"; case "megadebrid-api": - return "Login + Passwort (API)"; + return "Login:Passwort (API)"; case "megadebrid-web": - return "Login + Passwort (Web)"; + return "Login:Passwort (Web)"; case "bestdebrid-web": return "Cookies.txt-Import"; case "alldebrid-api": @@ -420,6 +421,7 @@ function getAccountPickerFunctionLabel(option: AccountOption): string { } function hasMegaDebridCredentials(settings: AppSettings): boolean { + if (parseMegaDebridAccounts(settings.megaCredentials || "").length > 0) return true; return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim()); } @@ -527,8 +529,12 @@ function summarizeAccount(kind: AccountKind, settings: AppSettings): string { case "realdebrid-web": return "Browser-Login"; case "megadebrid-api": - case "megadebrid-web": - return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Login + Passwort"; + case "megadebrid-web": { + const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword); + if (megaAccounts.length > 1) return `${megaAccounts.length} Accounts`; + if (megaAccounts.length === 1) return megaAccounts[0].maskedLogin; + return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Nicht hinterlegt"; + } case "bestdebrid-api": return maskValue(settings.bestToken, 3, 3); case "bestdebrid-web": @@ -560,6 +566,12 @@ function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string return keys.map((entry) => `${entry.label}: ${entry.masked}`); } } + if (kind === "megadebrid-api" || kind === "megadebrid-web") { + const accounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword); + if (accounts.length > 1) { + return accounts.map((entry) => `${entry.label}: ${entry.maskedLogin}`); + } + } return [summarizeAccount(kind, settings)]; } @@ -583,8 +595,14 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n case "realdebrid-web": return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; case "megadebrid-api": - case "megadebrid-web": - return { mode, kind, token: "", login: settings.megaLogin, password: settings.megaPassword, dailyLimitGb, keyDailyLimitGbById: {} }; + case "megadebrid-web": { + // Populate token field with megaCredentials, or build from legacy megaLogin/megaPassword + let megaToken = (settings.megaCredentials || "").trim(); + if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) { + megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`; + } + return { mode, kind, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; + } case "bestdebrid-api": return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {} }; case "bestdebrid-web": @@ -642,10 +660,18 @@ function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDial return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "realdebrid-web": return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "megadebrid-api": - return { ...settings, megaLogin: login, megaPassword: password, megaDebridApiEnabled: true, megaDebridPreferApi: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "megadebrid-web": - return { ...settings, megaLogin: login, megaPassword: password, megaDebridWebEnabled: true, megaDebridPreferApi: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + case "megadebrid-api": { + const megaAccounts = parseMegaDebridAccounts(token); + const firstLogin = megaAccounts.length > 0 ? megaAccounts[0].login : ""; + const firstPassword = megaAccounts.length > 0 ? megaAccounts[0].password : ""; + return { ...settings, megaCredentials: token, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridPreferApi: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + } + case "megadebrid-web": { + const megaAccounts = parseMegaDebridAccounts(token); + const firstLogin = megaAccounts.length > 0 ? megaAccounts[0].login : ""; + const firstPassword = megaAccounts.length > 0 ? megaAccounts[0].password : ""; + return { ...settings, megaCredentials: token, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridPreferApi: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; + } case "bestdebrid-api": return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; case "bestdebrid-web": @@ -692,11 +718,11 @@ function clearAccountServiceFromSettings(settings: AppSettings, service: Account case "megadebrid-api": return settings.megaDebridWebEnabled ? { ...settings, megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } - : { ...settings, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + : { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "megadebrid-web": return settings.megaDebridApiEnabled ? { ...settings, megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes } - : { ...settings, megaLogin: "", megaPassword: "", megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; + : { ...settings, megaCredentials: "", megaLogin: "", megaPassword: "", megaDebridWebEnabled: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "bestdebrid": return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; case "alldebrid": @@ -729,6 +755,11 @@ function validateAccountDialog(dialog: AccountDialogState): string | null { if (option.needsToken && !dialog.token.trim()) { return `${option.title}: Bitte Zugangstoken eintragen.`; } + if ((dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") && dialog.token.trim()) { + if (parseMegaDebridAccounts(dialog.token).length === 0) { + return `${option.title}: Mindestens ein gültiges Login:Passwort-Paar eintragen (Format: login:passwort, pro Zeile).`; + } + } if (option.needsCredentials) { if (!dialog.login.trim()) { return `${option.title}: Bitte Login oder E-Mail eintragen.`; @@ -792,7 +823,7 @@ type StatsSection = { const emptySnapshot = (): UiSnapshot => ({ settings: { - token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", + token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", debridLinkDisabledKeyIds: [], archivePasswordList: "", rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", @@ -817,6 +848,10 @@ const emptySnapshot = (): UiSnapshot => ({ debridLinkApiKeyDailyLimitBytes: {}, debridLinkApiKeyDailyUsageBytes: {}, debridLinkApiKeyTotalUsageBytes: {}, + megaDebridDisabledAccountIds: [], + megaDebridAccountDailyLimitBytes: {}, + megaDebridAccountDailyUsageBytes: {}, + megaDebridAccountTotalUsageBytes: {}, providerDailyUsageDay: getProviderUsageDayKey(), scheduledStartEpochMs: 0 }, @@ -2082,8 +2117,12 @@ export function App(): ReactElement { let statusLabel = "Aktiviert"; let note = ""; if (kind === "megadebrid-api") { + const megaAccountCount = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword).length; + statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; note = "Nur API aktiv. Kein Web-Fallback."; } else if (kind === "megadebrid-web") { + const megaAccountCount = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword).length; + statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; note = "Nur Web aktiv. Kein API-Fallback."; } else if (kind === "realdebrid-web") { note = "Login kann bei Bedarf direkt aus der Liste geöffnet werden."; @@ -5006,10 +5045,8 @@ export function App(): ReactElement { <button className="btn" disabled={actionBusy} onClick={() => { void onOpenRealDebridLogin(); }}>Real-Debrid Web-Login öffnen</button> </> )} - <label>Mega-Debrid Login</label> - <input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} /> - <label>Mega-Debrid Passwort</label> - <input type="password" value={settingsDraft.megaPassword} onChange={(e) => setText("megaPassword", e.target.value)} /> + <label>Mega-Debrid Accounts (Login:Passwort pro Zeile)</label> + <textarea rows={3} value={settingsDraft.megaCredentials || ""} onChange={(e) => setText("megaCredentials", e.target.value)} style={{ fontFamily: "monospace", resize: "vertical" }} placeholder={"user@example.com:passwort"} /> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.megaDebridPreferApi} onChange={(e) => setBool("megaDebridPreferApi", e.target.checked)} /> Mega-Debrid bevorzugt über API (schneller, Fallback auf Web)</label> <label>BestDebrid API Token</label> <input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} /> @@ -5375,7 +5412,7 @@ export function App(): ReactElement { <div className="account-modal-fields"> {accountDialogOption.needsToken && ( <div> - <label>{accountDialogOption.service === "alldebrid" || accountDialogOption.service === "onefichier" || accountDialogOption.service === "debridlink" ? "API-Key(s)" : "Token"}</label> + <label>{accountDialogOption.service === "alldebrid" || accountDialogOption.service === "onefichier" || accountDialogOption.service === "debridlink" ? "API-Key(s)" : accountDialogOption.service === "megadebrid-api" || accountDialogOption.service === "megadebrid-web" ? "Login:Passwort (pro Zeile)" : "Token"}</label> {accountDialogOption.service === "debridlink" ? ( <textarea rows={4} @@ -5388,6 +5425,14 @@ export function App(): ReactElement { } : prev)} style={{ fontFamily: "monospace", resize: "vertical" }} /> + ) : accountDialogOption.service === "megadebrid-api" || accountDialogOption.service === "megadebrid-web" ? ( + <textarea + rows={4} + placeholder={"user1@example.com:passwort1\nuser2@example.com:passwort2"} + value={accountDialog.token} + onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} + style={{ fontFamily: "monospace", resize: "vertical" }} + /> ) : ( <input type="password" value={accountDialog.token} onChange={(event) => setAccountDialog((prev) => prev ? { ...prev, token: event.target.value } : prev)} /> )} @@ -5457,10 +5502,10 @@ export function App(): ReactElement { <div className="account-modal-note">Der Web-Login nutzt ein echtes Browserfenster, damit reCAPTCHA sauber läuft.</div> )} {accountDialog.kind === "megadebrid-api" && ( - <div className="account-modal-note">Dieser Account nutzt nur die Mega-Debrid API. Kein Web-Fallback.</div> + <div className="account-modal-note">Ein Login:Passwort-Paar pro Zeile. Mehrere Accounts werden rotierend genutzt. Dieser Account nutzt nur die Mega-Debrid API. Kein Web-Fallback.</div> )} {accountDialog.kind === "megadebrid-web" && ( - <div className="account-modal-note">Dieser Account nutzt nur Mega-Debrid Web. Kein API-Fallback.</div> + <div className="account-modal-note">Ein Login:Passwort-Paar pro Zeile. Mehrere Accounts werden rotierend genutzt. Dieser Account nutzt nur Mega-Debrid Web. Kein API-Fallback.</div> )} {accountDialogOption.service === "alldebrid" && allDebridHostInfo && ( diff --git a/src/shared/mega-debrid-accounts.ts b/src/shared/mega-debrid-accounts.ts new file mode 100644 index 0000000..c5901e2 --- /dev/null +++ b/src/shared/mega-debrid-accounts.ts @@ -0,0 +1,96 @@ +export interface MegaDebridAccountEntry { + id: string; + login: string; + password: string; + index: number; + label: string; + maskedLogin: string; +} + +const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n; +const FNV64_PRIME = 0x100000001b3n; +const FNV64_MASK = 0xffffffffffffffffn; + +function fnv1a64(text: string): string { + let hash = FNV64_OFFSET_BASIS; + for (const char of text) { + hash ^= BigInt(char.codePointAt(0) || 0); + hash = (hash * FNV64_PRIME) & FNV64_MASK; + } + return hash.toString(36); +} + +export function getMegaDebridAccountId(login: string): string { + return `mda_${fnv1a64(login.trim().toLowerCase())}`; +} + +export function maskMegaDebridLogin(login: string): string { + const trimmed = login.trim(); + if (!trimmed) { + return "Nicht hinterlegt"; + } + if (trimmed.length <= 4) { + return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`; + } + return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`; +} + +export function getMegaDebridAccountLabel(index: number): string { + return `Account ${index + 1}`; +} + +/** + * Parse newline-separated "login:password" pairs. + * Falls back to treating the entire string as a single login if no colon + * is found (backward compat with old megaLogin field). + */ +export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] { + const seen = new Set<string>(); + const lines = String(raw || "") + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean); + + const entries: MegaDebridAccountEntry[] = []; + for (const line of lines) { + const colonIdx = line.indexOf(":"); + let login: string; + let password: string; + if (colonIdx >= 0) { + login = line.slice(0, colonIdx).trim(); + password = line.slice(colonIdx + 1).trim(); + } else { + // Legacy format: just a login, use the provided fallback password + login = line; + password = legacyPassword; + } + if (!login || !password) { + continue; + } + const key = login.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + entries.push({ + id: getMegaDebridAccountId(login), + login, + password, + index: entries.length, + label: getMegaDebridAccountLabel(entries.length), + maskedLogin: maskMegaDebridLogin(login) + }); + } + return entries; +} + +export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string { + return accounts + .filter((a) => a.login.trim() && a.password.trim()) + .map((a) => `${a.login.trim()}:${a.password.trim()}`) + .join("\n"); +} + +export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] { + return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id); +} diff --git a/src/shared/provider-daily-limits.ts b/src/shared/provider-daily-limits.ts index dde1ff6..59679a3 100644 --- a/src/shared/provider-daily-limits.ts +++ b/src/shared/provider-daily-limits.ts @@ -5,11 +5,13 @@ export type DebridLinkKeyByteMap = Record<string, number>; type ProviderDailySettings = Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay"> - & Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>; + & Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">> + & Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>; type ProviderUsageSettings = ProviderDailySettings - & Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>; + & Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">> + & Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>; function normalizePositiveBytes(value: unknown): number { const numeric = Number(value); @@ -247,3 +249,83 @@ export function addDebridLinkApiKeyTotalUsageBytes( debridLinkApiKeyTotalUsageBytes: currentUsageBytes }; } + +// ── Mega-Debrid per-account limits ── + +export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean { + return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId); +} + +export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number { + return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]); +} + +export function getMegaDebridAccountDailyUsageBytes( + settings: ProviderDailySettings, + accountId: string, + epochMs = Date.now() +): number { + if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) { + return 0; + } + return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]); +} + +export function isMegaDebridAccountDailyLimitReached( + settings: ProviderDailySettings, + accountId: string, + epochMs = Date.now() +): boolean { + const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId); + return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit; +} + +export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number { + return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]); +} + +export function addMegaDebridAccountDailyUsageBytes( + settings: ProviderDailySettings, + accountId: string, + byteDelta: number, + epochMs = Date.now() +): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> { + const increment = normalizePositiveBytes(byteDelta); + const dayKey = getProviderUsageDayKey(epochMs); + const currentUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.megaDebridAccountDailyUsageBytes || {}) } + : {}; + if (increment <= 0) { + return { + providerDailyUsageDay: dayKey, + megaDebridAccountDailyUsageBytes: currentUsageBytes + }; + } + + currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment; + + return { + providerDailyUsageDay: dayKey, + megaDebridAccountDailyUsageBytes: currentUsageBytes + }; +} + +export function addMegaDebridAccountTotalUsageBytes( + settings: ProviderUsageSettings, + accountId: string, + byteDelta: number +): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> { + const increment = normalizePositiveBytes(byteDelta); + const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) }; + if (increment <= 0) { + return { + megaDebridAccountTotalUsageBytes: currentUsageBytes + }; + } + + currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment; + + return { + megaDebridAccountTotalUsageBytes: currentUsageBytes + }; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 8f8d0f4..23a1253 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -27,9 +27,9 @@ export type DebridProvider = | "linksnappy"; export type DebridFallbackProvider = DebridProvider | "none"; export type AppTheme = "dark" | "light"; -export type PackagePriority = "high" | "normal" | "low"; -export type ExtractCpuPriority = "high" | "middle" | "low"; -export type HistoryRetentionMode = "never" | "session" | "permanent"; +export type PackagePriority = "high" | "normal" | "low"; +export type ExtractCpuPriority = "high" | "middle" | "low"; +export type HistoryRetentionMode = "never" | "session" | "permanent"; export interface BandwidthScheduleEntry { id: string; @@ -39,25 +39,26 @@ export interface BandwidthScheduleEntry { enabled: boolean; } -export interface DownloadStats { - totalDownloaded: number; - totalDownloadedAllTime: number; - totalFiles?: number; - totalFilesSession: number; - totalFilesAllTime: number; - totalPackages: number; - sessionStartedAt: number; - appSessionStartedAt: number; - sessionRuntimeMs: number; - totalRuntimeMs: number; - runtimeMeasuredAt: number; -} +export interface DownloadStats { + totalDownloaded: number; + totalDownloadedAllTime: number; + totalFiles?: number; + totalFilesSession: number; + totalFilesAllTime: number; + totalPackages: number; + sessionStartedAt: number; + appSessionStartedAt: number; + sessionRuntimeMs: number; + totalRuntimeMs: number; + runtimeMeasuredAt: number; +} export interface AppSettings { token: string; realDebridUseWebLogin: boolean; megaLogin: string; megaPassword: string; + megaCredentials: string; megaDebridApiEnabled: boolean; megaDebridWebEnabled: boolean; megaDebridPreferApi: boolean; @@ -74,7 +75,7 @@ export interface AppSettings { linkSnappyPassword: string; archivePasswordList: string; rememberToken: boolean; - providerOrder: readonly DebridProvider[]; + providerOrder: readonly DebridProvider[]; providerPrimary: DebridProvider; providerSecondary: DebridFallbackProvider; providerTertiary: DebridFallbackProvider; @@ -107,32 +108,36 @@ export interface AppSettings { autoUpdateCheck: boolean; clipboardWatch: boolean; minimizeToTray: boolean; - theme: AppTheme; - collapseNewPackages: boolean; - historyRetentionMode: HistoryRetentionMode; - accountListShowDetailedDebridLinkKeys: boolean; - autoSortPackagesByProgress: boolean; + theme: AppTheme; + collapseNewPackages: boolean; + historyRetentionMode: HistoryRetentionMode; + accountListShowDetailedDebridLinkKeys: boolean; + autoSortPackagesByProgress: boolean; autoSkipExtracted: boolean; - hideExtractedItems: boolean; - confirmDeleteSelection: boolean; - totalDownloadedAllTime: number; - totalCompletedFilesAllTime: number; - totalRuntimeAllTimeMs: number; - bandwidthSchedules: BandwidthScheduleEntry[]; - columnOrder: string[]; - extractCpuPriority: ExtractCpuPriority; + hideExtractedItems: boolean; + confirmDeleteSelection: boolean; + totalDownloadedAllTime: number; + totalCompletedFilesAllTime: number; + totalRuntimeAllTimeMs: number; + bandwidthSchedules: BandwidthScheduleEntry[]; + columnOrder: string[]; + extractCpuPriority: ExtractCpuPriority; autoExtractWhenStopped: boolean; disabledProviders: DebridProvider[]; - hosterRouting: Record<string, DebridProvider>; - providerDailyLimitBytes: Partial<Record<DebridProvider, number>>; - providerDailyUsageBytes: Partial<Record<DebridProvider, number>>; - providerTotalUsageBytes: Partial<Record<DebridProvider, number>>; - debridLinkApiKeyDailyLimitBytes: Record<string, number>; - debridLinkApiKeyDailyUsageBytes: Record<string, number>; - debridLinkApiKeyTotalUsageBytes: Record<string, number>; - providerDailyUsageDay: string; - scheduledStartEpochMs: number; -} + hosterRouting: Record<string, DebridProvider>; + providerDailyLimitBytes: Partial<Record<DebridProvider, number>>; + providerDailyUsageBytes: Partial<Record<DebridProvider, number>>; + providerTotalUsageBytes: Partial<Record<DebridProvider, number>>; + debridLinkApiKeyDailyLimitBytes: Record<string, number>; + debridLinkApiKeyDailyUsageBytes: Record<string, number>; + debridLinkApiKeyTotalUsageBytes: Record<string, number>; + megaDebridDisabledAccountIds: string[]; + megaDebridAccountDailyLimitBytes: Record<string, number>; + megaDebridAccountDailyUsageBytes: Record<string, number>; + megaDebridAccountTotalUsageBytes: Record<string, number>; + providerDailyUsageDay: string; + scheduledStartEpochMs: number; +} export interface DownloadItem { id: string; @@ -167,14 +172,14 @@ export interface PackageEntry { status: DownloadStatus; itemIds: string[]; cancelled: boolean; - enabled: boolean; - priority?: PackagePriority; - postProcessLabel?: string; - downloadStartedAt?: number; - downloadCompletedAt?: number; - createdAt: number; - updatedAt: number; -} + enabled: boolean; + priority?: PackagePriority; + postProcessLabel?: string; + downloadStartedAt?: number; + downloadCompletedAt?: number; + createdAt: number; + updatedAt: number; +} export interface SessionState { version: number; @@ -338,104 +343,104 @@ export interface BandwidthStats { sessionDurationSeconds: number; } -export interface SessionStats { - bandwidth: BandwidthStats; - totalDownloads: number; - completedDownloads: number; - failedDownloads: number; - activeDownloads: number; - queuedDownloads: number; -} - -export interface SupportTraceConfig { - enabled: boolean; - includeMainLog: boolean; - includeAudit: boolean; - logDebugRequests: boolean; - autoDisableAt: string | null; - updatedAt: string; -} - -export interface SupportFileSizeInfo { - path: string | null; - exists: boolean; - bytes: number; -} - -export interface SupportDirectorySizeInfo { - path: string; - exists: boolean; - fileCount: number; - bytes: number; -} - -export interface SupportDiskSpaceInfo { - path: string; - totalBytes: number | null; - freeBytes: number | null; - freePercent: number | null; -} - -export interface SupportBundleEstimate { - estimatedBytes: number; - estimatedEntries: number; - duplicatedLiveLogBytes: number; - note: string; -} - -export interface DebugSetupCheckResult { - status: "ok" | "warn"; - enabled: boolean; - runtimeBaseDir: string; - host: string; - port: number; - localOnly: boolean; - tokenConfigured: boolean; - tokenPath: string; - aiManifestPath: string; - aiManifestPresent: boolean; - traceConfigPath: string | null; - traceLogPath: string | null; - traceEnabled: boolean; - traceAutoDisableAt: string | null; - diskSpace: { - runtime: SupportDiskSpaceInfo; - output: SupportDiskSpaceInfo; - extract: SupportDiskSpaceInfo; - }; - logSummary: { - totalBytes: number; - main: SupportFileSizeInfo; - mainBackup: SupportFileSizeInfo; - audit: SupportFileSizeInfo; - auditBackup: SupportFileSizeInfo; - rename: SupportFileSizeInfo; - renameBackup: SupportFileSizeInfo; - session: SupportFileSizeInfo; - trace: SupportFileSizeInfo; - traceBackup: SupportFileSizeInfo; - sessionLogs: SupportDirectorySizeInfo; - packageLogs: SupportDirectorySizeInfo; - itemLogs: SupportDirectorySizeInfo; - }; - supportBundle: SupportBundleEstimate; - warnings: string[]; - notes: string[]; - localUrls: { - health: string; - meta: string; - diagnostics: string; - }; - remoteUrlTemplates: { - health: string; - meta: string; - diagnostics: string; - }; -} - -export interface HistoryEntry { - id: string; - name: string; +export interface SessionStats { + bandwidth: BandwidthStats; + totalDownloads: number; + completedDownloads: number; + failedDownloads: number; + activeDownloads: number; + queuedDownloads: number; +} + +export interface SupportTraceConfig { + enabled: boolean; + includeMainLog: boolean; + includeAudit: boolean; + logDebugRequests: boolean; + autoDisableAt: string | null; + updatedAt: string; +} + +export interface SupportFileSizeInfo { + path: string | null; + exists: boolean; + bytes: number; +} + +export interface SupportDirectorySizeInfo { + path: string; + exists: boolean; + fileCount: number; + bytes: number; +} + +export interface SupportDiskSpaceInfo { + path: string; + totalBytes: number | null; + freeBytes: number | null; + freePercent: number | null; +} + +export interface SupportBundleEstimate { + estimatedBytes: number; + estimatedEntries: number; + duplicatedLiveLogBytes: number; + note: string; +} + +export interface DebugSetupCheckResult { + status: "ok" | "warn"; + enabled: boolean; + runtimeBaseDir: string; + host: string; + port: number; + localOnly: boolean; + tokenConfigured: boolean; + tokenPath: string; + aiManifestPath: string; + aiManifestPresent: boolean; + traceConfigPath: string | null; + traceLogPath: string | null; + traceEnabled: boolean; + traceAutoDisableAt: string | null; + diskSpace: { + runtime: SupportDiskSpaceInfo; + output: SupportDiskSpaceInfo; + extract: SupportDiskSpaceInfo; + }; + logSummary: { + totalBytes: number; + main: SupportFileSizeInfo; + mainBackup: SupportFileSizeInfo; + audit: SupportFileSizeInfo; + auditBackup: SupportFileSizeInfo; + rename: SupportFileSizeInfo; + renameBackup: SupportFileSizeInfo; + session: SupportFileSizeInfo; + trace: SupportFileSizeInfo; + traceBackup: SupportFileSizeInfo; + sessionLogs: SupportDirectorySizeInfo; + packageLogs: SupportDirectorySizeInfo; + itemLogs: SupportDirectorySizeInfo; + }; + supportBundle: SupportBundleEstimate; + warnings: string[]; + notes: string[]; + localUrls: { + health: string; + meta: string; + diagnostics: string; + }; + remoteUrlTemplates: { + health: string; + meta: string; + diagnostics: string; + }; +} + +export interface HistoryEntry { + id: string; + name: string; totalBytes: number; downloadedBytes: number; fileCount: number; diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index a7d73b5..e501aae 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -1,16 +1,17 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants"; -import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; -import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; -import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, normalizeResolvedFilename, resetDebridLinkRuntimeStateForTests } from "../src/main/debrid"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants"; +import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; +import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; +import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, normalizeResolvedFilename, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; const originalFetch = globalThis.fetch; -afterEach(() => { - globalThis.fetch = originalFetch; - resetDebridLinkRuntimeStateForTests(); - vi.restoreAllMocks(); -}); +afterEach(() => { + globalThis.fetch = originalFetch; + resetDebridLinkRuntimeStateForTests(); + resetMegaDebridRuntimeStateForTests(); + vi.restoreAllMocks(); +}); describe("debrid service", () => { it("falls back to Mega web when Real-Debrid fails", async () => { @@ -19,6 +20,7 @@ describe("debrid service", () => { token: "rd-token", megaLogin: "user", megaPassword: "pass", + megaCredentials: "user:pass", bestToken: "", providerOrder: [] as const, providerPrimary: "realdebrid" as const, @@ -52,12 +54,13 @@ describe("debrid service", () => { expect(megaWeb).toHaveBeenCalledTimes(1); }); - it("does not fallback when auto fallback is disabled", async () => { + it("does not fallback when auto fallback is disabled", async () => { const settings = { ...defaultSettings(), token: "rd-token", megaLogin: "user", megaPassword: "pass", + megaCredentials: "user:pass", providerPrimary: "realdebrid" as const, providerSecondary: "megadebrid" as const, providerTertiary: "bestdebrid" as const, @@ -80,464 +83,464 @@ describe("debrid service", () => { })); const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); - await expect(service.unrestrictLink("https://rapidgator.net/file/example.part2.rar.html")).rejects.toThrow(); - expect(megaWeb).toHaveBeenCalledTimes(0); - }); - - it("skips a provider whose daily limit is already reached and uses the next provider", async () => { - const calledUrls: string[] = []; - const settings = { - ...defaultSettings(), - token: "rd-token", - debridLinkApiKeys: "dl-token", - providerOrder: ["realdebrid", "debridlink"] as const, - providerPrimary: "realdebrid" as const, - providerSecondary: "debridlink" as const, - providerTertiary: "none" as const, - autoProviderFallback: true, - providerDailyLimitBytes: { realdebrid: 100 }, - providerDailyUsageBytes: { realdebrid: 100 }, - providerDailyUsageDay: getProviderUsageDayKey() - }; - - globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - calledUrls.push(url); - if (url.includes("debrid-link.com/api/v2/downloader/add")) { - return new Response(JSON.stringify({ - success: true, - value: { - downloadUrl: "https://debrid-link.example/file.bin", - name: "file.bin", - size: 1234 - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { - throw new Error("Real-Debrid should have been skipped due to daily limit"); - } - return new Response("not-found", { status: 404 }); - }) as typeof fetch; - - const service = new DebridService(settings); - const result = await service.unrestrictLink("https://hoster.example/file.bin"); - expect(result.provider).toBe("debridlink"); - expect(result.directUrl).toBe("https://debrid-link.example/file.bin"); - expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false); - }); - - it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => { - const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); - let usedAuthHeader = ""; - const settings = { - ...defaultSettings(), - debridLinkApiKeys: "dl-key-one\ndl-key-two", - providerOrder: ["debridlink"] as const, - providerPrimary: "debridlink" as const, - providerSecondary: "none" as const, - providerTertiary: "none" as const, - debridLinkApiKeyDailyLimitBytes: { - [keys[0].id]: 100 - }, - debridLinkApiKeyDailyUsageBytes: { - [keys[0].id]: 100 - }, - providerDailyUsageDay: getProviderUsageDayKey() - }; - - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { - const headers = init?.headers; - if (headers instanceof Headers) { - usedAuthHeader = headers.get("Authorization") || ""; - } else if (Array.isArray(headers)) { - usedAuthHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; - } else { - usedAuthHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || ""); - } - return new Response(JSON.stringify({ - success: true, - value: { - downloadUrl: "https://debrid-link.example/file.bin", - name: "file.bin", - size: 1234 - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - }) as typeof fetch; - - const service = new DebridService(settings); - const result = await service.unrestrictLink("https://hoster.example/file.bin"); - - expect(usedAuthHeader).toBe("Bearer dl-key-two"); - expect(result.provider).toBe("debridlink"); - expect(result.providerLabel).toContain("Key 2"); - }); - - it("uses JSON add payload and refreshes missing Debrid-Link downloadUrl via downloader/list", async () => { - const settings = { - ...defaultSettings(), - debridLinkApiKeys: "dl-key-one", - providerOrder: ["debridlink"] as const, - providerPrimary: "debridlink" as const, - providerSecondary: "none" as const, - providerTertiary: "none" as const, - autoProviderFallback: true - }; - - let addBody = ""; - let addContentType = ""; - let addAccept = ""; - const calledUrls: string[] = []; - - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - calledUrls.push(url); - if (url.includes("debrid-link.com/api/v2/downloader/add")) { - const headers = init?.headers; - if (headers instanceof Headers) { - addContentType = headers.get("Content-Type") || ""; - addAccept = headers.get("Accept") || ""; - } else if (Array.isArray(headers)) { - addContentType = headers.find(([key]) => key.toLowerCase() === "content-type")?.[1] || ""; - addAccept = headers.find(([key]) => key.toLowerCase() === "accept")?.[1] || ""; - } else { - addContentType = String((headers as Record<string, unknown> | undefined)?.["Content-Type"] || ""); - addAccept = String((headers as Record<string, unknown> | undefined)?.Accept || ""); - } - addBody = String(init?.body || ""); - return new Response(JSON.stringify({ - success: true, - value: { - id: "dl-link-1", - url: "https://hoster.example/file.bin", - name: "file.bin", - expired: true - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - if (url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1")) { - return new Response(JSON.stringify({ - success: true, - value: [ - { - id: "dl-link-1", - url: "https://hoster.example/file.bin", - name: "file.bin", - downloadUrl: "https://debrid-link.example/file.bin", - size: 1234, - expired: false - } - ] - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - return new Response("not-found", { status: 404 }); - }) as typeof fetch; - - const service = new DebridService(settings); - const result = await service.unrestrictLink("https://hoster.example/file.bin"); - - expect(addContentType).toBe("application/json"); - expect(addAccept).toBe("application/json"); - expect(addBody).toBe(JSON.stringify({ url: "https://hoster.example/file.bin" })); - expect(result.provider).toBe("debridlink"); - expect(result.directUrl).toBe("https://debrid-link.example/file.bin"); - expect(calledUrls.some((url) => url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1"))).toBe(true); - }); - - it("rotates to the next Debrid-Link key when the first key is invalid", async () => { - const settings = { - ...defaultSettings(), - debridLinkApiKeys: "dl-key-one\ndl-key-two", - providerOrder: ["debridlink"] as const, - providerPrimary: "debridlink" as const, - providerSecondary: "none" as const, - providerTertiary: "none" as const, - autoProviderFallback: true - }; - - const authHeaders: string[] = []; - - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { - const headers = init?.headers; - let authHeader = ""; - if (headers instanceof Headers) { - authHeader = headers.get("Authorization") || ""; - } else if (Array.isArray(headers)) { - authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; - } else { - authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || ""); - } - authHeaders.push(authHeader); - if (authHeader === "Bearer dl-key-one") { - return new Response(JSON.stringify({ - success: false, - error: "badToken", - error_description: "token expired" - }), { - status: 401, - headers: { "Content-Type": "application/json" } - }); - } - return new Response(JSON.stringify({ - success: true, - value: { - downloadUrl: "https://debrid-link.example/valid.bin", - name: "valid.bin", - size: 2048 - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - }) as typeof fetch; - - const service = new DebridService(settings); - const result = await service.unrestrictLink("https://hoster.example/needs-rotation.bin"); - - expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); - expect(result.provider).toBe("debridlink"); - expect(result.providerLabel).toContain("Key 2"); - expect(result.directUrl).toBe("https://debrid-link.example/valid.bin"); - }); - - it("looks up limits and rotates keys when Debrid-Link host quota is reached", async () => { - const settings = { - ...defaultSettings(), - debridLinkApiKeys: "dl-key-one\ndl-key-two", - providerOrder: ["debridlink"] as const, - providerPrimary: "debridlink" as const, - providerSecondary: "none" as const, - providerTertiary: "none" as const, - autoProviderFallback: true - }; - - let limitCalls = 0; - const authHeaders: string[] = []; - - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - const headers = init?.headers; - let authHeader = ""; - if (headers instanceof Headers) { - authHeader = headers.get("Authorization") || ""; - } else if (Array.isArray(headers)) { - authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; - } else { - authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || ""); - } - - if (url.includes("debrid-link.com/api/v2/downloader/limits")) { - limitCalls += 1; - return new Response(JSON.stringify({ - success: true, - value: { - nextResetSeconds: { value: 900 } - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - - authHeaders.push(authHeader); - if (authHeader === "Bearer dl-key-one") { - return new Response(JSON.stringify({ - success: false, - error: "maxDataHost", - error_description: "host quota reached" - }), { - status: 403, - headers: { "Content-Type": "application/json" } - }); - } - - return new Response(JSON.stringify({ - success: true, - value: { - downloadUrl: "https://debrid-link.example/quota-ok.bin", - name: "quota-ok.bin", - size: 4096 - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - }) as typeof fetch; - - const service = new DebridService(settings); - const result = await service.unrestrictLink("https://rapidgator.net/file/quota-test"); - - expect(limitCalls).toBe(1); - expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); - expect(result.provider).toBe("debridlink"); - expect(result.providerLabel).toContain("Key 2"); - expect(result.directUrl).toBe("https://debrid-link.example/quota-ok.bin"); - }); - - it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => { - const settings = { - ...defaultSettings(), - debridLinkApiKeys: "dl-key-one\ndl-key-two", - providerOrder: ["debridlink"] as const, - providerPrimary: "debridlink" as const, - providerSecondary: "none" as const, - providerTertiary: "none" as const, - autoProviderFallback: true - }; - - const authHeaders: string[] = []; - - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { - const headers = init?.headers; - let authHeader = ""; - if (headers instanceof Headers) { - authHeader = headers.get("Authorization") || ""; - } else if (Array.isArray(headers)) { - authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; - } else { - authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || ""); - } - authHeaders.push(authHeader); - return new Response(JSON.stringify({ - success: false, - error: "badFilePassword", - error_description: "wrong password" - }), { - status: 400, - headers: { "Content-Type": "application/json" } - }); - }) as typeof fetch; - - const service = new DebridService(settings); - await expect(service.unrestrictLink("https://hoster.example/protected.bin")).rejects.toThrow("wrong password"); - expect(authHeaders).toEqual(["Bearer dl-key-one"]); - }); - - it("returns a cooldown marker when all Debrid-Link keys are temporarily cooling down", async () => { - const settings = { - ...defaultSettings(), - debridLinkApiKeys: "dl-key-one\ndl-key-two", - providerOrder: ["debridlink"] as const, - providerPrimary: "debridlink" as const, - providerSecondary: "none" as const, - providerTertiary: "none" as const, - autoProviderFallback: true - }; - - let addCalls = 0; - - globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (!url.includes("debrid-link.com/api/v2/downloader/add")) { - return new Response("not-found", { status: 404 }); - } - addCalls += 1; - return new Response(JSON.stringify({ - success: false, - error: "floodDetected", - error_description: "too many requests" - }), { - status: 403, - headers: { "Content-Type": "application/json" } - }); - }) as typeof fetch; - - const service = new DebridService(settings); - await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow("API-Rate-Limit erreicht"); - await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow(/debrid_link_cooldown:\d+:/i); - expect(addCalls).toBe(2); - }); - - it("fails fast on provider-wide Debrid-Link notDebrid errors without rotating through all keys", async () => { - const settings = { - ...defaultSettings(), - debridLinkApiKeys: "dl-key-one\ndl-key-two", - providerOrder: ["debridlink"] as const, - providerPrimary: "debridlink" as const, - providerSecondary: "none" as const, - providerTertiary: "none" as const, - autoProviderFallback: true - }; - - const authHeaders: string[] = []; - - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { - authHeaders.push(String((init?.headers as Record<string, string> | undefined)?.Authorization || "")); - return new Response(JSON.stringify({ - success: false, - error: "notDebrid", - error_description: "notDebrid" - }), { - status: 403, - headers: { "Content-Type": "application/json" } - }); - }) as typeof fetch; - - const service = new DebridService(settings); - await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow("Link kann aktuell nicht generiert werden (notDebrid: notDebrid)"); - expect(authHeaders).toEqual(["Bearer dl-key-one"]); - }); - - it("continues to the next Debrid-Link key for non-provider-wide skip errors without caching a cooldown", async () => { - const settings = { - ...defaultSettings(), - debridLinkApiKeys: "dl-key-one\ndl-key-two", - providerOrder: ["debridlink"] as const, - providerPrimary: "debridlink" as const, - providerSecondary: "none" as const, - providerTertiary: "none" as const, - autoProviderFallback: true - }; - - const authHeaders: string[] = []; - - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { - const authHeader = String((init?.headers as Record<string, string> | undefined)?.Authorization || ""); - authHeaders.push(authHeader); - if (authHeader === "Bearer dl-key-one") { - return new Response(JSON.stringify({ - success: false, - error: "noServerHost", - error_description: "host temporarily unavailable" - }), { - status: 403, - headers: { "Content-Type": "application/json" } - }); - } - return new Response(JSON.stringify({ - success: true, - value: { - downloadUrl: "https://debrid-link.example/second-key.bin", - name: "second-key.bin", - size: 4096 - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - }) as typeof fetch; - - const service = new DebridService(settings); - const result = await service.unrestrictLink("https://hoster.example/skip-key.bin"); - expect(result.directUrl).toBe("https://debrid-link.example/second-key.bin"); - expect(result.sourceAccountLabel).toBe("Key 2"); - expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); - }); - - it("uses BestDebrid auth header without token query fallback", async () => { - const settings = { - ...defaultSettings(), + await expect(service.unrestrictLink("https://rapidgator.net/file/example.part2.rar.html")).rejects.toThrow(); + expect(megaWeb).toHaveBeenCalledTimes(0); + }); + + it("skips a provider whose daily limit is already reached and uses the next provider", async () => { + const calledUrls: string[] = []; + const settings = { + ...defaultSettings(), + token: "rd-token", + debridLinkApiKeys: "dl-token", + providerOrder: ["realdebrid", "debridlink"] as const, + providerPrimary: "realdebrid" as const, + providerSecondary: "debridlink" as const, + providerTertiary: "none" as const, + autoProviderFallback: true, + providerDailyLimitBytes: { realdebrid: 100 }, + providerDailyUsageBytes: { realdebrid: 100 }, + providerDailyUsageDay: getProviderUsageDayKey() + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calledUrls.push(url); + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/file.bin", + name: "file.bin", + size: 1234 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + throw new Error("Real-Debrid should have been skipped due to daily limit"); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/file.bin"); + expect(result.provider).toBe("debridlink"); + expect(result.directUrl).toBe("https://debrid-link.example/file.bin"); + expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false); + }); + + it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => { + const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); + let usedAuthHeader = ""; + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + debridLinkApiKeyDailyLimitBytes: { + [keys[0].id]: 100 + }, + debridLinkApiKeyDailyUsageBytes: { + [keys[0].id]: 100 + }, + providerDailyUsageDay: getProviderUsageDayKey() + }; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + const headers = init?.headers; + if (headers instanceof Headers) { + usedAuthHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + usedAuthHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + usedAuthHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || ""); + } + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/file.bin", + name: "file.bin", + size: 1234 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/file.bin"); + + expect(usedAuthHeader).toBe("Bearer dl-key-two"); + expect(result.provider).toBe("debridlink"); + expect(result.providerLabel).toContain("Key 2"); + }); + + it("uses JSON add payload and refreshes missing Debrid-Link downloadUrl via downloader/list", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let addBody = ""; + let addContentType = ""; + let addAccept = ""; + const calledUrls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calledUrls.push(url); + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + const headers = init?.headers; + if (headers instanceof Headers) { + addContentType = headers.get("Content-Type") || ""; + addAccept = headers.get("Accept") || ""; + } else if (Array.isArray(headers)) { + addContentType = headers.find(([key]) => key.toLowerCase() === "content-type")?.[1] || ""; + addAccept = headers.find(([key]) => key.toLowerCase() === "accept")?.[1] || ""; + } else { + addContentType = String((headers as Record<string, unknown> | undefined)?.["Content-Type"] || ""); + addAccept = String((headers as Record<string, unknown> | undefined)?.Accept || ""); + } + addBody = String(init?.body || ""); + return new Response(JSON.stringify({ + success: true, + value: { + id: "dl-link-1", + url: "https://hoster.example/file.bin", + name: "file.bin", + expired: true + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1")) { + return new Response(JSON.stringify({ + success: true, + value: [ + { + id: "dl-link-1", + url: "https://hoster.example/file.bin", + name: "file.bin", + downloadUrl: "https://debrid-link.example/file.bin", + size: 1234, + expired: false + } + ] + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/file.bin"); + + expect(addContentType).toBe("application/json"); + expect(addAccept).toBe("application/json"); + expect(addBody).toBe(JSON.stringify({ url: "https://hoster.example/file.bin" })); + expect(result.provider).toBe("debridlink"); + expect(result.directUrl).toBe("https://debrid-link.example/file.bin"); + expect(calledUrls.some((url) => url.includes("debrid-link.com/api/v2/downloader/list?ids=dl-link-1"))).toBe(true); + }); + + it("rotates to the next Debrid-Link key when the first key is invalid", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const authHeaders: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + const headers = init?.headers; + let authHeader = ""; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || ""); + } + authHeaders.push(authHeader); + if (authHeader === "Bearer dl-key-one") { + return new Response(JSON.stringify({ + success: false, + error: "badToken", + error_description: "token expired" + }), { + status: 401, + headers: { "Content-Type": "application/json" } + }); + } + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/valid.bin", + name: "valid.bin", + size: 2048 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/needs-rotation.bin"); + + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + expect(result.provider).toBe("debridlink"); + expect(result.providerLabel).toContain("Key 2"); + expect(result.directUrl).toBe("https://debrid-link.example/valid.bin"); + }); + + it("looks up limits and rotates keys when Debrid-Link host quota is reached", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let limitCalls = 0; + const authHeaders: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const headers = init?.headers; + let authHeader = ""; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || ""); + } + + if (url.includes("debrid-link.com/api/v2/downloader/limits")) { + limitCalls += 1; + return new Response(JSON.stringify({ + success: true, + value: { + nextResetSeconds: { value: 900 } + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + authHeaders.push(authHeader); + if (authHeader === "Bearer dl-key-one") { + return new Response(JSON.stringify({ + success: false, + error: "maxDataHost", + error_description: "host quota reached" + }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/quota-ok.bin", + name: "quota-ok.bin", + size: 4096 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/quota-test"); + + expect(limitCalls).toBe(1); + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + expect(result.provider).toBe("debridlink"); + expect(result.providerLabel).toContain("Key 2"); + expect(result.directUrl).toBe("https://debrid-link.example/quota-ok.bin"); + }); + + it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const authHeaders: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + const headers = init?.headers; + let authHeader = ""; + if (headers instanceof Headers) { + authHeader = headers.get("Authorization") || ""; + } else if (Array.isArray(headers)) { + authHeader = headers.find(([key]) => key.toLowerCase() === "authorization")?.[1] || ""; + } else { + authHeader = String((headers as Record<string, unknown> | undefined)?.Authorization || ""); + } + authHeaders.push(authHeader); + return new Response(JSON.stringify({ + success: false, + error: "badFilePassword", + error_description: "wrong password" + }), { + status: 400, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/protected.bin")).rejects.toThrow("wrong password"); + expect(authHeaders).toEqual(["Bearer dl-key-one"]); + }); + + it("returns a cooldown marker when all Debrid-Link keys are temporarily cooling down", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + let addCalls = 0; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (!url.includes("debrid-link.com/api/v2/downloader/add")) { + return new Response("not-found", { status: 404 }); + } + addCalls += 1; + return new Response(JSON.stringify({ + success: false, + error: "floodDetected", + error_description: "too many requests" + }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow("API-Rate-Limit erreicht"); + await expect(service.unrestrictLink("https://hoster.example/cooldown.bin")).rejects.toThrow(/debrid_link_cooldown:\d+:/i); + expect(addCalls).toBe(2); + }); + + it("fails fast on provider-wide Debrid-Link notDebrid errors without rotating through all keys", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const authHeaders: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + authHeaders.push(String((init?.headers as Record<string, string> | undefined)?.Authorization || "")); + return new Response(JSON.stringify({ + success: false, + error: "notDebrid", + error_description: "notDebrid" + }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow("Link kann aktuell nicht generiert werden (notDebrid: notDebrid)"); + expect(authHeaders).toEqual(["Bearer dl-key-one"]); + }); + + it("continues to the next Debrid-Link key for non-provider-wide skip errors without caching a cooldown", async () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key-one\ndl-key-two", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + const authHeaders: string[] = []; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { + const authHeader = String((init?.headers as Record<string, string> | undefined)?.Authorization || ""); + authHeaders.push(authHeader); + if (authHeader === "Bearer dl-key-one") { + return new Response(JSON.stringify({ + success: false, + error: "noServerHost", + error_description: "host temporarily unavailable" + }), { + status: 403, + headers: { "Content-Type": "application/json" } + }); + } + return new Response(JSON.stringify({ + success: true, + value: { + downloadUrl: "https://debrid-link.example/second-key.bin", + name: "second-key.bin", + size: 4096 + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://hoster.example/skip-key.bin"); + expect(result.directUrl).toBe("https://debrid-link.example/second-key.bin"); + expect(result.sourceAccountLabel).toBe("Key 2"); + expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]); + }); + + it("uses BestDebrid auth header without token query fallback", async () => { + const settings = { + ...defaultSettings(), token: "", bestToken: "best-token", providerPrimary: "bestdebrid" as const, @@ -700,7 +703,7 @@ describe("debrid service", () => { expect(result.fileSize).toBe(4096); }); - it("loads AllDebrid host info via api", async () => { + it("loads AllDebrid host info via api", async () => { globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; if (url.includes("api.alldebrid.com/v4.1/user/hosts")) { @@ -734,98 +737,98 @@ describe("debrid service", () => { expect(info.quota).toBe(1200); expect(info.quotaMax).toBe(2400); expect(info.quotaType).toBe("traffic"); - expect(info.limitSimuDl).toBe(2); - }); - - it("loads Debrid-Link rapidgator limits per api key", async () => { - globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { - return new Response(JSON.stringify({ - success: true, - value: { - hosters: [ - { - name: "rapidgator", - daySize: { current: 0, value: 150323855360 }, - dayCount: { current: 0, value: 500 } - } - ] - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - return new Response("not-found", { status: 404 }); - }) as typeof fetch; - - const info = await fetchDebridLinkHostLimits("key-a", "rapidgator"); - expect(info).toHaveLength(1); - expect(info[0].keyLabel).toBe("Key 1"); - expect(info[0].host).toBe("rapidgator"); - expect(info[0].trafficCurrentBytes).toBe(0); - expect(info[0].trafficMaxBytes).toBe(150323855360); - expect(info[0].linksCurrent).toBe(0); - expect(info[0].linksMax).toBe(500); - }); - - it("falls back from Debrid-Link limits/all to limits when the host is only present in limits", async () => { - const calledUrls: string[] = []; - - globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - calledUrls.push(url); - if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { - return new Response(JSON.stringify({ - success: true, - value: { - hosters: [ - { - name: "uploaded", - daySize: { current: 1, value: 2 }, - dayCount: { current: 3, value: 4 } - } - ] - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - if (url.includes("debrid-link.com/api/v2/downloader/limits")) { - return new Response(JSON.stringify({ - success: true, - value: { - hosters: [ - { - name: "rapidgator", - displayName: "Rapidgator", - daySize: { current: 2147483648, value: 150323855360 }, - dayCount: { current: 42, value: 500 } - } - ] - } - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - return new Response("not-found", { status: 404 }); - }) as typeof fetch; - - const info = await fetchDebridLinkHostLimits("key-a", "rapidgator"); - expect(info).toHaveLength(1); - expect(info[0].host).toBe("rapidgator"); - expect(info[0].trafficCurrentBytes).toBe(2147483648); - expect(info[0].trafficMaxBytes).toBe(150323855360); - expect(info[0].linksCurrent).toBe(42); - expect(info[0].linksMax).toBe(500); - expect(calledUrls.some((url) => url.includes("/limits/all"))).toBe(true); - expect(calledUrls.some((url) => url.includes("/limits"))).toBe(true); - }); - - it("uses AllDebrid web path when enabled", async () => { + expect(info.limitSimuDl).toBe(2); + }); + + it("loads Debrid-Link rapidgator limits per api key", async () => { + globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "rapidgator", + daySize: { current: 0, value: 150323855360 }, + dayCount: { current: 0, value: 500 } + } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const info = await fetchDebridLinkHostLimits("key-a", "rapidgator"); + expect(info).toHaveLength(1); + expect(info[0].keyLabel).toBe("Key 1"); + expect(info[0].host).toBe("rapidgator"); + expect(info[0].trafficCurrentBytes).toBe(0); + expect(info[0].trafficMaxBytes).toBe(150323855360); + expect(info[0].linksCurrent).toBe(0); + expect(info[0].linksMax).toBe(500); + }); + + it("falls back from Debrid-Link limits/all to limits when the host is only present in limits", async () => { + const calledUrls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calledUrls.push(url); + if (url.includes("debrid-link.com/api/v2/downloader/limits/all")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "uploaded", + daySize: { current: 1, value: 2 }, + dayCount: { current: 3, value: 4 } + } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("debrid-link.com/api/v2/downloader/limits")) { + return new Response(JSON.stringify({ + success: true, + value: { + hosters: [ + { + name: "rapidgator", + displayName: "Rapidgator", + daySize: { current: 2147483648, value: 150323855360 }, + dayCount: { current: 42, value: 500 } + } + ] + } + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const info = await fetchDebridLinkHostLimits("key-a", "rapidgator"); + expect(info).toHaveLength(1); + expect(info[0].host).toBe("rapidgator"); + expect(info[0].trafficCurrentBytes).toBe(2147483648); + expect(info[0].trafficMaxBytes).toBe(150323855360); + expect(info[0].linksCurrent).toBe(42); + expect(info[0].linksMax).toBe(500); + expect(calledUrls.some((url) => url.includes("/limits/all"))).toBe(true); + expect(calledUrls.some((url) => url.includes("/limits"))).toBe(true); + }); + + it("uses AllDebrid web path when enabled", async () => { const settings = { ...defaultSettings(), allDebridToken: "ad-token", @@ -966,6 +969,7 @@ describe("debrid service", () => { allDebridToken: "", megaLogin: "user", megaPassword: "pass", + megaCredentials: "user:pass", providerOrder: [] as const, providerPrimary: "megadebrid" as const, providerSecondary: "megadebrid" as const, @@ -999,6 +1003,7 @@ describe("debrid service", () => { allDebridToken: "", megaLogin: "user", megaPassword: "pass", + megaCredentials: "user:pass", megaDebridApiEnabled: true, megaDebridWebEnabled: true, providerPrimary: "megadebrid-api" as const, @@ -1029,6 +1034,7 @@ describe("debrid service", () => { allDebridToken: "", megaLogin: "user", megaPassword: "pass", + megaCredentials: "user:pass", megaDebridApiEnabled: true, megaDebridWebEnabled: true, providerOrder: [] as const, @@ -1062,6 +1068,7 @@ describe("debrid service", () => { allDebridToken: "", megaLogin: "user", megaPassword: "pass", + megaCredentials: "user:pass", providerOrder: [] as const, providerPrimary: "megadebrid" as const, providerSecondary: "none" as const, @@ -1104,6 +1111,7 @@ describe("debrid service", () => { allDebridToken: "ad-token", megaLogin: "user", megaPassword: "pass", + megaCredentials: "user:pass", providerPrimary: "megadebrid" as const, providerSecondary: "megadebrid" as const, providerTertiary: "megadebrid" as const, @@ -1135,6 +1143,7 @@ describe("debrid service", () => { token: "", megaLogin: "user", megaPassword: "pass", + megaCredentials: "user:pass", providerPrimary: "realdebrid" as const, providerSecondary: "megadebrid" as const, providerTertiary: "none" as const, @@ -1159,6 +1168,7 @@ describe("debrid service", () => { token: "rd-token", megaLogin: "user", megaPassword: "pass", + megaCredentials: "user:pass", providerPrimary: "realdebrid" as const, providerSecondary: "none" as const, providerTertiary: "none" as const, diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index f6bd681..29c6961 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -6167,6 +6167,7 @@ describe("download manager", () => { ...defaultSettings(), megaLogin: "mega-user", megaPassword: "mega-pass", + megaCredentials: "mega-user:mega-pass", megaDebridWebEnabled: true, megaDebridApiEnabled: false, megaDebridPreferApi: false, @@ -9391,6 +9392,7 @@ describe("download manager", () => { ...defaultSettings(), megaLogin: "mega-user", megaPassword: "mega-pass", + megaCredentials: "mega-user:mega-pass", megaDebridApiEnabled: true, providerDailyUsageDay: getProviderUsageDayKey(), providerDailyUsageBytes: { realdebrid: 512 },