feat(realdebrid): rotate accounts during unrestrict

This commit is contained in:
Sucukdeluxe
2026-08-15 21:16:18 +02:00
parent 53cdac1ded
commit 7e65195057
13 changed files with 1159 additions and 139 deletions
+1 -6
View File
@@ -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<void> {
const accountId = String(request.accountId || "").trim();
if (!isRealDebridWebAccountId(accountId)) {
+321 -40
View File
@@ -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<UnrestrictedLink | null>;
export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type RealDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type RealDebridWebUnrestrictor = (accountId: string, link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
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<string, RealDebridCooldownDetail>();
const realDebridInFlight = new Map<string, number>();
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<string>): 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<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
}
const nextDebridLinkKeyIds = new Set<string>(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<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;
}
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<UnrestrictedLink> {
const failures: string[] = [];
const attempted = new Set<string>();
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<UnrestrictedLink> {
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);
+94 -22
View File
@@ -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<DebridProvider>();
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)) {
+63 -31
View File
@@ -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<string, unknown>;
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<UnrestrictedLink> {
let lastError = "";
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
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");
}
}
+9 -5
View File
@@ -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");
+16 -8
View File
@@ -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<string, unknown> {
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))
+98 -8
View File
@@ -5,14 +5,15 @@ export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
export type DebridLinkKeyByteMap = Record<string, number>;
type ProviderDailySettings =
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridApiDisabledAccountIds" | "megaDebridWebDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridApiDisabledAccountIds" | "megaDebridWebDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>
& Partial<Pick<AppSettings, "realDebridAccountDailyLimitBytes" | "realDebridAccountDailyUsageBytes">>;
type ProviderUsageSettings =
ProviderDailySettings
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
ProviderDailySettings
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes" | "realDebridAccountTotalUsageBytes">>;
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<AppSettings, "providerDailyUsageDay" | "realDebridAccountDailyUsageBytes"> {
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<AppSettings, "providerDailyUsageDay" | "realDebridAccountDailyUsageBytes"> {
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<AppSettings, "realDebridAccountTotalUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const currentUsageBytes = { ...(settings.realDebridAccountTotalUsageBytes || {}) };
if (increment > 0) {
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
}
return { realDebridAccountTotalUsageBytes: currentUsageBytes };
}