fix: harden account rotation and active download recovery

Apply account enablement changes optimistically and persist rapid toggles sequentially without losing clicks.

Refresh Mega-Debrid API and Web account pools during active downloads, isolate per-account cancellation budgets, rotate within the same conversion attempt, and keep pause, stop, resume, reset, and no-account gates consistent.

Use the native Electron clipboard under Remote Desktop and make support bundle export bounded, redacted, responsive, guarded, visible, and atomic.

Add end-to-end regressions for live account switching, persisted partial recovery, support export, native copy, availability preservation, and the 500 ms telemetry cadence.
This commit is contained in:
Sucukdeluxe
2026-08-13 05:14:44 +02:00
parent ca015553f3
commit e1b4708952
26 changed files with 2811 additions and 561 deletions
+184 -46
View File
@@ -54,7 +54,7 @@ function releaseTlsSkip(): void {
}
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, clearMegaDebridAccountRuntimeStates, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountAttemptTimeoutMs, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error";
@@ -78,7 +78,7 @@ type ActiveTask = {
itemId: string;
packageId: string;
abortController: AbortController;
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "reset" | "none";
abortReason: "stop" | "pause" | "cancel" | "reconnect" | "package_toggle" | "settings_refresh" | "stall" | "shutdown" | "reset" | "none";
resumable: boolean;
nonResumableCounted: boolean;
freshRetryUsed?: boolean;
@@ -122,7 +122,11 @@ const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 10000;
const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000;
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 60000;
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 60000;
const MAX_UNRESTRICT_TIMEOUT_MS = 2_147_483_647;
const UNRESTRICT_TIMEOUT_OVERHEAD_MS = 15_000;
const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
@@ -342,7 +346,7 @@ function getPostExtractTimeoutMs(): number {
return DEFAULT_POST_EXTRACT_TIMEOUT_MS;
}
function getUnrestrictTimeoutMs(): number {
function getUnrestrictTimeoutMs(): number {
const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 5000 && fromEnv <= 15 * 60 * 1000) {
return Math.floor(fromEnv);
@@ -476,6 +480,31 @@ function isArchiveLikePath(filePath: string): boolean {
function extractHosterKey(link: string): string {
return extractHosterFromUrl(link);
}
export function getUnrestrictTimeoutMsForProviderPlan(settings: AppSettings, providerPlan: readonly DebridProvider[]): number {
const baseTimeoutMs = getUnrestrictTimeoutMs();
const megaModes = new Set<"api" | "web">();
for (const provider of providerPlan) {
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
if (effectiveProvider === "megadebrid-api") {
megaModes.add("api");
} else if (effectiveProvider === "megadebrid-web") {
megaModes.add("web");
}
}
if (megaModes.size === 0) {
return baseTimeoutMs;
}
let accountCount = 0;
for (const mode of megaModes) {
accountCount += getAvailableMegaDebridAccounts(settings, mode).length;
}
if (accountCount === 0) {
return baseTimeoutMs;
}
const calculatedTimeoutMs = baseTimeoutMs + UNRESTRICT_TIMEOUT_OVERHEAD_MS + accountCount * getMegaDebridAccountAttemptTimeoutMs();
return Math.min(MAX_UNRESTRICT_TIMEOUT_MS, Math.max(baseTimeoutMs, calculatedTimeoutMs));
}
function isLargeBinaryLikePath(filePath: string): boolean {
const lower = path.basename(String(filePath || "")).toLowerCase();
@@ -552,9 +581,14 @@ function shouldPreflightFinalizeItemFromDisk(item: DownloadItem): boolean {
|| text.includes("server ignorierte range");
}
function isResumeHardResetReason(errorText: string): boolean {
const text = String(errorText || "");
return text.startsWith("resume_download_underflow:");
export function isResumeHardResetReason(errorText: string, renewedLinkFailures = 0): boolean {
const text = String(errorText || "").toLowerCase();
return text.startsWith("resume_download_underflow:")
|| (renewedLinkFailures > 0 && (
text.startsWith("range_ignored_on_resume:")
|| text.startsWith("range_mismatch_on_resume:")
|| text.includes("server ignorierte range")
));
}
function isRealDebridProvider(provider: string | null | undefined): boolean {
@@ -833,7 +867,7 @@ function providerLabel(provider: DownloadItem["provider"]): string {
return "Debrid";
}
function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider | null): DebridProvider | null {
function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider | null): DebridProvider | null {
if (provider !== "megadebrid") {
return provider;
}
@@ -1701,6 +1735,15 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
return Math.floor(jitter);
}
function isMegaDebridProviderKey(value: string): boolean {
return value === "megadebrid"
|| value === "megadebrid-api"
|| value === "megadebrid-web"
|| value.startsWith("megadebrid:")
|| value.startsWith("megadebrid-api:")
|| value.startsWith("megadebrid-web:");
}
export function getDiskWriteWaitReason(error: unknown): string | null {
const text = compactErrorText(error).replace(/^Error:\s*/i, "");
const marked = text.match(/^disk_write_wait:(.+)$/i);
@@ -2194,25 +2237,31 @@ export class DownloadManager extends EventEmitter {
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
const previous = this.settings;
const previousMegaPool = (["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => `${mode}:${account.id}:${account.password}`))
const previousMegaAccounts = (["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => ({ mode, account })));
const previousMegaPoolEntries = new Map<string, string>(previousMegaAccounts.map(({ mode, account }) => [`${account.id}:${mode}`, account.password]));
const previousMegaPool = [...previousMegaPoolEntries]
.map(([key, password]) => `${key}:${password}`)
.sort()
.join("\n");
const nextMegaAccounts = (["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode));
const nextMegaPool = (["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => `${mode}:${account.id}:${account.password}`))
const nextMegaPoolEntries = new Map<string, string>((["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => [`${account.id}:${mode}`, account.password] as const)));
const nextMegaPool = [...nextMegaPoolEntries]
.map(([key, password]) => `${key}:${password}`)
.sort()
.join("\n");
const previousMegaWebCredentials = getMegaDebridAccountsForMode(previous, "web")
const previousMegaWebPool = getAvailableMegaDebridAccounts(previous, "web")
.map((account) => `${account.id}:${account.password}`)
.sort()
.join("\n");
const nextMegaWebCredentials = getMegaDebridAccountsForMode(next, "web")
const nextMegaWebPool = getAvailableMegaDebridAccounts(next, "web")
.map((account) => `${account.id}:${account.password}`)
.sort()
.join("\n");
const megaPoolChanged = previousMegaPool !== nextMegaPool && nextMegaAccounts.length > 0;
const megaPoolChanged = previousMegaPool !== nextMegaPool;
const megaWebPoolChanged = previousMegaWebPool !== nextMegaWebPool;
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
const now = nowMs();
@@ -2222,7 +2271,7 @@ export class DownloadManager extends EventEmitter {
this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0;
this.runtimePersistedAt = now;
this.ensureProviderDailyUsageFresh(nowMs());
if (previousMegaWebCredentials !== nextMegaWebCredentials) {
if (megaWebPoolChanged) {
this.invalidateMegaSessionFn?.();
}
this.debridService.setSettings(next);
@@ -2280,7 +2329,34 @@ export class DownloadManager extends EventEmitter {
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
}
if (!opts?.settingsOnlyImport && megaPoolChanged) {
if (megaPoolChanged) {
const changedAccountKeys = new Set<string>();
for (const key of new Set<string>([...previousMegaPoolEntries.keys(), ...nextMegaPoolEntries.keys()])) {
if (previousMegaPoolEntries.get(key) !== nextMegaPoolEntries.get(key)) {
changedAccountKeys.add(key);
}
}
clearMegaDebridAccountRuntimeStates(changedAccountKeys);
for (const key of [...this.providerFailures.keys()]) {
if (isMegaDebridProviderKey(key)) {
this.providerFailures.delete(key);
}
}
for (const active of this.activeTasks.values()) {
const item = this.session.items[active.itemId];
if (!item || item.status !== "validating") {
continue;
}
const provider = String(item.provider || this.getExpectedProviderForItem(item) || "");
if (provider !== "megadebrid" && provider !== "megadebrid-api" && provider !== "megadebrid-web") {
continue;
}
active.abortReason = "settings_refresh";
active.abortController.abort("settings_refresh");
}
}
if (!opts?.settingsOnlyImport && megaPoolChanged && nextMegaAccounts.length > 0) {
this.releaseMegaDebridResetParks();
}
@@ -5372,8 +5448,7 @@ export class DownloadManager extends EventEmitter {
item.targetPath = "";
item.provider = null;
item.fullStatus = "Wartet";
item.onlineStatus = undefined;
item.updatedAt = nowMs();
item.updatedAt = nowMs();
}
const postProcessTasks = this.abortPackagePostProcessing(packageId, "reset");
@@ -5456,8 +5531,7 @@ export class DownloadManager extends EventEmitter {
item.targetPath = "";
item.provider = null;
item.fullStatus = "Wartet";
item.onlineStatus = undefined;
item.updatedAt = nowMs();
item.updatedAt = nowMs();
if (this.session.running) {
this.runItemIds.add(itemId);
@@ -5923,7 +5997,7 @@ export class DownloadManager extends EventEmitter {
});
}
public stop(options?: { parkForRestart?: boolean }): void {
public stop(options?: { parkForRestart?: boolean }): void {
const parkForRestart = options?.parkForRestart === true;
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
const keepExtraction = this.settings.autoExtractWhenStopped;
@@ -5936,7 +6010,13 @@ export class DownloadManager extends EventEmitter {
this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear();
this.retryStateByItem.clear();
this.invalidateMegaSessionFn?.();
for (const key of [...this.providerFailures.keys()]) {
if (isMegaDebridProviderKey(key)) {
this.providerFailures.delete(key);
}
}
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs();
this.speedEvents = [];
@@ -6086,11 +6166,16 @@ export class DownloadManager extends EventEmitter {
}
this.session.paused = !this.session.paused;
if (!wasPaused && this.session.paused) {
this.speedEvents = [];
this.speedBytesLastWindow = 0;
this.speedBytesPerPackage.clear();
this.speedEventsHead = 0;
if (!wasPaused && this.session.paused) {
this.speedEvents = [];
this.speedBytesLastWindow = 0;
this.speedBytesPerPackage.clear();
this.speedEventsHead = 0;
this.invalidateMegaSessionFn?.();
for (const active of this.activeTasks.values()) {
active.abortReason = "pause";
active.abortController.abort("pause");
}
}
if (wasPaused && !this.session.paused) {
@@ -8177,7 +8262,7 @@ export class DownloadManager extends EventEmitter {
}
}
private getProviderOrder(): DebridProvider[] {
private getProviderOrder(): DebridProvider[] {
if (this.settings.providerOrder && this.settings.providerOrder.length > 0) {
return [...this.settings.providerOrder];
}
@@ -8185,8 +8270,36 @@ export class DownloadManager extends EventEmitter {
this.settings.providerPrimary,
this.settings.providerSecondary !== "none" ? this.settings.providerSecondary : null,
this.settings.providerTertiary !== "none" ? this.settings.providerTertiary : null
].filter(Boolean) as DebridProvider[];
}
].filter(Boolean) as DebridProvider[];
}
private getReachableUnrestrictProviderPlan(item: DownloadItem, preferredLeadProvider: DebridProvider | null): DebridProvider[] {
const baseOrder = this.getProviderOrder();
const order = preferredLeadProvider && baseOrder.includes(preferredLeadProvider)
? [preferredLeadProvider, ...baseOrder.filter((provider) => provider !== preferredLeadProvider)]
: baseOrder;
const candidates: DebridProvider[] = [];
const hosterKey = extractHosterKey(item.url);
const routedProvider = hosterKey ? (this.settings.hosterRouting || {})[hosterKey] : undefined;
if (routedProvider && this.isProviderConfigured(routedProvider)) {
candidates.push(routedProvider);
if (!this.settings.autoProviderFallback) {
return [resolveMegaDebridProvider(this.settings, routedProvider) || routedProvider];
}
}
const configuredOrder = order.filter((provider) => this.isProviderConfigured(provider));
candidates.push(...(this.settings.autoProviderFallback ? configuredOrder : configuredOrder.slice(0, 1)));
const seen = new Set<DebridProvider>();
const providerPlan: DebridProvider[] = [];
for (const provider of candidates) {
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
if (!seen.has(effectiveProvider)) {
seen.add(effectiveProvider);
providerPlan.push(effectiveProvider);
}
}
return providerPlan;
}
private findFallbackProviderNotInCooldown(item: DownloadItem): DebridProvider | null {
const hosterKey = extractHosterKey(item.url);
@@ -8677,17 +8790,17 @@ export class DownloadManager extends EventEmitter {
return;
}
const VALIDATING_STUCK_MS = getUnrestrictTimeoutMs() + 15000;
for (const active of this.activeTasks.values()) {
for (const active of this.activeTasks.values()) {
if (active.abortController.signal.aborted) {
continue;
}
const item = this.session.items[active.itemId];
if (!item || item.status !== "validating") {
continue;
}
const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0;
if (ageMs > VALIDATING_STUCK_MS) {
if (!item || item.status !== "validating") {
continue;
}
const validatingStuckMs = getUnrestrictTimeoutMsForProviderPlan(this.settings, this.getReachableUnrestrictProviderPlan(item, null)) + 15_000;
const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0;
if (ageMs > validatingStuckMs) {
logger.warn(`Validating-Stuck erkannt: item=${item.fileName || active.itemId}, ${Math.floor(ageMs / 1000)}s ohne Fortschritt`);
active.abortReason = "stall";
active.abortController.abort("stall");
@@ -9172,7 +9285,9 @@ export class DownloadManager extends EventEmitter {
this.emitState();
return;
}
const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs());
const unrestrictProviderPlan = this.getReachableUnrestrictProviderPlan(item, preferredLeadProvider);
const unrestrictTimeoutMs = getUnrestrictTimeoutMsForProviderPlan(this.settings, unrestrictProviderPlan);
const unrestrictTimeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs);
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
let unrestricted;
try {
@@ -9193,7 +9308,7 @@ export class DownloadManager extends EventEmitter {
traceConversionPhase({
phase: "caller-timeout",
outcome: "timeout",
detail: `Caller-Budget ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
});
}
throw innerError;
@@ -9203,7 +9318,7 @@ export class DownloadManager extends EventEmitter {
} catch (unrestrictError) {
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
this.recordProviderFailure(cooldownProvider);
throw new Error(`Unrestrict Timeout nach ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s`);
throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`);
}
const errText = compactErrorText(unrestrictError);
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) {
@@ -9484,7 +9599,19 @@ export class DownloadManager extends EventEmitter {
this.dropItemContribution(item.id);
}
this.retryStateByItem.delete(item.id);
} else if (reason === "shutdown") {
} else if (reason === "pause") {
item.status = "queued";
item.speedBps = 0;
item.fullStatus = "Pausiert";
this.retryAfterByItem.delete(item.id);
this.retryStateByItem.set(item.id, {
freshRetryUsed: Boolean(active.freshRetryUsed),
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
stallRetries: Number(active.stallRetries || 0),
genericErrorRetries: Number(active.genericErrorRetries || 0),
unrestrictRetries: Number(active.unrestrictRetries || 0)
});
} else if (reason === "shutdown") {
this.logPackageForItem(item, "WARN", "Download für Shutdown geparkt", {
reason
});
@@ -9507,9 +9634,20 @@ export class DownloadManager extends EventEmitter {
genericErrorRetries: Number(active.genericErrorRetries || 0),
unrestrictRetries: Number(active.unrestrictRetries || 0)
});
} else if (reason === "reset") {
this.retryStateByItem.delete(item.id);
} else if (reason === "package_toggle") {
} else if (reason === "reset") {
this.retryStateByItem.delete(item.id);
} else if (reason === "settings_refresh") {
item.status = "queued";
item.speedBps = 0;
item.fullStatus = "Wartet";
item.lastError = "";
item.provider = null;
item.providerLabel = undefined;
item.providerAccountId = undefined;
item.providerAccountLabel = undefined;
this.retryAfterByItem.delete(item.id);
this.retryStateByItem.delete(item.id);
} else if (reason === "package_toggle") {
this.logPackageForItem(item, "WARN", "Download wegen Paket-Toggle pausiert", {
reason
});
@@ -9640,7 +9778,7 @@ export class DownloadManager extends EventEmitter {
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
return;
}
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) {
active.resumeHardResetUsed = true;
item.retries += 1;
logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`);