Fix lifecycle review findings

Advance each serialized web-provider queue when an aborted caller is released while retaining terminal observation of the underlying request. Preserve an accepted pending start across repeated stop requests and dispatch it exactly once after drain. Derive provider retry deadlines from eligible queued items, configured fallback chains, enabled accounts and keys, and matching provider or hoster cooldowns. Add RED-to-GREEN coverage for queue progress, repeated stop behavior, disabled accounts, alternative providers, unrelated hosters, and post-processing-only state.
This commit is contained in:
Sucukdeluxe
2026-08-22 10:53:00 +02:00
parent ad29239661
commit ab31d04410
10 changed files with 326 additions and 38 deletions
+4 -3
View File
@@ -315,10 +315,11 @@ export class AllDebridWebFallback {
throw new Error(`AllDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
}
return job();
};
};
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
return raceWithAbort(run, signal);
const result = raceWithAbort(run, signal);
this.queue = result.then(() => undefined, () => undefined);
return result;
}
private async ensureLoginWindow(): Promise<BrowserWindow> {
+4 -3
View File
@@ -273,10 +273,11 @@ export class BestDebridWebFallback {
throw new Error(`BestDebrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
}
return job();
};
};
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
return raceWithAbort(run, signal);
const result = raceWithAbort(run, signal);
this.queue = result.then(() => undefined, () => undefined);
return result;
}
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
+91 -12
View File
@@ -588,16 +588,6 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna
};
}
export function getNextProviderRuntimeRetryAt(now = Date.now()): number | null {
const deadlines = [
...[...realDebridAccountCooldowns.values()].map((entry) => entry.until),
...[...megaDebridAccountCooldowns.values()].map((entry) => entry.until),
...debridLinkKeyCooldowns.values(),
...debridLinkKeyHostCooldowns.values()
].filter((deadline) => Number.isFinite(deadline) && deadline > now);
return deadlines.length > 0 ? Math.min(...deadlines) : null;
}
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
const PROVIDER_LABELS: Record<DebridProvider, string> = {
@@ -4098,7 +4088,7 @@ class DdownloadClient {
}
}
export class DebridService {
export class DebridService {
private settings: AppSettings;
private options: DebridServiceOptions;
@@ -4115,7 +4105,7 @@ export class DebridService {
this.options = options;
}
public setSettings(next: AppSettings): void {
public setSettings(next: AppSettings): void {
const prev = this.settings;
this.settings = cloneSettings(next);
@@ -4158,6 +4148,95 @@ export class DebridService {
for (const keyId of nextDebridLinkKeyIds) validRuntimeKeys.add(`debridlink:${keyId}`);
pruneAccountRuntimeSession(validRuntimeKeys);
}
public getBlockingProviderRetryAt(link: string, preferredLeadProvider: DebridProvider | null = null, now = Date.now()): number | null {
const settings = cloneSettings(this.settings);
const configuredOrder = settings.providerOrder && settings.providerOrder.length > 0
? uniqueProviderOrder(settings.providerOrder)
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
const orderedProviders = leadProviderChainWith(configuredOrder, preferredLeadProvider);
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 deadlines: number[] = [];
const seen = new Set<DebridProvider>();
for (const provider of plan) {
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
if (seen.has(effectiveProvider)) {
continue;
}
seen.add(effectiveProvider);
const state = this.getProviderRuntimeWaitState(settings, effectiveProvider, hosterKey, now);
if (!state.configured) {
continue;
}
if (state.retryAt === null) {
return null;
}
deadlines.push(state.retryAt);
}
return deadlines.length > 0 ? Math.min(...deadlines) : null;
}
private getProviderRuntimeWaitState(
settings: AppSettings,
provider: DebridProvider,
hosterKey: string,
now: number
): { configured: boolean; retryAt: number | null } {
if ((settings.disabledProviders || []).includes(provider)) {
return { configured: false, retryAt: null };
}
if (provider === "realdebrid") {
const accounts = this.getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled
&& !isRealDebridAccountDailyLimitReached(settings, account.id, now));
if (accounts.length === 0) {
return { configured: false, retryAt: null };
}
const deadlines = accounts.map((account) => getRealDebridAccountCooldown(account.id, now)?.until ?? null);
return deadlines.some((deadline) => deadline === null)
? { configured: true, retryAt: null }
: { configured: true, retryAt: Math.min(...deadlines as number[]) };
}
if (provider === "megadebrid-api" || provider === "megadebrid-web") {
const mode = provider === "megadebrid-web" ? "web" : "api";
if (!isMegaDebridModeEnabled(settings, mode) || (mode === "web" && !this.options.megaWebUnrestrict)) {
return { configured: false, retryAt: null };
}
const accounts = getMegaDebridAccountList(settings, mode).filter((account) => !isMegaDebridAccountDisabled(settings, account.id, mode)
&& !isMegaDebridAccountDailyLimitReached(settings, account.id, now));
if (accounts.length === 0) {
return { configured: false, retryAt: null };
}
const deadlines = accounts.map((account) => getMegaDebridAccountCooldownState(`${account.id}:${mode}`, now)?.until ?? null);
return deadlines.some((deadline) => deadline === null)
? { configured: true, retryAt: null }
: { configured: true, retryAt: Math.min(...deadlines as number[]) };
}
if (provider === "debridlink") {
const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter((key) => !isDebridLinkApiKeyDisabled(settings, key.id)
&& !isDebridLinkApiKeyDailyLimitReached(settings, key.id, now));
if (keys.length === 0) {
return { configured: false, retryAt: null };
}
const deadlines: number[] = [];
for (const key of keys) {
const keyRetryAt = getDebridLinkKeyCooldownState(key.id, now)?.until ?? 0;
const hostRetryAt = getDebridLinkKeyHostCooldownState(key.id, hosterKey, now)?.until ?? 0;
const retryAt = Math.max(keyRetryAt, hostRetryAt);
if (retryAt === 0) {
return { configured: true, retryAt: null };
}
deadlines.push(retryAt);
}
return { configured: true, retryAt: Math.min(...deadlines) };
}
const configured = this.isProviderConfiguredFor(settings, provider) && !this.isProviderDailyLimited(settings, provider);
return { configured, retryAt: null };
}
private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {
if (this.cachedDebridLinkClient && this.cachedDebridLinkKey === apiKeysRaw) {
+46 -8
View File
@@ -62,7 +62,7 @@ function releaseTlsSkip(): void {
}
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getNextProviderRuntimeRetryAt, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error";
@@ -3940,12 +3940,47 @@ export class DownloadManager extends EventEmitter {
}
private getEarliestProviderRetryAt(now: number): number | null {
const deadlines = [...this.providerFailures.values()]
.map((entry) => entry.cooldownUntil)
.filter((deadline) => Number.isFinite(deadline) && deadline > now);
const runtimeRetryAt = getNextProviderRuntimeRetryAt(now);
if (runtimeRetryAt) {
deadlines.push(runtimeRetryAt);
const queuedItems: DownloadItem[] = [];
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
if (this.runPackageIds.size > 0 && !this.runPackageIds.has(packageId)) {
continue;
}
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (item && (item.status === "queued" || item.status === "reconnect_wait")) {
queuedItems.push(item);
}
}
}
if (queuedItems.length === 0) {
return null;
}
const deadlines: number[] = [];
for (const item of queuedItems) {
const itemDeadlines: number[] = [];
const failureKey = this.getProviderFailureKeyForItem(item);
const localRetryAt = this.providerFailures.get(failureKey)?.cooldownUntil || 0;
const localFallback = localRetryAt > now && this.settings.autoProviderFallback
? this.findFallbackProviderNotInCooldown(item)
: null;
const hasLocalFallback = localRetryAt > now
&& this.settings.autoProviderFallback
&& localFallback !== null;
if (localRetryAt > now && !hasLocalFallback) {
itemDeadlines.push(localRetryAt);
}
const runtimeRetryAt = this.debridService.getBlockingProviderRetryAt(item.url, localFallback, now);
if (runtimeRetryAt) {
itemDeadlines.push(runtimeRetryAt);
}
if (itemDeadlines.length === 0) {
return null;
}
deadlines.push(Math.min(...itemDeadlines));
}
return deadlines.length > 0 ? Math.min(...deadlines) : null;
}
@@ -6687,10 +6722,13 @@ export class DownloadManager extends EventEmitter {
public stop(options?: { parkForRestart?: boolean }): void {
const parkForRestart = options?.parkForRestart === true;
const wasStopping = this.lifecyclePhase === "stopping";
this.lifecycleGeneration += 1;
this.lifecyclePhase = "stopping";
this.lifecycleReason = "Laufende Arbeit wird beendet";
this.pendingStartOptions = null;
if (!wasStopping) {
this.pendingStartOptions = null;
}
this.healthManualStop = !parkForRestart;
this.healthShuttingDown = parkForRestart;
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
+4 -3
View File
@@ -334,10 +334,11 @@ export class RealDebridWebFallback {
throw new Error(`Real-Debrid-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
}
return job();
};
};
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
return raceWithAbort(run, signal);
const result = raceWithAbort(run, signal);
this.queue = result.then(() => undefined, () => undefined);
return result;
}
private async ensureLoginWindow(): Promise<BrowserWindow> {