diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index c590530..cdeb457 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -151,7 +151,7 @@ export class AppController { this.manager = new DownloadManager(this.settings, session, this.storagePaths, { megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account), allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), - realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.unrestrictWithFirstRealDebridWebAccount(link, signal), + realDebridWebUnrestrict: (accountId: string, link: string, signal?: AbortSignal) => this.unrestrictRealDebridWebAccount(accountId, link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), protectEmptyClobber: loadResult.status === "empty-unreadable", @@ -717,11 +717,6 @@ export class AppController { } } - private async unrestrictWithFirstRealDebridWebAccount(link: string, signal?: AbortSignal) { - const account = getRealDebridAccounts(this.settings).find((entry) => entry.kind === "web" && entry.enabled); - return account ? this.unrestrictRealDebridWebAccount(account.id, link, signal) : null; - } - public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise { const accountId = String(request.accountId || "").trim(); if (!isRealDebridWebAccountId(accountId)) { diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 2304959..bf0e240 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1,14 +1,15 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { getMegaDebridAccountsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, type MegaDebridAccountEntry, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts"; +import { getRealDebridAccounts, type RealDebridAccountEntry } from "../shared/real-debrid-accounts"; import { extractHosterFromUrl } from "../shared/hoster"; import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types"; -import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; +import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached, isRealDebridAccountDailyLimitReached } from "../shared/provider-daily-limits"; import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; import { APP_VERSION, REQUEST_RETRIES } from "./constants"; import { logger } from "./logger"; import { logAccountRotation } from "./account-rotation-log"; import { traceConversionPhase } from "./conversion-trace"; -import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; +import { RealDebridApiError, RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback"; import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; @@ -422,7 +423,7 @@ function setMegaDebridAccountCooldownState( }); } -export function getMegaDebridAccountCooldownState( +export function getMegaDebridAccountCooldownState( accountId: string, now = Date.now() ): { until: number; remainingMs: number; message: string; category: MegaDebridCooldownCategory; untilRestart: boolean } | null { @@ -451,9 +452,15 @@ export interface ProviderRuntimeCooldown { untilRestart?: boolean; } -export interface ProviderRuntimeSnapshot { - capturedAtMs: number; - megaDebrid: { +export interface ProviderRuntimeSnapshot { + capturedAtMs: number; + realDebrid: { + rotationCursor: number; + stickyCount: number; + cooldownCount: number; + inFlightCount: number; + }; + megaDebrid: { rotationCursor: number; stickyCount: number; accounts: Array<{ @@ -532,9 +539,15 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna }; }); - return { - capturedAtMs: now, - megaDebrid: { + return { + capturedAtMs: now, + realDebrid: { + rotationCursor: realDebridRotationCursor, + stickyCount: realDebridStickyCount, + cooldownCount: [...realDebridAccountCooldowns.values()].filter((entry) => entry.until > now).length, + inFlightCount: [...realDebridInFlight.values()].reduce((total, count) => total + count, 0) + }, + megaDebrid: { rotationCursor: megaDebridRotationCursor, stickyCount: megaDebridStickyCount, accounts: megaAccounts @@ -565,7 +578,7 @@ interface ProviderUnrestrictedLink extends UnrestrictedLink { export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => Promise; export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; -export type RealDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; +export type RealDebridWebUnrestrictor = (accountId: string, link: string, signal?: AbortSignal) => Promise; export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise; interface DebridServiceOptions { @@ -585,7 +598,12 @@ function cloneSettings(settings: AppSettings): AppSettings { providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) }, debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }, - debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) }, + debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) }, + realDebridWebAccountIds: [...(settings.realDebridWebAccountIds || [])], + realDebridDisabledAccountIds: [...(settings.realDebridDisabledAccountIds || [])], + realDebridAccountDailyLimitBytes: { ...(settings.realDebridAccountDailyLimitBytes || {}) }, + realDebridAccountDailyUsageBytes: { ...(settings.realDebridAccountDailyUsageBytes || {}) }, + realDebridAccountTotalUsageBytes: { ...(settings.realDebridAccountTotalUsageBytes || {}) }, megaDebridDisabledAccountIds: [...(settings.megaDebridDisabledAccountIds || [])], megaDebridApiDisabledAccountIds: [...(settings.megaDebridApiDisabledAccountIds || [])], megaDebridWebDisabledAccountIds: [...(settings.megaDebridWebDisabledAccountIds || [])], @@ -611,6 +629,141 @@ export function getAvailableMegaDebridAccounts(settings: AppSettings, mode: Mega ); } +type RealDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary"; +type RealDebridFailureClassification = { + rotateAccount: true; + cooldownMs: number; + category: RealDebridCooldownCategory; + message: string; +} | { + rotateAccount: false; + cooldownMs: 0; + category: "provider_or_link"; + message: string; +}; +type RealDebridCooldownDetail = { until: number; message: string; category: RealDebridCooldownCategory }; +const realDebridAccountCooldowns = new Map(); +const realDebridInFlight = new Map(); +let realDebridRotationCursor = 0; +let realDebridStickyAccountId = ""; +let realDebridStickyCount = 0; +export const REAL_DEBRID_STICKY_LINKS = 4; + +export function getRealDebridAccountAttemptTimeoutMs(): number { + const timeoutMsRaw = Number(process.env.RD_REALDEBRID_ACCOUNT_TIMEOUT_MS || 35_000); + return Number.isFinite(timeoutMsRaw) ? Math.max(1000, Math.floor(timeoutMsRaw)) : 35_000; +} + +export function resetRealDebridRuntimeStateForTests(): void { + realDebridAccountCooldowns.clear(); + realDebridInFlight.clear(); + realDebridRotationCursor = 0; + realDebridStickyAccountId = ""; + realDebridStickyCount = 0; +} + +export function pruneRealDebridRuntimeStateForAccounts(activeAccountIds: Set): void { + for (const accountId of realDebridAccountCooldowns.keys()) { + if (!activeAccountIds.has(accountId)) { + realDebridAccountCooldowns.delete(accountId); + } + } + for (const accountId of realDebridInFlight.keys()) { + if (!activeAccountIds.has(accountId)) { + realDebridInFlight.delete(accountId); + } + } + if (realDebridStickyAccountId && !activeAccountIds.has(realDebridStickyAccountId)) { + realDebridStickyAccountId = ""; + realDebridStickyCount = 0; + } + if (activeAccountIds.size === 0) { + realDebridRotationCursor = 0; + } else { + realDebridRotationCursor %= activeAccountIds.size; + } +} + +export function pruneExpiredRealDebridRuntimeState(now = Date.now()): number { + let removed = 0; + for (const [accountId, detail] of realDebridAccountCooldowns) { + if (detail.until <= now) { + realDebridAccountCooldowns.delete(accountId); + removed += 1; + } + } + return removed; +} + +export function primeRealDebridRuntimeCooldownForTests( + accountId: string, + cooldownMs: number, + message = "Real-Debrid Account im Cooldown" +): void { + setRealDebridAccountCooldown(accountId, cooldownMs, message, "temporary"); +} + +function setRealDebridAccountCooldown( + accountId: string, + cooldownMs: number, + message: string, + category: RealDebridCooldownCategory +): void { + realDebridAccountCooldowns.set(accountId, { + until: Date.now() + Math.max(1000, Math.floor(cooldownMs)), + message, + category + }); +} + +function getRealDebridAccountCooldown(accountId: string, now = Date.now()): RealDebridCooldownDetail | null { + const detail = realDebridAccountCooldowns.get(accountId); + if (!detail) { + return null; + } + if (detail.until <= now) { + realDebridAccountCooldowns.delete(accountId); + return null; + } + return detail; +} + +export function getAvailableRealDebridAccounts(settings: AppSettings, now = Date.now()): RealDebridAccountEntry[] { + return getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled + && !isRealDebridAccountDailyLimitReached(settings, account.id, now) + && !getRealDebridAccountCooldown(account.id, now)); +} + +function getConfiguredRealDebridAccounts(settings: AppSettings): RealDebridAccountEntry[] { + const accounts = getRealDebridAccounts(settings); + if (accounts.length > 0) { + return accounts; + } + const legacy: RealDebridAccountEntry[] = []; + if (settings.realDebridUseWebLogin) { + legacy.push({ + id: "rdw_legacy", + kind: "web", + index: 0, + label: "Browser-Login 1", + maskedLogin: "Geschützter Browser-Login", + enabled: true + }); + } + if (settings.token.trim()) { + legacy.push({ + id: "rda_legacy_1", + kind: "api", + index: 0, + label: "API-Token 1", + maskedLogin: "Geschützter API-Token", + enabled: true, + token: settings.token.trim() + }); + } + return legacy; +} + function getMegaDebridAccountList(settings: AppSettings, mode: MegaDebridAccountMode): MegaDebridAccountEntry[] { const multiAccounts = getMegaDebridAccountsForMode(settings, mode); if (multiAccounts.length > 0) { @@ -3699,9 +3852,10 @@ export class DebridService { MegaDebridClient.clearCachedApiToken(prevAcc.login); } } - const nextDebridLinkKeyIds = new Set(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id)); - pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds); - } + const nextDebridLinkKeyIds = new Set(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id)); + pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds); + pruneRealDebridRuntimeStateForAccounts(new Set(this.getConfiguredRealDebridAccounts(next).map((account) => account.id))); + } private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient { if (this.cachedDebridLinkClient && this.cachedDebridLinkKey === apiKeysRaw) { @@ -3798,9 +3952,15 @@ export class DebridService { return clean; } - private shouldUseRealDebridWeb(settings: AppSettings): boolean { - return Boolean(settings.realDebridUseWebLogin && this.options.realDebridWebUnrestrict); - } + private getConfiguredRealDebridAccounts(settings: AppSettings): RealDebridAccountEntry[] { + return getConfiguredRealDebridAccounts(settings).filter((account) => account.kind === "api" || Boolean(this.options.realDebridWebUnrestrict)); + } + + private getAvailableRealDebridAccounts(settings: AppSettings, now = Date.now()): RealDebridAccountEntry[] { + return this.getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled + && !isRealDebridAccountDailyLimitReached(settings, account.id, now) + && !getRealDebridAccountCooldown(account.id, now)); + } private shouldUseAllDebridWeb(settings: AppSettings): boolean { return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict); @@ -3810,8 +3970,14 @@ export class DebridService { return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict); } - private isProviderDailyLimited(settings: AppSettings, provider: DebridProvider): boolean { - const effectiveProvider = resolveMegaDebridProvider(settings, provider); + private isProviderDailyLimited(settings: AppSettings, provider: DebridProvider): boolean { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (effectiveProvider === "realdebrid") { + const configuredAccounts = this.getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled); + if (configuredAccounts.length > 0 && this.getAvailableRealDebridAccounts(settings).length === 0) { + return true; + } + } if (effectiveProvider === "debridlink") { const configuredKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys); if (configuredKeys.length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) { @@ -3832,8 +3998,13 @@ export class DebridService { return this.isProviderConfiguredFor(settings, provider) && !this.isProviderDailyLimited(settings, provider); } - private formatProviderLimitMessage(settings: AppSettings, provider: DebridProvider): string { - const effectiveProvider = resolveMegaDebridProvider(settings, provider); + private formatProviderLimitMessage(settings: AppSettings, provider: DebridProvider): string { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (effectiveProvider === "realdebrid" + && this.getConfiguredRealDebridAccounts(settings).some((account) => account.enabled) + && this.getAvailableRealDebridAccounts(settings).length === 0) { + return "Real-Debrid nicht verfügbar (alle aktiven Accounts deaktiviert, im Cooldown oder ausgeschöpft)"; + } if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) { return "Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft)"; } @@ -4034,9 +4205,9 @@ export class DebridService { 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 ((settings.disabledProviders || []).includes(provider) || (settings.disabledProviders || []).includes(effectiveProvider)) return false; + if (effectiveProvider === "realdebrid") { + return this.getConfiguredRealDebridAccounts(settings).some((account) => account.enabled); } if (effectiveProvider === "megadebrid-api") { return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "api")); @@ -4060,22 +4231,132 @@ export class DebridService { 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 { - 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; + } + + private selectRealDebridAccount(accounts: RealDebridAccountEntry[]): RealDebridAccountEntry { + const minimumInFlight = Math.min(...accounts.map((account) => realDebridInFlight.get(account.id) || 0)); + const leastBusy = accounts.filter((account) => (realDebridInFlight.get(account.id) || 0) === minimumInFlight); + const sticky = leastBusy.find((account) => account.id === realDebridStickyAccountId); + if (sticky && realDebridStickyCount < REAL_DEBRID_STICKY_LINKS) { + return sticky; + } + const selected = leastBusy[realDebridRotationCursor % leastBusy.length]; + return selected; + } + + private classifyRealDebridFailure(error: unknown): RealDebridFailureClassification { + const message = compactErrorText(error); + const status = error instanceof RealDebridApiError ? error.status : 0; + const apiError = error instanceof RealDebridApiError ? error.apiError.toLowerCase() : ""; + if (status === 401 || /^(bad_token|invalid_token|token_expired)$/.test(apiError) || /HTTP\s*401|bad[ _-]?token|invalid.*token|unauthorized/i.test(message)) { + return { rotateAccount: true, cooldownMs: 60 * 60 * 1000, category: "invalid", message }; + } + if (status === 429 || /^(too_many_requests|slow_down)$/.test(apiError) || /HTTP\s*429|rate.?limit|too[ _-]?many[ _-]?requests/i.test(message)) { + return { rotateAccount: true, cooldownMs: 15 * 60 * 1000, category: "rate_limit", message }; + } + if (/^(file_unavailable|invalid_link|bad_link|unsupported_hoster|hoster_unsupported|hoster_not_supported|hoster_unavailable|hoster_maintenance|hoster_temporarily_unavailable|service_unavailable)$/.test(apiError) + || /file[ _-]?unavailable|invalid[ _-]?link|bad[ _-]?link|unsupported[ _-]?hoster|hoster.*not.*supported|hoster[ _-]?(unavailable|maintenance|temporarily[ _-]?unavailable)|service[ _-]?unavailable/i.test(message)) { + return { rotateAccount: false, cooldownMs: 0, category: "provider_or_link", message }; + } + if (status === 403 + || /^(permission_denied|traffic_exhausted|account_locked|account_not_activated|invalid_login|invalid_password|hoster_limit_reached|too_many_active_downloads|ip_not_allowed)$/.test(apiError) + || /HTTP\s*403|traffic|quota|premium/i.test(message)) { + return { rotateAccount: true, cooldownMs: 30 * 60 * 1000, category: "quota", message }; + } + if (status === 400 + || status === 404 + || (error instanceof RealDebridApiError && status >= 500)) { + return { rotateAccount: false, cooldownMs: 0, category: "provider_or_link", message }; + } + if (/timeout|timed out|aborted|network|fetch failed|econnreset|etimedout/i.test(message)) { + return { rotateAccount: true, cooldownMs: 2 * 60 * 1000, category: "temporary", message }; + } + return { rotateAccount: true, cooldownMs: 30 * 1000, category: "temporary", message }; + } + + private async unrestrictWithRealDebridAccounts( + settings: AppSettings, + link: string, + signal?: AbortSignal + ): Promise { + const failures: string[] = []; + const attempted = new Set(); + while (true) { + if (signal?.aborted) { + throw new Error(`aborted:${String(signal.reason || "caller")}`); + } + const available = this.getAvailableRealDebridAccounts(settings).filter((account) => !attempted.has(account.id)); + if (available.length === 0) { + break; + } + const account = this.selectRealDebridAccount(available); + attempted.add(account.id); + realDebridInFlight.set(account.id, (realDebridInFlight.get(account.id) || 0) + 1); + const timeoutMs = getRealDebridAccountAttemptTimeoutMs(); + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const accountSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + try { + const result = account.kind === "api" + ? await new RealDebridClient(account.token).unrestrictLink(link, accountSignal) + : await this.options.realDebridWebUnrestrict?.(account.id, link, accountSignal); + if (!result) { + throw new Error("Real-Debrid-Web-Fallback nicht verfügbar"); + } + realDebridAccountCooldowns.delete(account.id); + if (realDebridStickyAccountId === account.id) { + realDebridStickyCount += 1; + } else { + realDebridStickyAccountId = account.id; + realDebridStickyCount = 1; + } + const accountIndex = available.findIndex((candidate) => candidate.id === account.id); + if (realDebridStickyCount >= REAL_DEBRID_STICKY_LINKS && accountIndex >= 0) { + realDebridRotationCursor = (accountIndex + 1) % available.length; + } + return { + ...result, + sourceLabel: account.kind === "api" ? "API" : "Web", + sourceAccountId: account.id, + sourceAccountLabel: account.label + }; + } catch (error) { + if (signal?.aborted) { + throw error; + } + const failure = this.classifyRealDebridFailure(error); + if (!failure.rotateAccount) { + throw error; + } + const enabledAccountCount = this.getConfiguredRealDebridAccounts(settings).filter((candidate) => candidate.enabled).length; + if (failure.category !== "temporary" || enabledAccountCount > 1) { + setRealDebridAccountCooldown(account.id, failure.cooldownMs, failure.message, failure.category); + } + failures.push(`${account.label}: ${failure.message}`); + realDebridStickyAccountId = ""; + realDebridStickyCount = 0; + const accountIndex = available.findIndex((candidate) => candidate.id === account.id); + if (accountIndex >= 0) { + realDebridRotationCursor = accountIndex % Math.max(1, available.length - 1); + } + } finally { + const remaining = (realDebridInFlight.get(account.id) || 1) - 1; + if (remaining > 0) { + realDebridInFlight.set(account.id, remaining); + } else { + realDebridInFlight.delete(account.id); + } + } + } + if (failures.length > 0) { + throw new Error(`Real-Debrid Account-Pool fehlgeschlagen: ${failures.join(" | ")}`); + } + throw new Error("Real-Debrid nicht verfügbar (alle aktiven Accounts deaktiviert, im Cooldown oder ausgeschöpft)"); + } + + private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (effectiveProvider === "realdebrid") { + return this.unrestrictWithRealDebridAccounts(settings, link, signal); } if (effectiveProvider === "megadebrid-api") { return MegaDebridClient.unrestrictWithAccounts(settings, "api", provider === "megadebrid" && settings.megaDebridPreferApi, link, this.options.megaWebUnrestrict, signal); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index f488d2a..c662d35 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -26,13 +26,16 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { extractHosterFromUrl } from "../shared/hoster"; import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; -import { +import { getRealDebridAccounts } from "../shared/real-debrid-accounts"; +import { addDebridLinkApiKeyDailyUsageBytes, addDebridLinkApiKeyTotalUsageBytes, addMegaDebridAccountDailyUsageBytes, addMegaDebridAccountTotalUsageBytes, addProviderDailyUsageBytes, - addProviderTotalUsageBytes, + addProviderTotalUsageBytes, + addRealDebridAccountDailyUsageBytes, + addRealDebridAccountTotalUsageBytes, getProviderUsageDayKey, isProviderDailyLimitReached } from "../shared/provider-daily-limits"; @@ -55,7 +58,7 @@ function releaseTlsSkip(): void { } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; @@ -353,13 +356,55 @@ function getPostExtractTimeoutMs(): number { return DEFAULT_POST_EXTRACT_TIMEOUT_MS; } -function getUnrestrictTimeoutMs(): number { +function getUnrestrictTimeoutMs(): number { const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN); if (Number.isFinite(fromEnv) && fromEnv >= 5000 && fromEnv <= 15 * 60 * 1000) { return Math.floor(fromEnv); } - return DEFAULT_UNRESTRICT_TIMEOUT_MS; -} + return DEFAULT_UNRESTRICT_TIMEOUT_MS; +} + +export function resolveUnrestrictTimeoutBudgetMs( + baseTimeoutMs: number, + preferredLeadProvider: DebridProvider | null, + settings: AppSettings, + link: string, + realDebridAccountAttemptTimeoutMs = getRealDebridAccountAttemptTimeoutMs() +): number { + const configuredOrder = settings.providerOrder?.length > 0 + ? [...settings.providerOrder] + : [settings.providerPrimary, settings.providerSecondary, settings.providerTertiary] + .filter((provider): provider is DebridProvider => provider !== "none"); + const orderedProviders = preferredLeadProvider + ? [preferredLeadProvider, ...configuredOrder.filter((provider) => provider !== preferredLeadProvider)] + : configuredOrder; + const hosterKey = extractHosterFromUrl(link); + const routedProvider = hosterKey ? settings.hosterRouting?.[hosterKey] : undefined; + const routedPlan = routedProvider + ? [routedProvider, ...orderedProviders.filter((provider) => provider !== routedProvider)] + : orderedProviders; + const plan = settings.autoProviderFallback || routedProvider ? routedPlan : routedPlan.slice(0, 1); + const uniqueProviders: DebridProvider[] = []; + const seen = new Set(); + for (const provider of plan) { + const effectiveProvider = resolveMegaDebridProvider(settings, provider) || provider; + if (!seen.has(effectiveProvider)) { + seen.add(effectiveProvider); + uniqueProviders.push(effectiveProvider); + } + } + const accountCount = getAvailableRealDebridAccounts(settings).length; + const budgetMs = uniqueProviders.reduce((total, provider) => { + if (provider !== "realdebrid") { + return total + baseTimeoutMs; + } + if (accountCount === 0) { + return total; + } + return total + Math.max(baseTimeoutMs, realDebridAccountAttemptTimeoutMs * accountCount); + }, 0); + return Math.max(baseTimeoutMs, Math.min(Number.MAX_SAFE_INTEGER, budgetMs)); +} function getLowThroughputTimeoutMs(): number { const fromEnv = Number(process.env.RD_LOW_THROUGHPUT_TIMEOUT_MS ?? NaN); @@ -2280,7 +2325,11 @@ export class DownloadManager extends EventEmitter { } const credChanges: Array<{ prev: string; next: string; providers: string[] }> = [ - { prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] }, + { + prev: `${previous.token || ""}|${previous.realDebridApiTokens || ""}|${(previous.realDebridWebAccountIds || []).join(",")}|${(previous.realDebridDisabledAccountIds || []).join(",")}`, + next: `${next.token || ""}|${next.realDebridApiTokens || ""}|${(next.realDebridWebAccountIds || []).join(",")}|${(next.realDebridDisabledAccountIds || []).join(",")}`, + providers: ["realdebrid"] + }, { prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] }, { prev: previous.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] }, { prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] }, @@ -8148,9 +8197,10 @@ export class DownloadManager extends EventEmitter { return; } this.settings.providerDailyUsageDay = currentDay; - this.settings.providerDailyUsageBytes = {}; - this.settings.debridLinkApiKeyDailyUsageBytes = {}; - this.settings.megaDebridAccountDailyUsageBytes = {}; + this.settings.providerDailyUsageBytes = {}; + this.settings.debridLinkApiKeyDailyUsageBytes = {}; + this.settings.megaDebridAccountDailyUsageBytes = {}; + this.settings.realDebridAccountDailyUsageBytes = {}; this.statsCache = null; this.statsCacheAt = 0; if (persist) { @@ -8178,13 +8228,26 @@ export class DownloadManager extends EventEmitter { this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes; this.settings.debridLinkApiKeyTotalUsageBytes = nextKeyTotalUsage.debridLinkApiKeyTotalUsageBytes; } - if ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && providerAccountId) { + if ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && providerAccountId) { const nextAcctUsage = addMegaDebridAccountDailyUsageBytes(this.settings, providerAccountId, byteDelta); const nextAcctTotalUsage = addMegaDebridAccountTotalUsageBytes(this.settings, providerAccountId, byteDelta); this.settings.providerDailyUsageDay = nextAcctUsage.providerDailyUsageDay; this.settings.megaDebridAccountDailyUsageBytes = nextAcctUsage.megaDebridAccountDailyUsageBytes; - this.settings.megaDebridAccountTotalUsageBytes = nextAcctTotalUsage.megaDebridAccountTotalUsageBytes; - } + this.settings.megaDebridAccountTotalUsageBytes = nextAcctTotalUsage.megaDebridAccountTotalUsageBytes; + } + const realDebridAccounts = effectiveProvider === "realdebrid" ? getRealDebridAccounts(this.settings) : []; + const realDebridAccountStillConfigured = Boolean(providerAccountId) && ( + realDebridAccounts.some((account) => account.id === providerAccountId) + || (realDebridAccounts.length === 0 && providerAccountId === "rda_legacy_1" && Boolean(this.settings.token.trim())) + || (realDebridAccounts.length === 0 && providerAccountId === "rdw_legacy" && this.settings.realDebridUseWebLogin) + ); + if (effectiveProvider === "realdebrid" && providerAccountId && realDebridAccountStillConfigured) { + const nextAcctUsage = addRealDebridAccountDailyUsageBytes(this.settings, providerAccountId, byteDelta); + const nextAcctTotalUsage = addRealDebridAccountTotalUsageBytes(this.settings, providerAccountId, byteDelta); + this.settings.providerDailyUsageDay = nextAcctUsage.providerDailyUsageDay; + this.settings.realDebridAccountDailyUsageBytes = nextAcctUsage.realDebridAccountDailyUsageBytes; + this.settings.realDebridAccountTotalUsageBytes = nextAcctTotalUsage.realDebridAccountTotalUsageBytes; + } } private isProviderConfigured(provider: DebridProvider): boolean { @@ -8196,8 +8259,10 @@ export class DownloadManager extends EventEmitter { if (isProviderDailyLimitReached(this.settings, effectiveProvider)) { return false; } - if (effectiveProvider === "realdebrid") { - return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); + if (effectiveProvider === "realdebrid") { + return getRealDebridAccounts(this.settings).some((account) => account.enabled) + ? getAvailableRealDebridAccounts(this.settings).length > 0 + : Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); } if (effectiveProvider === "megadebrid-api") { const hasMegaCreds = getAvailableMegaDebridAccounts(this.settings, "api").length > 0; @@ -8646,10 +8711,11 @@ export class DownloadManager extends EventEmitter { allDebridPruned += 1; } } - const dlPruned = pruneExpiredDebridLinkRuntimeState(now); - const mdPruned = pruneExpiredMegaDebridRuntimeState(now); - if (allDebridPruned > 0 || dlPruned > 0 || mdPruned > 0) { - logger.info(`Soft-Reset: pruned ${allDebridPruned} AllDebrid host entries, ${dlPruned} Debrid-Link entries, ${mdPruned} Mega-Debrid entries`); + const dlPruned = pruneExpiredDebridLinkRuntimeState(now); + const mdPruned = pruneExpiredMegaDebridRuntimeState(now); + const rdPruned = pruneExpiredRealDebridRuntimeState(now); + if (allDebridPruned > 0 || dlPruned > 0 || mdPruned > 0 || rdPruned > 0) { + logger.info(`Soft-Reset: pruned ${allDebridPruned} AllDebrid host entries, ${dlPruned} Debrid-Link entries, ${mdPruned} Mega-Debrid entries, ${rdPruned} Real-Debrid entries`); } } @@ -9249,7 +9315,13 @@ export class DownloadManager extends EventEmitter { this.emitState(); return; } - const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs()); + const unrestrictTimeoutMs = resolveUnrestrictTimeoutBudgetMs( + getUnrestrictTimeoutMs(), + preferredLeadProvider, + this.settings, + item.url + ); + const unrestrictTimeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs); const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]); let unrestricted; try { @@ -9270,7 +9342,7 @@ export class DownloadManager extends EventEmitter { traceConversionPhase({ phase: "caller-timeout", outcome: "timeout", - detail: `Caller-Budget ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)` + detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)` }); } throw innerError; @@ -9280,7 +9352,7 @@ export class DownloadManager extends EventEmitter { } catch (unrestrictError) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { this.recordProviderFailure(cooldownProvider); - throw new Error(`Unrestrict Timeout nach ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s`); + throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`); } const errText = compactErrorText(unrestrictError); if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index e903e65..0d693c6 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -113,13 +113,41 @@ function looksLikeHtmlResponse(contentType: string, body: string): boolean { return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || "")); } -function parseErrorBody(status: number, body: string, contentType: string): string { - if (looksLikeHtmlResponse(contentType, body)) { - return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`; - } - const clean = compactErrorText(body); - return clean || `HTTP ${status}`; -} +function parseErrorBody(status: number, body: string, contentType: string): RealDebridApiError { + if (looksLikeHtmlResponse(contentType, body)) { + return new RealDebridApiError(status, "html_response", null, "Real-Debrid lieferte HTML statt JSON"); + } + if (String(contentType || "").toLowerCase().includes("json") || /^\s*\{/.test(body)) { + try { + const payload = JSON.parse(body) as Record; + const apiError = String(payload.error || "").trim(); + const codeValue = Number(payload.error_code ?? NaN); + const apiErrorCode = Number.isFinite(codeValue) ? Math.floor(codeValue) : null; + if (apiError || apiErrorCode !== null) { + return new RealDebridApiError(status, apiError, apiErrorCode); + } + } catch { + } + } + const clean = compactErrorText(body); + return new RealDebridApiError(status, "", null, clean || `HTTP ${status}`); +} + +export class RealDebridApiError extends Error { + public readonly status: number; + public readonly apiError: string; + public readonly apiErrorCode: number | null; + + public constructor(status: number, apiError: string, apiErrorCode: number | null, fallbackMessage = "") { + const normalizedError = String(apiError || "").trim(); + const codeText = apiErrorCode === null ? "" : ` (${apiErrorCode})`; + super(fallbackMessage || `Real-Debrid HTTP ${status}: ${normalizedError || "API-Fehler"}${codeText}`); + this.name = "RealDebridApiError"; + this.status = status; + this.apiError = normalizedError; + this.apiErrorCode = apiErrorCode; + } +} export class RealDebridClient { private token: string; @@ -128,8 +156,8 @@ export class RealDebridClient { this.token = token; } - public async unrestrictLink(link: string, signal?: AbortSignal): Promise { - let lastError = ""; + public async unrestrictLink(link: string, signal?: AbortSignal): Promise { + let lastError: unknown = null; for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { try { const body = new URLSearchParams({ link }); @@ -146,13 +174,13 @@ export class RealDebridClient { const text = await response.text(); const contentType = String(response.headers.get("content-type") || ""); - if (!response.ok) { - const parsed = parseErrorBody(response.status, text, contentType); - if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { - await sleepWithSignal(retryDelayForResponse(response, attempt), signal); - continue; - } - throw new Error(parsed); + if (!response.ok) { + const parsed = parseErrorBody(response.status, text, contentType); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + throw parsed; } if (looksLikeHtmlResponse(contentType, text)) { @@ -187,18 +215,22 @@ export class RealDebridClient { fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null, 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 || "Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, "")); - } -} + } catch (error) { + lastError = error; + const lastErrorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(lastErrorText) && !/timeout/i.test(lastErrorText))) { + break; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastErrorText)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + if (lastError instanceof Error) { + throw lastError; + } + throw new Error(compactErrorText(lastError) || "Unrestrict fehlgeschlagen"); + } +} diff --git a/src/main/startup-health-check.ts b/src/main/startup-health-check.ts index 9dd73d4..5ffe1fd 100644 --- a/src/main/startup-health-check.ts +++ b/src/main/startup-health-check.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { AppSettings } from "../shared/types"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import { getRealDebridAccounts } from "../shared/real-debrid-accounts"; import { StoragePaths } from "./storage"; export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR"; @@ -71,10 +72,13 @@ function getFreeDiskSpaceBytes(target: string): number | null { } } -function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } { - const providers: string[] = []; - if (settings.token?.trim() || settings.realDebridUseWebLogin) { - providers.push("Real-Debrid"); +function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } { + const providers: string[] = []; + const realDebridAccounts = getRealDebridAccounts(settings); + if (realDebridAccounts.length > 0) { + providers.push(`Real-Debrid (${realDebridAccounts.length} Account${realDebridAccounts.length === 1 ? "" : "s"})`); + } else if (settings.token?.trim() || settings.realDebridUseWebLogin) { + providers.push("Real-Debrid"); } if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) { providers.push("AllDebrid"); diff --git a/src/main/support-data.ts b/src/main/support-data.ts index 007421f..c699983 100644 --- a/src/main/support-data.ts +++ b/src/main/support-data.ts @@ -1,4 +1,5 @@ -import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; +import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; +import { getRealDebridAccounts } from "../shared/real-debrid-accounts"; import { isNotifyUrlValid } from "./notify"; import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types"; @@ -7,15 +8,22 @@ function hasText(value: unknown): boolean { } export function buildAccountSummary(settings: AppSettings): Record { - const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys); - const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []); + const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys); + const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []); + const realDebridAccounts = getRealDebridAccounts(settings); + const enabledRealDebridAccounts = realDebridAccounts.filter((account) => account.enabled); return { - realDebrid: { - configured: hasText(settings.token) || settings.realDebridUseWebLogin, - tokenConfigured: hasText(settings.token), - webLoginEnabled: settings.realDebridUseWebLogin, - rememberToken: settings.rememberToken + realDebrid: { + configured: realDebridAccounts.length > 0 || hasText(settings.token) || settings.realDebridUseWebLogin, + accountCount: realDebridAccounts.length, + enabledAccountCount: enabledRealDebridAccounts.length, + disabledAccountCount: realDebridAccounts.length - enabledRealDebridAccounts.length, + apiAccountCount: realDebridAccounts.filter((account) => account.kind === "api").length, + webAccountCount: realDebridAccounts.filter((account) => account.kind === "web").length, + tokenConfigured: realDebridAccounts.some((account) => account.kind === "api") || hasText(settings.token), + webLoginEnabled: realDebridAccounts.some((account) => account.kind === "web") || settings.realDebridUseWebLogin, + rememberToken: settings.rememberToken }, megaDebrid: { configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword)) diff --git a/src/shared/provider-daily-limits.ts b/src/shared/provider-daily-limits.ts index 7baeb13..035bc03 100644 --- a/src/shared/provider-daily-limits.ts +++ b/src/shared/provider-daily-limits.ts @@ -5,14 +5,15 @@ export type ProviderByteMap = Partial>; export type DebridLinkKeyByteMap = Record; type ProviderDailySettings = - Pick - & Partial> - & Partial>; + Pick + & Partial> + & Partial> + & Partial>; type ProviderUsageSettings = - ProviderDailySettings - & Partial> - & Partial>; + ProviderDailySettings + & Partial> + & Partial>; function normalizePositiveBytes(value: unknown): number { const numeric = Number(value); @@ -312,7 +313,7 @@ export function addMegaDebridAccountDailyUsageBytes( }; } -export function addMegaDebridAccountTotalUsageBytes( +export function addMegaDebridAccountTotalUsageBytes( settings: ProviderUsageSettings, accountId: string, byteDelta: number @@ -330,4 +331,93 @@ export function addMegaDebridAccountTotalUsageBytes( return { megaDebridAccountTotalUsageBytes: currentUsageBytes }; -} +} + +export function getRealDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number { + return normalizePositiveBytes(settings.realDebridAccountDailyLimitBytes?.[accountId]); +} + +export function getRealDebridAccountDailyUsageBytes( + settings: ProviderDailySettings, + accountId: string, + epochMs = Date.now() +): number { + if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) { + return 0; + } + return normalizePositiveBytes(settings.realDebridAccountDailyUsageBytes?.[accountId]); +} + +export function isRealDebridAccountDailyLimitReached( + settings: ProviderDailySettings, + accountId: string, + epochMs = Date.now() +): boolean { + const limit = getRealDebridAccountDailyLimitBytes(settings, accountId); + return limit > 0 && getRealDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit; +} + +export function getRealDebridAccountDailyRemainingBytes( + settings: ProviderDailySettings, + accountId: string, + epochMs = Date.now() +): number | null { + const limit = getRealDebridAccountDailyLimitBytes(settings, accountId); + if (limit <= 0) { + return null; + } + return Math.max(0, limit - getRealDebridAccountDailyUsageBytes(settings, accountId, epochMs)); +} + +export function getRealDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number { + return normalizePositiveBytes(settings.realDebridAccountTotalUsageBytes?.[accountId]); +} + +export function resetRealDebridAccountDailyUsage( + settings: ProviderDailySettings, + accountId?: string, + epochMs = Date.now() +): Pick { + const dayKey = getProviderUsageDayKey(epochMs); + if (!accountId) { + return { providerDailyUsageDay: dayKey, realDebridAccountDailyUsageBytes: {} }; + } + const currentUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.realDebridAccountDailyUsageBytes || {}) } + : {}; + delete currentUsageBytes[accountId]; + return { providerDailyUsageDay: dayKey, realDebridAccountDailyUsageBytes: currentUsageBytes }; +} + +export function addRealDebridAccountDailyUsageBytes( + settings: ProviderDailySettings, + accountId: string, + byteDelta: number, + epochMs = Date.now() +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const dayKey = getProviderUsageDayKey(epochMs); + const currentUsageBytes = settings.providerDailyUsageDay === dayKey + ? { ...(settings.realDebridAccountDailyUsageBytes || {}) } + : {}; + if (increment > 0) { + currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment; + } + return { + providerDailyUsageDay: dayKey, + realDebridAccountDailyUsageBytes: currentUsageBytes + }; +} + +export function addRealDebridAccountTotalUsageBytes( + settings: ProviderUsageSettings, + accountId: string, + byteDelta: number +): Pick { + const increment = normalizePositiveBytes(byteDelta); + const currentUsageBytes = { ...(settings.realDebridAccountTotalUsageBytes || {}) }; + if (increment > 0) { + currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment; + } + return { realDebridAccountTotalUsageBytes: currentUsageBytes }; +} diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index f65d4a9..2928992 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -1,17 +1,19 @@ 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 { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors"; -import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; +import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getAvailableRealDebridAccounts, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid"; const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; - resetDebridLinkRuntimeStateForTests(); - resetMegaDebridRuntimeStateForTests(); + resetDebridLinkRuntimeStateForTests(); + resetMegaDebridRuntimeStateForTests(); + resetRealDebridRuntimeStateForTests(); delete process.env.RD_MEGA_ABORT_MIN_RUN_MS; vi.restoreAllMocks(); }); @@ -2828,7 +2830,277 @@ describe("checkRapidgatorOnline", () => { }); }); -describe("filenameFromRapidgatorUrlPath", () => { +describe("Real-Debrid account rotation", () => { + const accountSettings = (accounts: Array<{ id: string; token: string }>) => ({ + ...defaultSettings(), + token: "", + realDebridUseWebLogin: false, + realDebridApiTokens: serializeRealDebridApiAccounts(accounts), + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + providerOrder: ["realdebrid"] as const, + autoProviderFallback: false + }); + + const successResponse = (accountId: string) => new Response(JSON.stringify({ + download: `https://download.example/${accountId}.bin`, + filename: `${accountId}.bin`, + filesize: 1234 + }), { status: 200, headers: { "Content-Type": "application/json" } }); + + it("returns the concrete account identity when the first API account succeeds", async () => { + globalThis.fetch = (async () => successResponse("rda_one")) as typeof fetch; + const service = new DebridService(accountSettings([{ id: "rda_one", token: "token-one" }])); + + const result = await service.unrestrictLink("https://hoster.example/first.bin"); + + expect(result.sourceAccountId).toBe("rda_one"); + expect(result.sourceAccountLabel).toBe("API-Token 1"); + }); + + it("fails over from a rejected API account to the next account in the same call", async () => { + const usedTokens: string[] = []; + globalThis.fetch = (async (_input, init) => { + const token = String((init?.headers as Record)?.Authorization || "").replace("Bearer ", ""); + usedTokens.push(token); + return token === "token-one" + ? new Response(JSON.stringify({ error: "bad_token", error_code: 8 }), { status: 401, headers: { "Content-Type": "application/json" } }) + : successResponse("rda_two"); + }) as typeof fetch; + const settings = accountSettings([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ]); + const service = new DebridService(settings); + + const result = await service.unrestrictLink("https://hoster.example/failover.bin"); + + expect(result.sourceAccountId).toBe("rda_two"); + expect(usedTokens).toEqual(["token-one", "token-two"]); + expect(getAvailableRealDebridAccounts(settings, Date.now() + 3 * 60 * 1000).map((account) => account.id)).toEqual(["rda_two"]); + }); + + it("cools down a rate-limited account and skips it on the next call", async () => { + const usedTokens: string[] = []; + globalThis.fetch = (async (_input, init) => { + const token = String((init?.headers as Record)?.Authorization || "").replace("Bearer ", ""); + usedTokens.push(token); + return token === "token-one" + ? new Response(JSON.stringify({ error: "too_many_requests", error_code: 34 }), { status: 429, headers: { "Content-Type": "application/json" } }) + : successResponse("rda_two"); + }) as typeof fetch; + const settings = accountSettings([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ]); + const service = new DebridService(settings); + + await service.unrestrictLink("https://hoster.example/limited-one.bin"); + const firstCallCount = usedTokens.length; + await service.unrestrictLink("https://hoster.example/limited-two.bin"); + + expect(usedTokens.slice(firstCallCount)).toEqual(["token-two"]); + expect(getAvailableRealDebridAccounts(settings, Date.now() + 3 * 60 * 1000).map((account) => account.id)).toEqual(["rda_two"]); + }); + + it("rotates from a timed-out API account to an isolated web account", async () => { + globalThis.fetch = (async () => { throw new Error("Timeout"); }) as typeof fetch; + const webAccounts: string[] = []; + const settings = { + ...accountSettings([{ id: "rda_one", token: "token-one" }]), + realDebridWebAccountIds: ["rdw_two"] + }; + const service = new DebridService(settings, { + realDebridWebUnrestrict: async (accountId) => { + webAccounts.push(accountId); + return { + fileName: "web.bin", + directUrl: "https://download.example/web.bin", + fileSize: 4321, + retriesUsed: 0 + }; + } + }); + + const result = await service.unrestrictLink("https://hoster.example/web-failover.bin"); + + expect(result.sourceAccountId).toBe("rdw_two"); + expect(webAccounts).toEqual(["rdw_two"]); + }); + + it("treats a pool with only disabled accounts as not configured", async () => { + const settings = { + ...accountSettings([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ]), + realDebridDisabledAccountIds: ["rda_one", "rda_two"] + }; + const service = new DebridService(settings); + + await expect(service.unrestrictLink("https://hoster.example/disabled.bin")).rejects.toThrow(/nicht konfiguriert/i); + }); + + it("reports an exhausted pool when every active account reached its own daily limit", async () => { + const settings = { + ...accountSettings([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ]), + providerDailyUsageDay: getProviderUsageDayKey(), + realDebridAccountDailyLimitBytes: { rda_one: 100, rda_two: 200 }, + realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 200 } + }; + const service = new DebridService(settings); + + await expect(service.unrestrictLink("https://hoster.example/daily-limit.bin")).rejects.toThrow(/Real-Debrid.*Accounts.*ausgesch/i); + }); + + it("propagates a caller abort without trying another account", async () => { + const controller = new AbortController(); + const attempted: string[] = []; + const settings = { + ...accountSettings([]), + realDebridWebAccountIds: ["rdw_one", "rdw_two"] + }; + const service = new DebridService(settings, { + realDebridWebUnrestrict: async (accountId) => { + attempted.push(accountId); + controller.abort("stop"); + throw new Error("aborted:stop"); + } + }); + + await expect(service.unrestrictLink("https://hoster.example/abort.bin", controller.signal)).rejects.toThrow(/aborted/i); + expect(attempted).toEqual(["rdw_one"]); + }); + + it("shares sequential successes fairly across the available API accounts", async () => { + const usedTokens: string[] = []; + globalThis.fetch = (async (_input, init) => { + const token = String((init?.headers as Record)?.Authorization || "").replace("Bearer ", ""); + usedTokens.push(token); + return successResponse(token === "token-one" ? "rda_one" : "rda_two"); + }) as typeof fetch; + const service = new DebridService(accountSettings([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ])); + + for (let index = 0; index < 8; index += 1) { + await service.unrestrictLink(`https://hoster.example/fair-${index}.bin`); + } + + expect(usedTokens.filter((token) => token === "token-one")).toHaveLength(4); + expect(usedTokens.filter((token) => token === "token-two")).toHaveLength(4); + }); + + it("keeps round-robin fair when the middle configured account is disabled", async () => { + const usedTokens: string[] = []; + globalThis.fetch = (async (_input, init) => { + const token = String((init?.headers as Record)?.Authorization || "").replace("Bearer ", ""); + usedTokens.push(token); + return successResponse(token === "token-one" ? "rda_one" : "rda_three"); + }) as typeof fetch; + const settings = { + ...accountSettings([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" }, + { id: "rda_three", token: "token-three" } + ]), + realDebridDisabledAccountIds: ["rda_two"] + }; + const service = new DebridService(settings); + + for (let index = 0; index < 16; index += 1) { + await service.unrestrictLink(`https://hoster.example/filtered-fair-${index}.bin`); + } + + expect(usedTokens.filter((token) => token === "token-one")).toHaveLength(8); + expect(usedTokens.filter((token) => token === "token-three")).toHaveLength(8); + expect(usedTokens).not.toContain("token-two"); + }); + + it("does not rotate or cool down an account for a permanent link error", async () => { + const usedTokens: string[] = []; + globalThis.fetch = (async (_input, init) => { + const token = String((init?.headers as Record)?.Authorization || "").replace("Bearer ", ""); + usedTokens.push(token); + return new Response(JSON.stringify({ error: "file_unavailable", error_code: 22 }), { + status: 400, + headers: { "Content-Type": "application/json" } + }); + }) as typeof fetch; + const service = new DebridService(accountSettings([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ])); + + await expect(service.unrestrictLink("https://hoster.example/missing.bin")).rejects.toThrow(/file_unavailable/i); + + expect(usedTokens).toEqual(["token-one"]); + expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(0); + }); + + it("does not rotate or cool down accounts for a provider-wide hoster_unavailable response", async () => { + const usedTokens: string[] = []; + globalThis.fetch = (async (_input, init) => { + const token = String((init?.headers as Record)?.Authorization || "").replace("Bearer ", ""); + const link = String((init?.body as URLSearchParams)?.get("link") || ""); + usedTokens.push(token); + if (link.includes("hoster-down")) { + return new Response(JSON.stringify({ error: "hoster_unavailable", error_code: 19 }), { + status: 503, + headers: { "Content-Type": "application/json" } + }); + } + return successResponse("rda_one"); + }) as typeof fetch; + const service = new DebridService(accountSettings([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ])); + + await expect(service.unrestrictLink("https://hoster-down.example/file.bin")).rejects.toThrow(/hoster_unavailable/i); + + expect(new Set(usedTokens)).toEqual(new Set(["token-one"])); + expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(0); + + usedTokens.length = 0; + const result = await service.unrestrictLink("https://hoster-up.example/file.bin"); + expect(result.sourceAccountId).toBe("rda_one"); + expect(usedTokens).toEqual(["token-one"]); + }); + + it("routes concurrent unrestrict calls to the least busy accounts", async () => { + const usedTokens: string[] = []; + let releaseResponses: (() => void) | null = null; + const responseGate = new Promise((resolve) => { releaseResponses = resolve; }); + globalThis.fetch = (async (_input, init) => { + const token = String((init?.headers as Record)?.Authorization || "").replace("Bearer ", ""); + usedTokens.push(token); + if (usedTokens.length === 2) { + releaseResponses?.(); + } + await responseGate; + return successResponse(token === "token-one" ? "rda_one" : "rda_two"); + }) as typeof fetch; + const service = new DebridService(accountSettings([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ])); + + await Promise.all([ + service.unrestrictLink("https://hoster.example/concurrent-one.bin"), + service.unrestrictLink("https://hoster.example/concurrent-two.bin") + ]); + + expect(usedTokens).toEqual(["token-one", "token-two"]); + }); +}); + +describe("filenameFromRapidgatorUrlPath", () => { it("extracts filename from standard rapidgator URL", () => { expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html")) .toBe("Show.S01E01.part01.rar"); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index fa4c7a8..5039d49 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -6,7 +6,7 @@ import crypto from "node:crypto"; import { EventEmitter, once } from "node:events"; import AdmZip from "adm-zip"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager"; +import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager"; import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion"; import { DiskReservationCoordinator } from "../src/main/disk-space"; import { defaultSettings } from "../src/main/constants"; @@ -15,8 +15,9 @@ import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; import { createStoragePaths, emptySession } from "../src/main/storage"; -import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid"; +import { getProviderRuntimeSnapshot, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests, primeRealDebridRuntimeCooldownForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; import { UnrestrictedLink } from "../src/main/realdebrid"; import { resetVideoToolingCache } from "../src/main/video-processor"; @@ -44,6 +45,48 @@ describe("runWithLimitedConcurrency", () => { }); }); +describe("resolveUnrestrictTimeoutBudgetMs", () => { + it("covers the complete provider plan and each later Real-Debrid account attempt", () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key", + bestToken: "best-token", + realDebridApiTokens: serializeRealDebridApiAccounts([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" }, + { id: "rda_three", token: "token-three" } + ]), + realDebridDisabledAccountIds: ["rda_three"], + providerOrder: ["debridlink", "realdebrid", "bestdebrid"] as const, + autoProviderFallback: true + }; + + expect(resolveUnrestrictTimeoutBudgetMs(5_000, "debridlink", settings, "https://hoster.example/file", 35_000)).toBe(80_000); + }); + + it("includes a Real-Debrid pool selected only by hoster routing", () => { + const settings = { + ...defaultSettings(), + debridLinkApiKeys: "dl-key", + realDebridApiTokens: serializeRealDebridApiAccounts([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ]), + providerOrder: ["debridlink"] as const, + hosterRouting: { rapidgator: "realdebrid" as const }, + autoProviderFallback: true + }; + + expect(resolveUnrestrictTimeoutBudgetMs( + 5_000, + null, + settings, + "https://rapidgator.net/file/abc123/file.rar.html", + 35_000 + )).toBeGreaterThanOrEqual(70_000); + }); +}); + describe("disk write recovery", () => { it("classifies retryable disk write stalls without treating permission errors as temporary", () => { expect(getDiskWriteWaitReason(Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" }))).toMatch(/Festplatte voll/); @@ -765,6 +808,7 @@ afterEach(async () => { resetVideoToolingCache(); resetDebridLinkRuntimeStateForTests(); resetMegaDebridRuntimeStateForTests(); + resetRealDebridRuntimeStateForTests(); shutdownItemLogs(); shutdownPackageLogs(); shutdownRenameLog(); @@ -13158,7 +13202,7 @@ describe("download manager", () => { expect((internal.settings.providerTotalUsageBytes as Record).megadebrid).toBeUndefined(); }); - it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => { + it("tracks daily usage on the actual Debrid-Link key without touching other keys", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const [firstKey, secondKey] = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); @@ -13189,9 +13233,77 @@ describe("download manager", () => { expect(internal.settings.debridLinkApiKeyDailyUsageBytes[firstKey.id]).toBe(1024); expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512); expect(internal.settings.debridLinkApiKeyTotalUsageBytes[firstKey.id]).toBe(1024); - expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048); - }); - + expect(internal.settings.debridLinkApiKeyTotalUsageBytes[secondKey.id]).toBe(2048); + }); + + it("tracks Real-Debrid traffic only on the account that produced the direct link", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const settings = { + ...defaultSettings(), + providerDailyUsageDay: getProviderUsageDayKey(), + realDebridApiTokens: serializeRealDebridApiAccounts([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ]), + realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 200 }, + realDebridAccountTotalUsageBytes: { rda_one: 1000, rda_two: 2000 } + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + const internal = manager as unknown as { + recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number, providerAccountId?: string) => void; + settings: typeof settings; + }; + + internal.recordProviderDownloadedBytes("realdebrid", 50, "rda_two"); + + expect(internal.settings.realDebridAccountDailyUsageBytes).toEqual({ rda_one: 100, rda_two: 250 }); + expect(internal.settings.realDebridAccountTotalUsageBytes).toEqual({ rda_one: 1000, rda_two: 2050 }); + }); + + it("does not recreate account usage when the source account was removed during the download", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const settings = { + ...defaultSettings(), + providerDailyUsageDay: getProviderUsageDayKey(), + providerDailyUsageBytes: { realdebrid: 100 }, + providerTotalUsageBytes: { realdebrid: 1000 }, + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_one", token: "token-one" }]), + realDebridAccountDailyUsageBytes: {}, + realDebridAccountTotalUsageBytes: {} + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + manager.setSettings({ ...settings, realDebridApiTokens: "" }); + const internal = manager as unknown as { + recordProviderDownloadedBytes: (provider: "realdebrid", bytes: number, providerAccountId?: string) => void; + settings: typeof settings; + }; + + internal.recordProviderDownloadedBytes("realdebrid", 50, "rda_one"); + + expect(internal.settings.providerDailyUsageBytes.realdebrid).toBe(150); + expect(internal.settings.providerTotalUsageBytes.realdebrid).toBe(1050); + expect(internal.settings.realDebridAccountDailyUsageBytes).toEqual({}); + expect(internal.settings.realDebridAccountTotalUsageBytes).toEqual({}); + }); + + it("prunes removed Real-Debrid account runtime state on a live settings update", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const settings = { + ...defaultSettings(), + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: "rda_one", token: "token-one" }]) + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + primeRealDebridRuntimeCooldownForTests("rda_one", 60_000); + expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(1); + + manager.setSettings({ ...settings, realDebridApiTokens: "" }); + + expect(getProviderRuntimeSnapshot().realDebrid.cooldownCount).toBe(0); + }); + it("does not hang when rapid stop is followed by disabling the last provider", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/provider-daily-limits.test.ts b/tests/provider-daily-limits.test.ts new file mode 100644 index 0000000..21f0e07 --- /dev/null +++ b/tests/provider-daily-limits.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { + addRealDebridAccountDailyUsageBytes, + addRealDebridAccountTotalUsageBytes, + getProviderUsageDayKey, + getRealDebridAccountDailyRemainingBytes, + getRealDebridAccountDailyUsageBytes, + getRealDebridAccountTotalUsageBytes, + isRealDebridAccountDailyLimitReached, + resetRealDebridAccountDailyUsage +} from "../src/shared/provider-daily-limits"; + +describe("Real-Debrid account usage", () => { + it("counts daily and lifetime traffic only for the selected account", () => { + const settings = { + ...defaultSettings(), + providerDailyUsageDay: getProviderUsageDayKey(), + realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 200 }, + realDebridAccountTotalUsageBytes: { rda_one: 1000, rda_two: 2000 } + }; + + const daily = addRealDebridAccountDailyUsageBytes(settings, "rda_two", 50); + const total = addRealDebridAccountTotalUsageBytes(settings, "rda_two", 50); + + expect(daily.realDebridAccountDailyUsageBytes).toEqual({ rda_one: 100, rda_two: 250 }); + expect(total.realDebridAccountTotalUsageBytes).toEqual({ rda_one: 1000, rda_two: 2050 }); + }); + + it("resets stale daily usage before adding new account traffic", () => { + const settings = { + ...defaultSettings(), + providerDailyUsageDay: "2000-01-01", + realDebridAccountDailyUsageBytes: { rda_one: 900 } + }; + + const next = addRealDebridAccountDailyUsageBytes(settings, "rda_two", 75); + + expect(next.providerDailyUsageDay).toBe(getProviderUsageDayKey()); + expect(next.realDebridAccountDailyUsageBytes).toEqual({ rda_two: 75 }); + expect(getRealDebridAccountDailyUsageBytes(settings, "rda_one")).toBe(0); + }); + + it("marks only the account whose own daily limit is reached", () => { + const settings = { + ...defaultSettings(), + providerDailyUsageDay: getProviderUsageDayKey(), + realDebridAccountDailyLimitBytes: { rda_one: 100, rda_two: 500 }, + realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 100 } + }; + + expect(isRealDebridAccountDailyLimitReached(settings, "rda_one")).toBe(true); + expect(isRealDebridAccountDailyLimitReached(settings, "rda_two")).toBe(false); + expect(getRealDebridAccountDailyRemainingBytes(settings, "rda_two")).toBe(400); + expect(getRealDebridAccountTotalUsageBytes({ ...settings, realDebridAccountTotalUsageBytes: { rda_two: 900 } }, "rda_two")).toBe(900); + }); + + it("resets one Real-Debrid account without clearing the others", () => { + const settings = { + ...defaultSettings(), + providerDailyUsageDay: getProviderUsageDayKey(), + realDebridAccountDailyUsageBytes: { rda_one: 100, rda_two: 200 } + }; + + const next = resetRealDebridAccountDailyUsage(settings, "rda_one"); + + expect(next.realDebridAccountDailyUsageBytes).toEqual({ rda_two: 200 }); + }); +}); diff --git a/tests/realdebrid.test.ts b/tests/realdebrid.test.ts index cc37e9b..023be58 100644 --- a/tests/realdebrid.test.ts +++ b/tests/realdebrid.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { RealDebridClient } from "../src/main/realdebrid"; +import { RealDebridApiError, RealDebridClient } from "../src/main/realdebrid"; const originalFetch = globalThis.fetch; @@ -20,7 +20,7 @@ describe("realdebrid client", () => { await expect(client.unrestrictLink("https://hoster.example/file/html")).rejects.toThrow(/html/i); }); - it("does not leak raw response body on JSON parse errors", async () => { + it("does not leak raw response body on JSON parse errors", async () => { globalThis.fetch = (async (): Promise => { return new Response("token=secret-should-not-leak", { status: 200, @@ -37,6 +37,38 @@ describe("realdebrid client", () => { expect(text.toLowerCase()).toContain("json"); expect(text.toLowerCase()).not.toContain("secret-should-not-leak"); expect(text.toLowerCase()).not.toContain(""); - } - }); -}); + } + }); + + it("preserves the HTTP status and structured bad_token error", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + error: "bad_token", + error_code: 8 + }), { + status: 401, + headers: { "Content-Type": "application/json" } + })) as typeof fetch; + + const client = new RealDebridClient("rd-token"); + const error = await client.unrestrictLink("https://hoster.example/file/auth").then(() => null, (value) => value); + + expect(error).toBeInstanceOf(RealDebridApiError); + expect(error).toMatchObject({ status: 401, apiError: "bad_token", apiErrorCode: 8 }); + }); + + it("preserves too_many_requests after the client's retries", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify({ + error: "too_many_requests", + error_code: 34 + }), { + status: 429, + headers: { "Content-Type": "application/json", "Retry-After": "0" } + })) as typeof fetch; + + const client = new RealDebridClient("rd-token"); + const error = await client.unrestrictLink("https://hoster.example/file/rate").then(() => null, (value) => value); + + expect(error).toBeInstanceOf(RealDebridApiError); + expect(error).toMatchObject({ status: 429, apiError: "too_many_requests", apiErrorCode: 34 }); + }); +}); diff --git a/tests/startup-health-check.test.ts b/tests/startup-health-check.test.ts index 7cb553d..998c561 100644 --- a/tests/startup-health-check.test.ts +++ b/tests/startup-health-check.test.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { defaultSettings } from "../src/main/constants"; +import { defaultSettings } from "../src/main/constants"; +import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import { createStoragePaths } from "../src/main/storage"; import { runStartupHealthCheck } from "../src/main/startup-health-check"; @@ -66,7 +67,7 @@ describe("runStartupHealthCheck", () => { expect(report.warnCount).toBeGreaterThanOrEqual(1); }); - it("reports configured providers when at least one credential is set", () => { + it("reports configured providers when at least one credential is set", () => { const { outputDir, paths } = makeTempBase(); fs.mkdirSync(paths.baseDir, { recursive: true }); @@ -82,7 +83,27 @@ describe("runStartupHealthCheck", () => { expect(providersFinding?.message).toContain("Real-Debrid"); expect(providersFinding?.message).toContain("Debrid-Link"); expect(providersFinding?.message).toContain("2 Keys"); - }); + }); + + it("recognizes a Real-Debrid account pool without legacy singleton fields", () => { + const { outputDir, paths } = makeTempBase(); + fs.mkdirSync(paths.baseDir, { recursive: true }); + const settings = { + ...defaultSettings(), + token: "", + realDebridUseWebLogin: false, + outputDir, + realDebridApiTokens: serializeRealDebridApiAccounts([ + { id: "rda_one", token: "token-one" }, + { id: "rda_two", token: "token-two" } + ]) + }; + + const report = runStartupHealthCheck(settings, paths); + const providersFinding = report.findings.find((finding) => finding.code === "providers_configured"); + + expect(providersFinding?.message).toContain("Real-Debrid (2 Accounts)"); + }); it("flags large state files", () => { const { outputDir, paths } = makeTempBase(); diff --git a/tests/support-data.test.ts b/tests/support-data.test.ts new file mode 100644 index 0000000..126f57f --- /dev/null +++ b/tests/support-data.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { buildAccountSummary } from "../src/main/support-data"; +import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; + +describe("Real-Debrid support summary", () => { + it("reports pool counts without exposing account IDs or credentials", () => { + const summary = buildAccountSummary({ + ...defaultSettings(), + realDebridApiTokens: serializeRealDebridApiAccounts([ + { id: "rda_private_one", token: "secret-one" }, + { id: "rda_private_two", token: "secret-two" } + ]), + realDebridWebAccountIds: ["rdw_private_three"], + realDebridDisabledAccountIds: ["rda_private_two"] + }); + const realDebrid = summary.realDebrid as Record; + const serialized = JSON.stringify(realDebrid); + + expect(realDebrid).toMatchObject({ + configured: true, + accountCount: 3, + enabledAccountCount: 2, + disabledAccountCount: 1, + apiAccountCount: 2, + webAccountCount: 1 + }); + expect(serialized).not.toContain("rda_private"); + expect(serialized).not.toContain("rdw_private"); + expect(serialized).not.toContain("secret-"); + }); +});