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:
@@ -43,3 +43,31 @@ Umgesetzt und fokussiert verifiziert.
|
||||
|
||||
- Der vollständige Fünf-Dateien-Lauf erreichte vor der letzten Korrektur 395/397. Die beiden isolierten Fehler wurden anschließend einzeln und im finalen 7-Test-Manager-Gate grün verifiziert; die übrigen 393 Manager-/Web-/Renderer-Tests wurden nach dieser letzten kleinen Kompatibilitätskorrektur nicht nochmals gemeinsam ausgeführt.
|
||||
- Der Renderer-Build bleibt grün, meldet aber die bereits bestehende Warnung für einen JavaScript-Chunk über 500 kB.
|
||||
|
||||
## Fixrunde 1/5
|
||||
|
||||
### Korrekturen
|
||||
|
||||
- Die Webqueue-Tail folgt jetzt dem abortbaren Caller-Abschluss. Ein alter Roh-Request darf intern weiterlaufen, blockiert aber keine neue Real-Debrid-, AllDebrid- oder BestDebrid-Umwandlung mehr; seine späte terminale Rejection bleibt beobachtet.
|
||||
- Ein weiterer `stop()` während eines bereits laufenden Stop-Drains bewahrt den angenommenen Startwunsch. Nach dem tatsächlichen Drain wird weiterhin genau ein Folgestart ausgeführt.
|
||||
- Provider-Retry-Zeitpunkte werden aus der aktuellen startfähigen Queue abgeleitet. Berücksichtigt werden nur die reale Providerkette des Links sowie aktivierbare Accounts beziehungsweise Keys und passende Provider-/Hoster-Circuit-Breaker.
|
||||
- Cooldowns deaktivierter Accounts, verfügbare Alternativprovider, fremde Provider oder Hoster und reine Nachbearbeitung erzeugen weder `waiting_provider` noch `retryAt`.
|
||||
|
||||
### RED→GREEN
|
||||
|
||||
- Alle drei Webqueues: Der zweite Request lief vor dem Fix in den 200-ms-Timeout, solange der erste Roh-Promise offen blieb; danach erreichte er den Provider und schloss erfolgreich ab, bevor die erste späte Rejection ausgelöst wurde.
|
||||
- Doppelter Stop: Der zweite Stop setzte `pendingStart` vor dem Fix auf `false`; danach blieb der Wunsch sichtbar und es wurden insgesamt exakt zwei Provideraufrufe beobachtet.
|
||||
- Cooldown-Kontext: Deaktivierter Account, verfügbarer Alternativprovider, fremder Hoster/Provider und reine Nachbearbeitung lieferten vor dem Fix falsche Retry-Zeitpunkte; danach blieben Phase und `retryAt` korrekt. Der wirklich blockierende Ein-Provider-Cooldown blieb grün und emittierte sein Ablaufereignis.
|
||||
|
||||
### Verifikation
|
||||
|
||||
- 9/9 fokussierte Manager-Regressionen bestanden.
|
||||
- 152/152 Provider-/Renderer-Tests bestanden.
|
||||
- `npx tsc --noEmit` bestand.
|
||||
- `npm run build` bestand für Main und Renderer.
|
||||
- `git diff --check` bestand.
|
||||
|
||||
### Bedenken
|
||||
|
||||
- Der vollständige Manager-Testlauf wurde in dieser Fixrunde nicht erneut ausgeführt; die geänderten Lifecycle-/Cooldown-Fälle sowie alle drei vollständigen Providerdateien und der vollständige Download-Renderer-Test wurden fokussiert geprüft.
|
||||
- Der bestehende Renderer-Buildhinweis für einen JavaScript-Chunk über 500 kB bleibt unverändert.
|
||||
|
||||
@@ -317,8 +317,9 @@ export class AllDebridWebFallback {
|
||||
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> {
|
||||
|
||||
@@ -275,8 +275,9 @@ export class BestDebridWebFallback {
|
||||
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" }> {
|
||||
|
||||
+89
-10
@@ -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> = {
|
||||
@@ -4159,6 +4149,95 @@ export class DebridService {
|
||||
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) {
|
||||
return this.cachedDebridLinkClient;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -336,8 +336,9 @@ export class RealDebridWebFallback {
|
||||
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> {
|
||||
|
||||
@@ -144,9 +144,15 @@ describe("alldebrid-web", () => {
|
||||
|
||||
it("releases an aborted caller while the active web request ignores its signal", async () => {
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
mockFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}));
|
||||
mockFetch
|
||||
.mockReturnValueOnce(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
link: "https://alldebrid.direct/second.bin",
|
||||
filename: "second.bin",
|
||||
filesize: 333
|
||||
}), { status: 200 }));
|
||||
const fallback = new AllDebridWebFallback(() => true);
|
||||
const controller = new AbortController();
|
||||
const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal)
|
||||
@@ -160,6 +166,17 @@ describe("alldebrid-web", () => {
|
||||
]);
|
||||
|
||||
expect(outcome).toContain("aborted:alldebrid-web");
|
||||
const secondOutcome = await Promise.race([
|
||||
fallback.unrestrict("https://rapidgator.net/file/second"),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
|
||||
]);
|
||||
expect(secondOutcome).toEqual({
|
||||
directUrl: "https://alldebrid.direct/second.bin",
|
||||
fileName: "second.bin",
|
||||
fileSize: 333,
|
||||
retriesUsed: 0
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
rejectRequest(new Error("late alldebrid rejection"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -174,9 +174,16 @@ describe("bestdebrid-web", () => {
|
||||
const fallback = new BestDebridWebFallback(() => true);
|
||||
await fallback.importCookiesFromFile(filePath);
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
mockFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}));
|
||||
mockFetch
|
||||
.mockReturnValueOnce(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
error: 0,
|
||||
link: "https://bestdebrid.direct/second.bin",
|
||||
filename: "second.bin",
|
||||
size: "444 B"
|
||||
}), { status: 200 }));
|
||||
const controller = new AbortController();
|
||||
const running = fallback.unrestrict("https://1fichier.com/?abort-race", controller.signal)
|
||||
.then(() => "resolved" as const, (error) => String(error));
|
||||
@@ -189,6 +196,17 @@ describe("bestdebrid-web", () => {
|
||||
]);
|
||||
|
||||
expect(outcome).toContain("aborted:bestdebrid-web");
|
||||
const secondOutcome = await Promise.race([
|
||||
fallback.unrestrict("https://1fichier.com/?second"),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
|
||||
]);
|
||||
expect(secondOutcome).toEqual({
|
||||
directUrl: "https://bestdebrid.direct/second.bin",
|
||||
fileName: "second.bin",
|
||||
fileSize: 444,
|
||||
retriesUsed: 0
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
rejectRequest(new Error("late bestdebrid rejection"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -847,6 +847,8 @@ describe("deterministic stop and restart lifecycle", () => {
|
||||
manager.stop();
|
||||
await manager.start();
|
||||
|
||||
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "stopping", pendingStart: true });
|
||||
manager.stop();
|
||||
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "stopping", pendingStart: true });
|
||||
finishFirstAbort();
|
||||
await waitFor(() => attempts.length === 2);
|
||||
@@ -854,6 +856,8 @@ describe("deterministic stop and restart lifecycle", () => {
|
||||
session: { running: true },
|
||||
lifecycle: { phase: "running", pendingStart: false }
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(attempts).toHaveLength(2);
|
||||
|
||||
manager.stop();
|
||||
});
|
||||
@@ -945,6 +949,89 @@ describe("deterministic stop and restart lifecycle", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores cooldowns from disabled accounts when projecting provider wait state", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-cooldown-context-"));
|
||||
tempDirs.push(root);
|
||||
const accountId = "rda_disabled_cooldown";
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
|
||||
realDebridDisabledAccountIds: [accountId],
|
||||
providerOrder: ["realdebrid"],
|
||||
autoExtract: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
manager.addPackages([{ name: "disabled", links: ["https://rapidgator.net/file/disabled"] }]);
|
||||
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
|
||||
|
||||
expect(manager.getSnapshot()).toMatchObject({
|
||||
canStart: false,
|
||||
lifecycle: { phase: "idle", retryAt: null }
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a primary provider cooldown when an alternative provider can start the queue", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alternative-provider-context-"));
|
||||
tempDirs.push(root);
|
||||
const accountId = "rda_alternative_provider";
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
|
||||
allDebridToken: "all-debrid-token",
|
||||
providerOrder: ["realdebrid", "alldebrid"],
|
||||
autoProviderFallback: true,
|
||||
autoExtract: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
manager.addPackages([{ name: "alternative", links: ["https://rapidgator.net/file/alternative"] }]);
|
||||
primeRealDebridRuntimeCooldownForTests(accountId, 60_000);
|
||||
|
||||
expect(manager.getSnapshot()).toMatchObject({
|
||||
canStart: true,
|
||||
lifecycle: { phase: "idle", retryAt: null }
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores cooldowns for another provider or hoster and for pure post-processing", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-irrelevant-cooldown-context-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
oneFichierApiKey: "onefichier-key",
|
||||
providerOrder: ["onefichier"],
|
||||
autoExtract: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
manager.addPackages([{ name: "other-hoster", links: ["https://1fichier.com/?other-hoster"] }]);
|
||||
const internal = manager as unknown as {
|
||||
packagePostProcessTasks: Map<string, Promise<void>>;
|
||||
providerFailures: Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>;
|
||||
session: ReturnType<typeof manager.getSession>;
|
||||
};
|
||||
internal.providerFailures.set("alldebrid:rapidgator", {
|
||||
count: 20,
|
||||
lastFailAt: Date.now(),
|
||||
cooldownUntil: Date.now() + 60_000
|
||||
});
|
||||
|
||||
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "idle", retryAt: null });
|
||||
|
||||
const itemId = Object.keys(internal.session.items)[0];
|
||||
internal.session.items[itemId].status = "completed";
|
||||
internal.packagePostProcessTasks.set("postprocess-only", new Promise<void>(() => {}));
|
||||
|
||||
expect(manager.getSnapshot().lifecycle).toMatchObject({ phase: "postprocessing", retryAt: null });
|
||||
});
|
||||
|
||||
it("keeps post-processing drain visible and starts the pending run after its abort settles", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-stop-drain-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -193,9 +193,16 @@ describe("realdebrid-web", () => {
|
||||
|
||||
it("releases an aborted caller while the active web request ignores its signal", async () => {
|
||||
let rejectRequest!: (error: Error) => void;
|
||||
mockSessionFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}));
|
||||
mockSessionFetch
|
||||
.mockReturnValueOnce(new Promise<Response>((_resolve, reject) => {
|
||||
rejectRequest = reject;
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response("<input name=\"private_token\" value=\"second-token\">", { status: 200 }));
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
download: "https://cdn.real-debrid.example/second.bin",
|
||||
filename: "second.bin",
|
||||
filesize: 222
|
||||
}), { status: 200 })));
|
||||
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_abort_race", () => true);
|
||||
const controller = new AbortController();
|
||||
const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal)
|
||||
@@ -209,6 +216,17 @@ describe("realdebrid-web", () => {
|
||||
]);
|
||||
|
||||
expect(outcome).toContain("aborted:realdebrid-web");
|
||||
const secondOutcome = await Promise.race([
|
||||
fallback.unrestrict("https://rapidgator.net/file/second"),
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
|
||||
]);
|
||||
expect(secondOutcome).toEqual({
|
||||
directUrl: "https://cdn.real-debrid.example/second.bin",
|
||||
fileName: "second.bin",
|
||||
fileSize: 222,
|
||||
retriesUsed: 0
|
||||
});
|
||||
expect(mockSessionFetch).toHaveBeenCalledTimes(2);
|
||||
rejectRequest(new Error("late realdebrid rejection"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user