feat(realdebrid): rotate accounts during unrestrict
This commit is contained in:
@@ -151,7 +151,7 @@ export class AppController {
|
|||||||
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
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),
|
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),
|
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),
|
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
||||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||||
protectEmptyClobber: loadResult.status === "empty-unreadable",
|
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> {
|
public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise<void> {
|
||||||
const accountId = String(request.accountId || "").trim();
|
const accountId = String(request.accountId || "").trim();
|
||||||
if (!isRealDebridWebAccountId(accountId)) {
|
if (!isRealDebridWebAccountId(accountId)) {
|
||||||
|
|||||||
+321
-40
@@ -1,14 +1,15 @@
|
|||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, type MegaDebridAccountEntry, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts";
|
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 { extractHosterFromUrl } from "../shared/hoster";
|
||||||
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
|
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 { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
|
||||||
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { logAccountRotation } from "./account-rotation-log";
|
import { logAccountRotation } from "./account-rotation-log";
|
||||||
import { traceConversionPhase } from "./conversion-trace";
|
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 { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
|
||||||
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
|
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
|
||||||
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
||||||
@@ -422,7 +423,7 @@ function setMegaDebridAccountCooldownState(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMegaDebridAccountCooldownState(
|
export function getMegaDebridAccountCooldownState(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
now = Date.now()
|
now = Date.now()
|
||||||
): { until: number; remainingMs: number; message: string; category: MegaDebridCooldownCategory; untilRestart: boolean } | null {
|
): { until: number; remainingMs: number; message: string; category: MegaDebridCooldownCategory; untilRestart: boolean } | null {
|
||||||
@@ -451,9 +452,15 @@ export interface ProviderRuntimeCooldown {
|
|||||||
untilRestart?: boolean;
|
untilRestart?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderRuntimeSnapshot {
|
export interface ProviderRuntimeSnapshot {
|
||||||
capturedAtMs: number;
|
capturedAtMs: number;
|
||||||
megaDebrid: {
|
realDebrid: {
|
||||||
|
rotationCursor: number;
|
||||||
|
stickyCount: number;
|
||||||
|
cooldownCount: number;
|
||||||
|
inFlightCount: number;
|
||||||
|
};
|
||||||
|
megaDebrid: {
|
||||||
rotationCursor: number;
|
rotationCursor: number;
|
||||||
stickyCount: number;
|
stickyCount: number;
|
||||||
accounts: Array<{
|
accounts: Array<{
|
||||||
@@ -532,9 +539,15 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
capturedAtMs: now,
|
capturedAtMs: now,
|
||||||
megaDebrid: {
|
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,
|
rotationCursor: megaDebridRotationCursor,
|
||||||
stickyCount: megaDebridStickyCount,
|
stickyCount: megaDebridStickyCount,
|
||||||
accounts: megaAccounts
|
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 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 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>;
|
export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
|
||||||
|
|
||||||
interface DebridServiceOptions {
|
interface DebridServiceOptions {
|
||||||
@@ -585,7 +598,12 @@ function cloneSettings(settings: AppSettings): AppSettings {
|
|||||||
providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) },
|
providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) },
|
||||||
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
|
debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
|
||||||
debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) },
|
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 || [])],
|
megaDebridDisabledAccountIds: [...(settings.megaDebridDisabledAccountIds || [])],
|
||||||
megaDebridApiDisabledAccountIds: [...(settings.megaDebridApiDisabledAccountIds || [])],
|
megaDebridApiDisabledAccountIds: [...(settings.megaDebridApiDisabledAccountIds || [])],
|
||||||
megaDebridWebDisabledAccountIds: [...(settings.megaDebridWebDisabledAccountIds || [])],
|
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[] {
|
function getMegaDebridAccountList(settings: AppSettings, mode: MegaDebridAccountMode): MegaDebridAccountEntry[] {
|
||||||
const multiAccounts = getMegaDebridAccountsForMode(settings, mode);
|
const multiAccounts = getMegaDebridAccountsForMode(settings, mode);
|
||||||
if (multiAccounts.length > 0) {
|
if (multiAccounts.length > 0) {
|
||||||
@@ -3699,9 +3852,10 @@ export class DebridService {
|
|||||||
MegaDebridClient.clearCachedApiToken(prevAcc.login);
|
MegaDebridClient.clearCachedApiToken(prevAcc.login);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
|
const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
|
||||||
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
|
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
|
||||||
}
|
pruneRealDebridRuntimeStateForAccounts(new Set(this.getConfiguredRealDebridAccounts(next).map((account) => account.id)));
|
||||||
|
}
|
||||||
|
|
||||||
private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {
|
private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {
|
||||||
if (this.cachedDebridLinkClient && this.cachedDebridLinkKey === apiKeysRaw) {
|
if (this.cachedDebridLinkClient && this.cachedDebridLinkKey === apiKeysRaw) {
|
||||||
@@ -3798,9 +3952,15 @@ export class DebridService {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldUseRealDebridWeb(settings: AppSettings): boolean {
|
private getConfiguredRealDebridAccounts(settings: AppSettings): RealDebridAccountEntry[] {
|
||||||
return Boolean(settings.realDebridUseWebLogin && this.options.realDebridWebUnrestrict);
|
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 {
|
private shouldUseAllDebridWeb(settings: AppSettings): boolean {
|
||||||
return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict);
|
return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict);
|
||||||
@@ -3810,8 +3970,14 @@ export class DebridService {
|
|||||||
return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict);
|
return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isProviderDailyLimited(settings: AppSettings, provider: DebridProvider): boolean {
|
private isProviderDailyLimited(settings: AppSettings, provider: DebridProvider): boolean {
|
||||||
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
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") {
|
if (effectiveProvider === "debridlink") {
|
||||||
const configuredKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
|
const configuredKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
|
||||||
if (configuredKeys.length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
|
if (configuredKeys.length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
|
||||||
@@ -3832,8 +3998,13 @@ export class DebridService {
|
|||||||
return this.isProviderConfiguredFor(settings, provider) && !this.isProviderDailyLimited(settings, provider);
|
return this.isProviderConfiguredFor(settings, provider) && !this.isProviderDailyLimited(settings, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatProviderLimitMessage(settings: AppSettings, provider: DebridProvider): string {
|
private formatProviderLimitMessage(settings: AppSettings, provider: DebridProvider): string {
|
||||||
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
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) {
|
if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
|
||||||
return "Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft)";
|
return "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 {
|
private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean {
|
||||||
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
||||||
if ((settings.disabledProviders || []).includes(provider) || (settings.disabledProviders || []).includes(effectiveProvider)) return false;
|
if ((settings.disabledProviders || []).includes(provider) || (settings.disabledProviders || []).includes(effectiveProvider)) return false;
|
||||||
if (effectiveProvider === "realdebrid") {
|
if (effectiveProvider === "realdebrid") {
|
||||||
return Boolean(this.shouldUseRealDebridWeb(settings) || settings.token.trim());
|
return this.getConfiguredRealDebridAccounts(settings).some((account) => account.enabled);
|
||||||
}
|
}
|
||||||
if (effectiveProvider === "megadebrid-api") {
|
if (effectiveProvider === "megadebrid-api") {
|
||||||
return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "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(settings.linkSnappyLogin.trim() && settings.linkSnappyPassword.trim());
|
||||||
}
|
}
|
||||||
return Boolean(this.shouldUseBestDebridWeb(settings) || settings.bestToken.trim());
|
return Boolean(this.shouldUseBestDebridWeb(settings) || settings.bestToken.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
private selectRealDebridAccount(accounts: RealDebridAccountEntry[]): RealDebridAccountEntry {
|
||||||
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
const minimumInFlight = Math.min(...accounts.map((account) => realDebridInFlight.get(account.id) || 0));
|
||||||
if (effectiveProvider === "realdebrid") {
|
const leastBusy = accounts.filter((account) => (realDebridInFlight.get(account.id) || 0) === minimumInFlight);
|
||||||
if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) {
|
const sticky = leastBusy.find((account) => account.id === realDebridStickyAccountId);
|
||||||
const result = await this.options.realDebridWebUnrestrict(link, signal);
|
if (sticky && realDebridStickyCount < REAL_DEBRID_STICKY_LINKS) {
|
||||||
if (!result) {
|
return sticky;
|
||||||
throw new Error("Real-Debrid-Web-Fallback nicht verfügbar");
|
}
|
||||||
}
|
const selected = leastBusy[realDebridRotationCursor % leastBusy.length];
|
||||||
result.sourceLabel = "Web";
|
return selected;
|
||||||
return result;
|
}
|
||||||
}
|
|
||||||
const result = await new RealDebridClient(settings.token).unrestrictLink(link, signal);
|
private classifyRealDebridFailure(error: unknown): RealDebridFailureClassification {
|
||||||
result.sourceLabel = "API";
|
const message = compactErrorText(error);
|
||||||
return result;
|
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") {
|
if (effectiveProvider === "megadebrid-api") {
|
||||||
return MegaDebridClient.unrestrictWithAccounts(settings, "api", provider === "megadebrid" && settings.megaDebridPreferApi, link, this.options.megaWebUnrestrict, signal);
|
return MegaDebridClient.unrestrictWithAccounts(settings, "api", provider === "megadebrid" && settings.megaDebridPreferApi, link, this.options.megaWebUnrestrict, signal);
|
||||||
|
|||||||
@@ -26,13 +26,16 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
|||||||
import { extractHosterFromUrl } from "../shared/hoster";
|
import { extractHosterFromUrl } from "../shared/hoster";
|
||||||
import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
|
import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
|
||||||
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
|
||||||
import {
|
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
|
||||||
|
import {
|
||||||
addDebridLinkApiKeyDailyUsageBytes,
|
addDebridLinkApiKeyDailyUsageBytes,
|
||||||
addDebridLinkApiKeyTotalUsageBytes,
|
addDebridLinkApiKeyTotalUsageBytes,
|
||||||
addMegaDebridAccountDailyUsageBytes,
|
addMegaDebridAccountDailyUsageBytes,
|
||||||
addMegaDebridAccountTotalUsageBytes,
|
addMegaDebridAccountTotalUsageBytes,
|
||||||
addProviderDailyUsageBytes,
|
addProviderDailyUsageBytes,
|
||||||
addProviderTotalUsageBytes,
|
addProviderTotalUsageBytes,
|
||||||
|
addRealDebridAccountDailyUsageBytes,
|
||||||
|
addRealDebridAccountTotalUsageBytes,
|
||||||
getProviderUsageDayKey,
|
getProviderUsageDayKey,
|
||||||
isProviderDailyLimitReached
|
isProviderDailyLimitReached
|
||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
@@ -55,7 +58,7 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
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 { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
|
||||||
import { validateFileAgainstManifest } from "./integrity";
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
import { classifyDiskError } from "./fs-error";
|
import { classifyDiskError } from "./fs-error";
|
||||||
@@ -353,13 +356,55 @@ function getPostExtractTimeoutMs(): number {
|
|||||||
return DEFAULT_POST_EXTRACT_TIMEOUT_MS;
|
return DEFAULT_POST_EXTRACT_TIMEOUT_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUnrestrictTimeoutMs(): number {
|
function getUnrestrictTimeoutMs(): number {
|
||||||
const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN);
|
const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN);
|
||||||
if (Number.isFinite(fromEnv) && fromEnv >= 5000 && fromEnv <= 15 * 60 * 1000) {
|
if (Number.isFinite(fromEnv) && fromEnv >= 5000 && fromEnv <= 15 * 60 * 1000) {
|
||||||
return Math.floor(fromEnv);
|
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 {
|
function getLowThroughputTimeoutMs(): number {
|
||||||
const fromEnv = Number(process.env.RD_LOW_THROUGHPUT_TIMEOUT_MS ?? NaN);
|
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[] }> = [
|
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.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] },
|
||||||
{ prev: previous.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] },
|
{ prev: previous.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] },
|
||||||
{ prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] },
|
{ prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] },
|
||||||
@@ -8148,9 +8197,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.settings.providerDailyUsageDay = currentDay;
|
this.settings.providerDailyUsageDay = currentDay;
|
||||||
this.settings.providerDailyUsageBytes = {};
|
this.settings.providerDailyUsageBytes = {};
|
||||||
this.settings.debridLinkApiKeyDailyUsageBytes = {};
|
this.settings.debridLinkApiKeyDailyUsageBytes = {};
|
||||||
this.settings.megaDebridAccountDailyUsageBytes = {};
|
this.settings.megaDebridAccountDailyUsageBytes = {};
|
||||||
|
this.settings.realDebridAccountDailyUsageBytes = {};
|
||||||
this.statsCache = null;
|
this.statsCache = null;
|
||||||
this.statsCacheAt = 0;
|
this.statsCacheAt = 0;
|
||||||
if (persist) {
|
if (persist) {
|
||||||
@@ -8178,13 +8228,26 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes;
|
this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes;
|
||||||
this.settings.debridLinkApiKeyTotalUsageBytes = nextKeyTotalUsage.debridLinkApiKeyTotalUsageBytes;
|
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 nextAcctUsage = addMegaDebridAccountDailyUsageBytes(this.settings, providerAccountId, byteDelta);
|
||||||
const nextAcctTotalUsage = addMegaDebridAccountTotalUsageBytes(this.settings, providerAccountId, byteDelta);
|
const nextAcctTotalUsage = addMegaDebridAccountTotalUsageBytes(this.settings, providerAccountId, byteDelta);
|
||||||
this.settings.providerDailyUsageDay = nextAcctUsage.providerDailyUsageDay;
|
this.settings.providerDailyUsageDay = nextAcctUsage.providerDailyUsageDay;
|
||||||
this.settings.megaDebridAccountDailyUsageBytes = nextAcctUsage.megaDebridAccountDailyUsageBytes;
|
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 {
|
private isProviderConfigured(provider: DebridProvider): boolean {
|
||||||
@@ -8196,8 +8259,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (isProviderDailyLimitReached(this.settings, effectiveProvider)) {
|
if (isProviderDailyLimitReached(this.settings, effectiveProvider)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (effectiveProvider === "realdebrid") {
|
if (effectiveProvider === "realdebrid") {
|
||||||
return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim());
|
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") {
|
if (effectiveProvider === "megadebrid-api") {
|
||||||
const hasMegaCreds = getAvailableMegaDebridAccounts(this.settings, "api").length > 0;
|
const hasMegaCreds = getAvailableMegaDebridAccounts(this.settings, "api").length > 0;
|
||||||
@@ -8646,10 +8711,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
allDebridPruned += 1;
|
allDebridPruned += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const dlPruned = pruneExpiredDebridLinkRuntimeState(now);
|
const dlPruned = pruneExpiredDebridLinkRuntimeState(now);
|
||||||
const mdPruned = pruneExpiredMegaDebridRuntimeState(now);
|
const mdPruned = pruneExpiredMegaDebridRuntimeState(now);
|
||||||
if (allDebridPruned > 0 || dlPruned > 0 || mdPruned > 0) {
|
const rdPruned = pruneExpiredRealDebridRuntimeState(now);
|
||||||
logger.info(`Soft-Reset: pruned ${allDebridPruned} AllDebrid host entries, ${dlPruned} Debrid-Link entries, ${mdPruned} Mega-Debrid entries`);
|
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();
|
this.emitState();
|
||||||
return;
|
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]);
|
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
||||||
let unrestricted;
|
let unrestricted;
|
||||||
try {
|
try {
|
||||||
@@ -9270,7 +9342,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
traceConversionPhase({
|
traceConversionPhase({
|
||||||
phase: "caller-timeout",
|
phase: "caller-timeout",
|
||||||
outcome: "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;
|
throw innerError;
|
||||||
@@ -9280,7 +9352,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} catch (unrestrictError) {
|
} catch (unrestrictError) {
|
||||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||||
this.recordProviderFailure(cooldownProvider);
|
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);
|
const errText = compactErrorText(unrestrictError);
|
||||||
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) {
|
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) {
|
||||||
|
|||||||
+63
-31
@@ -113,13 +113,41 @@ function looksLikeHtmlResponse(contentType: string, body: string): boolean {
|
|||||||
return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
|
return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseErrorBody(status: number, body: string, contentType: string): string {
|
function parseErrorBody(status: number, body: string, contentType: string): RealDebridApiError {
|
||||||
if (looksLikeHtmlResponse(contentType, body)) {
|
if (looksLikeHtmlResponse(contentType, body)) {
|
||||||
return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`;
|
return new RealDebridApiError(status, "html_response", null, "Real-Debrid lieferte HTML statt JSON");
|
||||||
}
|
}
|
||||||
const clean = compactErrorText(body);
|
if (String(contentType || "").toLowerCase().includes("json") || /^\s*\{/.test(body)) {
|
||||||
return clean || `HTTP ${status}`;
|
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 {
|
export class RealDebridClient {
|
||||||
private token: string;
|
private token: string;
|
||||||
@@ -128,8 +156,8 @@ export class RealDebridClient {
|
|||||||
this.token = token;
|
this.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
|
||||||
let lastError = "";
|
let lastError: unknown = null;
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const body = new URLSearchParams({ link });
|
const body = new URLSearchParams({ link });
|
||||||
@@ -146,13 +174,13 @@ export class RealDebridClient {
|
|||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const contentType = String(response.headers.get("content-type") || "");
|
const contentType = String(response.headers.get("content-type") || "");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const parsed = parseErrorBody(response.status, text, contentType);
|
const parsed = parseErrorBody(response.status, text, contentType);
|
||||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new Error(parsed);
|
throw parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (looksLikeHtmlResponse(contentType, text)) {
|
if (looksLikeHtmlResponse(contentType, text)) {
|
||||||
@@ -187,18 +215,22 @@ export class RealDebridClient {
|
|||||||
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
||||||
retriesUsed: attempt - 1
|
retriesUsed: attempt - 1
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = compactErrorText(error);
|
lastError = error;
|
||||||
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
const lastErrorText = compactErrorText(error);
|
||||||
break;
|
if (signal?.aborted || (/aborted/i.test(lastErrorText) && !/timeout/i.test(lastErrorText))) {
|
||||||
}
|
break;
|
||||||
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
|
}
|
||||||
break;
|
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastErrorText)) {
|
||||||
}
|
break;
|
||||||
await sleepWithSignal(retryDelay(attempt), signal);
|
}
|
||||||
}
|
await sleepWithSignal(retryDelay(attempt), signal);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
throw new Error(String(lastError || "Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
|
||||||
}
|
if (lastError instanceof Error) {
|
||||||
}
|
throw lastError;
|
||||||
|
}
|
||||||
|
throw new Error(compactErrorText(lastError) || "Unrestrict fehlgeschlagen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { AppSettings } from "../shared/types";
|
import { AppSettings } from "../shared/types";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
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";
|
import { StoragePaths } from "./storage";
|
||||||
|
|
||||||
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
||||||
@@ -71,10 +72,13 @@ function getFreeDiskSpaceBytes(target: string): number | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
|
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
|
||||||
const providers: string[] = [];
|
const providers: string[] = [];
|
||||||
if (settings.token?.trim() || settings.realDebridUseWebLogin) {
|
const realDebridAccounts = getRealDebridAccounts(settings);
|
||||||
providers.push("Real-Debrid");
|
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) {
|
if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) {
|
||||||
providers.push("AllDebrid");
|
providers.push("AllDebrid");
|
||||||
|
|||||||
@@ -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 { isNotifyUrlValid } from "./notify";
|
||||||
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
|
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> {
|
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
||||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
||||||
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
||||||
|
const realDebridAccounts = getRealDebridAccounts(settings);
|
||||||
|
const enabledRealDebridAccounts = realDebridAccounts.filter((account) => account.enabled);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
realDebrid: {
|
realDebrid: {
|
||||||
configured: hasText(settings.token) || settings.realDebridUseWebLogin,
|
configured: realDebridAccounts.length > 0 || hasText(settings.token) || settings.realDebridUseWebLogin,
|
||||||
tokenConfigured: hasText(settings.token),
|
accountCount: realDebridAccounts.length,
|
||||||
webLoginEnabled: settings.realDebridUseWebLogin,
|
enabledAccountCount: enabledRealDebridAccounts.length,
|
||||||
rememberToken: settings.rememberToken
|
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: {
|
megaDebrid: {
|
||||||
configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword))
|
configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword))
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
|
|||||||
export type DebridLinkKeyByteMap = Record<string, number>;
|
export type DebridLinkKeyByteMap = Record<string, number>;
|
||||||
|
|
||||||
type ProviderDailySettings =
|
type ProviderDailySettings =
|
||||||
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
||||||
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
|
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
|
||||||
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridApiDisabledAccountIds" | "megaDebridWebDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
|
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridApiDisabledAccountIds" | "megaDebridWebDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>
|
||||||
|
& Partial<Pick<AppSettings, "realDebridAccountDailyLimitBytes" | "realDebridAccountDailyUsageBytes">>;
|
||||||
|
|
||||||
type ProviderUsageSettings =
|
type ProviderUsageSettings =
|
||||||
ProviderDailySettings
|
ProviderDailySettings
|
||||||
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
|
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
|
||||||
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
|
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes" | "realDebridAccountTotalUsageBytes">>;
|
||||||
|
|
||||||
function normalizePositiveBytes(value: unknown): number {
|
function normalizePositiveBytes(value: unknown): number {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -312,7 +313,7 @@ export function addMegaDebridAccountDailyUsageBytes(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addMegaDebridAccountTotalUsageBytes(
|
export function addMegaDebridAccountTotalUsageBytes(
|
||||||
settings: ProviderUsageSettings,
|
settings: ProviderUsageSettings,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
byteDelta: number
|
byteDelta: number
|
||||||
@@ -330,4 +331,93 @@ export function addMegaDebridAccountTotalUsageBytes(
|
|||||||
return {
|
return {
|
||||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
+277
-5
@@ -1,17 +1,19 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
||||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
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 { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
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;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
resetDebridLinkRuntimeStateForTests();
|
resetDebridLinkRuntimeStateForTests();
|
||||||
resetMegaDebridRuntimeStateForTests();
|
resetMegaDebridRuntimeStateForTests();
|
||||||
|
resetRealDebridRuntimeStateForTests();
|
||||||
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
||||||
vi.restoreAllMocks();
|
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<string, string>)?.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<string, string>)?.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<string, string>)?.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<string, string>)?.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<string, string>)?.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<string, string>)?.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<void>((resolve) => { releaseResponses = resolve; });
|
||||||
|
globalThis.fetch = (async (_input, init) => {
|
||||||
|
const token = String((init?.headers as Record<string, string>)?.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", () => {
|
it("extracts filename from standard rapidgator URL", () => {
|
||||||
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
|
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
|
||||||
.toBe("Show.S01E01.part01.rar");
|
.toBe("Show.S01E01.part01.rar");
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
|||||||
import { EventEmitter, once } from "node:events";
|
import { EventEmitter, once } from "node:events";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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 { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||||
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
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 { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
||||||
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
||||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
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 { 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 { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
||||||
import { UnrestrictedLink } from "../src/main/realdebrid";
|
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||||
import { resetVideoToolingCache } from "../src/main/video-processor";
|
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", () => {
|
describe("disk write recovery", () => {
|
||||||
it("classifies retryable disk write stalls without treating permission errors as temporary", () => {
|
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/);
|
expect(getDiskWriteWaitReason(Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" }))).toMatch(/Festplatte voll/);
|
||||||
@@ -765,6 +808,7 @@ afterEach(async () => {
|
|||||||
resetVideoToolingCache();
|
resetVideoToolingCache();
|
||||||
resetDebridLinkRuntimeStateForTests();
|
resetDebridLinkRuntimeStateForTests();
|
||||||
resetMegaDebridRuntimeStateForTests();
|
resetMegaDebridRuntimeStateForTests();
|
||||||
|
resetRealDebridRuntimeStateForTests();
|
||||||
shutdownItemLogs();
|
shutdownItemLogs();
|
||||||
shutdownPackageLogs();
|
shutdownPackageLogs();
|
||||||
shutdownRenameLog();
|
shutdownRenameLog();
|
||||||
@@ -13158,7 +13202,7 @@ describe("download manager", () => {
|
|||||||
expect((internal.settings.providerTotalUsageBytes as Record<string, number>).megadebrid).toBeUndefined();
|
expect((internal.settings.providerTotalUsageBytes as Record<string, number>).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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const [firstKey, secondKey] = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
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[firstKey.id]).toBe(1024);
|
||||||
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512);
|
expect(internal.settings.debridLinkApiKeyDailyUsageBytes[secondKey.id]).toBe(512);
|
||||||
expect(internal.settings.debridLinkApiKeyTotalUsageBytes[firstKey.id]).toBe(1024);
|
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 () => {
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { RealDebridClient } from "../src/main/realdebrid";
|
import { RealDebridApiError, RealDebridClient } from "../src/main/realdebrid";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ describe("realdebrid client", () => {
|
|||||||
await expect(client.unrestrictLink("https://hoster.example/file/html")).rejects.toThrow(/html/i);
|
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<Response> => {
|
globalThis.fetch = (async (): Promise<Response> => {
|
||||||
return new Response("<html>token=secret-should-not-leak</html>", {
|
return new Response("<html>token=secret-should-not-leak</html>", {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -37,6 +37,38 @@ describe("realdebrid client", () => {
|
|||||||
expect(text.toLowerCase()).toContain("json");
|
expect(text.toLowerCase()).toContain("json");
|
||||||
expect(text.toLowerCase()).not.toContain("secret-should-not-leak");
|
expect(text.toLowerCase()).not.toContain("secret-should-not-leak");
|
||||||
expect(text.toLowerCase()).not.toContain("<html>");
|
expect(text.toLowerCase()).not.toContain("<html>");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
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 { createStoragePaths } from "../src/main/storage";
|
||||||
import { runStartupHealthCheck } from "../src/main/startup-health-check";
|
import { runStartupHealthCheck } from "../src/main/startup-health-check";
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ describe("runStartupHealthCheck", () => {
|
|||||||
expect(report.warnCount).toBeGreaterThanOrEqual(1);
|
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();
|
const { outputDir, paths } = makeTempBase();
|
||||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||||
|
|
||||||
@@ -82,7 +83,27 @@ describe("runStartupHealthCheck", () => {
|
|||||||
expect(providersFinding?.message).toContain("Real-Debrid");
|
expect(providersFinding?.message).toContain("Real-Debrid");
|
||||||
expect(providersFinding?.message).toContain("Debrid-Link");
|
expect(providersFinding?.message).toContain("Debrid-Link");
|
||||||
expect(providersFinding?.message).toContain("2 Keys");
|
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", () => {
|
it("flags large state files", () => {
|
||||||
const { outputDir, paths } = makeTempBase();
|
const { outputDir, paths } = makeTempBase();
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
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-");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user