diff --git a/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md b/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md index 2c7fc75..26389a0 100644 --- a/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md +++ b/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md @@ -71,3 +71,33 @@ Umgesetzt und fokussiert verifiziert. - 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. + +## Fixrunde 2/5 + +### Korrekturen + +- Eine gemeinsame pure Providerplanung liefert Hosterroute, direkte 1Fichier-/DDownload-Pfade und die geordnete Providerkette sowohl an den echten Unrestrictpfad als auch an die Cooldown-Projektion. +- Bei deaktiviertem automatischem Fehlerfallback bildet die Projektion weiterhin den realen Sonderfall ab, dass ein wegen Real-Debrid-Cooldown nicht auswählbarer Primärprovider auf einen sofort verfügbaren Sekundärprovider wechseln darf. +- Die gemeinsame Provider-Deaktivierungsprüfung behandelt `megadebrid` als Alias für beide konkreten Mega-Debrid-Modi und wird auch vom Manager-Startgate verwendet. +- Die äußere Abort-Race hängt ihren terminalen Observer an den rohen Queue-Promise, bevor ein bereits abgebrochenes Signal ausgewertet wird. Dadurch bleibt auch die spätere rohe Rejection behandelt und die Queue sofort für den Folgejob frei. + +### RED→GREEN + +- `autoProviderFallback=false`: Vorher blieb ein falsches Real-Debrid-`retryAt` trotz sofort verfügbarem AllDebrid-Zweitprovider; danach `retryAt=null`. +- Direkte 1Fichier-/DDownload-Links: Vorher überlagerte der Real-Debrid-Cooldown beide Direktpfade; danach bleiben sie startfähig. Der Direktpfad wurde zusätzlich mit einer gezielten Mutation wieder rot und nach Wiederherstellung grün belegt. +- `disabledProviders: ["megadebrid"]`: Vorher waren `canStart=true` und ein Mega-Retry sichtbar; danach `canStart=false`, `phase=idle`, `retryAt=null`. +- Bereits abgebrochenes Signal: Real-Debrid, AllDebrid und BestDebrid erzeugten vor dem Fix jeweils eine rohe `unhandledRejection`; danach keine, und der jeweilige Folgejob schloss erfolgreich ab. + +### Verifikation + +- 12/12 fokussierte Manager-Lifecycle-/Cooldown-Tests bestanden. +- 6/6 bestehende Debrid-Providerwahltests bestanden. +- 155/155 vollständige Webprovider-/Download-Renderer-Tests bestanden. +- `npx tsc --noEmit` bestand. +- `npm run build` bestand für Main und Renderer. +- `git diff --check` bestand. + +### Bedenken + +- Die vollständigen Manager- und Debrid-Testdateien wurden nicht komplett ausgeführt; die geänderten Auswahl-, Lifecycle- und Queuepfade wurden fokussiert abgedeckt. +- Der bestehende Renderer-Buildhinweis für einen JavaScript-Chunk über 500 kB bleibt unverändert. diff --git a/src/main/all-debrid-web.ts b/src/main/all-debrid-web.ts index 0a9c6b6..4f7004e 100644 --- a/src/main/all-debrid-web.ts +++ b/src/main/all-debrid-web.ts @@ -75,9 +75,6 @@ async function raceWithAbort(promise: Promise, signal?: AbortSignal): Prom if (!signal) { return promise; } - if (signal.aborted) { - throw abortError(); - } return new Promise((resolve, reject) => { let settled = false; const onAbort = (): void => { @@ -104,6 +101,9 @@ async function raceWithAbort(promise: Promise, signal?: AbortSignal): Prom signal.removeEventListener("abort", onAbort); reject(error); }); + if (signal.aborted) { + onAbort(); + } }); } diff --git a/src/main/bestdebrid-web.ts b/src/main/bestdebrid-web.ts index 45ff079..004ffea 100644 --- a/src/main/bestdebrid-web.ts +++ b/src/main/bestdebrid-web.ts @@ -33,9 +33,6 @@ async function raceWithAbort(promise: Promise, signal?: AbortSignal): Prom if (!signal) { return promise; } - if (signal.aborted) { - throw abortError(); - } return new Promise((resolve, reject) => { let settled = false; const onAbort = (): void => { @@ -62,6 +59,9 @@ async function raceWithAbort(promise: Promise, signal?: AbortSignal): Prom signal.removeEventListener("abort", onAbort); reject(error); }); + if (signal.aborted) { + onAbort(); + } }); } diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 8664897..b561f85 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1636,14 +1636,50 @@ function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvi return uniqueProviderOrder(order); } -export function leadProviderChainWith(order: readonly DebridProvider[], preferred: DebridProvider | null | undefined): DebridProvider[] { +export function leadProviderChainWith(order: readonly DebridProvider[], preferred: DebridProvider | null | undefined): DebridProvider[] { if (!preferred || !order.includes(preferred)) { return [...order]; } - return [preferred, ...order.filter((provider) => provider !== preferred)]; -} - -function isRapidgatorLink(link: string): boolean { + return [preferred, ...order.filter((provider) => provider !== preferred)]; +} + +type ProviderSelectionPlan = { + hosterKey: string; + routedProvider?: DebridProvider; + directProviders: DebridProvider[]; + order: DebridProvider[]; +}; + +function buildProviderSelectionPlan( + settings: AppSettings, + link: string, + preferredLeadProvider?: DebridProvider | null +): ProviderSelectionPlan { + const configuredOrder = settings.providerOrder && settings.providerOrder.length > 0 + ? uniqueProviderOrder(settings.providerOrder) + : toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary); + const order = leadProviderChainWith(configuredOrder, preferredLeadProvider); + const hosterKey = extractHosterFromUrl(link); + const routedProvider = hosterKey ? settings.hosterRouting?.[hosterKey] : undefined; + const directProviders: DebridProvider[] = []; + if (isOneFichierLink(link)) { + directProviders.push("onefichier"); + } + if (DDOWNLOAD_URL_RE.test(link)) { + directProviders.push("ddownload"); + } + return { hosterKey, routedProvider, directProviders, order }; +} + +export function isProviderDisabledForSelection(settings: AppSettings, provider: DebridProvider): boolean { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + const disabled = settings.disabledProviders || []; + return disabled.includes(provider) + || disabled.includes(effectiveProvider) + || ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && disabled.includes("megadebrid")); +} + +function isRapidgatorLink(link: string): boolean { try { const hostname = new URL(link).hostname.toLowerCase(); return hostname === "rapidgator.net" @@ -4151,25 +4187,62 @@ export class DebridService { 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 plan = buildProviderSelectionPlan(settings, link, preferredLeadProvider); const deadlines: number[] = []; + if (plan.routedProvider) { + const routedState = this.getProviderRuntimeWaitState(settings, plan.routedProvider, plan.hosterKey, now); + if (routedState.configured) { + if (routedState.retryAt === null) { + return null; + } + deadlines.push(routedState.retryAt); + } + } + for (const provider of plan.directProviders) { + const state = this.getProviderRuntimeWaitState(settings, provider, plan.hosterKey, now); + if (state.configured && state.retryAt === null) { + return null; + } + if (state.retryAt) { + deadlines.push(state.retryAt); + } + } + if (!settings.autoProviderFallback) { + const primary = plan.order[0]; + if (!primary || !this.isProviderConfiguredFor(settings, primary)) { + return deadlines.length > 0 ? Math.min(...deadlines) : null; + } + if (this.isProviderDailyLimited(settings, primary)) { + const secondary = plan.order.find((provider) => provider !== primary && this.isProviderSelectableFor(settings, provider)); + if (secondary) { + const secondaryState = this.getProviderRuntimeWaitState(settings, secondary, plan.hosterKey, now); + if (secondaryState.retryAt === null) { + return null; + } + deadlines.push(secondaryState.retryAt); + } else { + const primaryState = this.getProviderRuntimeWaitState(settings, primary, plan.hosterKey, now); + if (primaryState.retryAt) { + deadlines.push(primaryState.retryAt); + } + } + } else { + const primaryState = this.getProviderRuntimeWaitState(settings, primary, plan.hosterKey, now); + if (primaryState.retryAt === null) { + return null; + } + deadlines.push(primaryState.retryAt); + } + return deadlines.length > 0 ? Math.min(...deadlines) : null; + } const seen = new Set(); - for (const provider of plan) { + for (const provider of plan.order) { const effectiveProvider = resolveMegaDebridProvider(settings, provider); if (seen.has(effectiveProvider)) { continue; } seen.add(effectiveProvider); - const state = this.getProviderRuntimeWaitState(settings, effectiveProvider, hosterKey, now); + const state = this.getProviderRuntimeWaitState(settings, provider, plan.hosterKey, now); if (!state.configured) { continue; } @@ -4187,10 +4260,11 @@ export class DebridService { hosterKey: string, now: number ): { configured: boolean; retryAt: number | null } { - if ((settings.disabledProviders || []).includes(provider)) { + if (isProviderDisabledForSelection(settings, provider)) { return { configured: false, retryAt: null }; } - if (provider === "realdebrid") { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (effectiveProvider === "realdebrid") { const accounts = this.getConfiguredRealDebridAccounts(settings).filter((account) => account.enabled && !isRealDebridAccountDailyLimitReached(settings, account.id, now)); if (accounts.length === 0) { @@ -4201,8 +4275,8 @@ export class DebridService { ? { 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 (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") { + const mode = effectiveProvider === "megadebrid-web" ? "web" : "api"; if (!isMegaDebridModeEnabled(settings, mode) || (mode === "web" && !this.options.megaWebUnrestrict)) { return { configured: false, retryAt: null }; } @@ -4216,7 +4290,7 @@ export class DebridService { ? { configured: true, retryAt: null } : { configured: true, retryAt: Math.min(...deadlines as number[]) }; } - if (provider === "debridlink") { + if (effectiveProvider === "debridlink") { const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter((key) => !isDebridLinkApiKeyDisabled(settings, key.id) && !isDebridLinkApiKeyDailyLimitReached(settings, key.id, now)); if (keys.length === 0) { @@ -4398,13 +4472,11 @@ export class DebridService { return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`; } - public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise { - const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings); - - const routing = settings.hosterRouting || {}; - const hosterKey = extractHosterFromUrl(link); - if (hosterKey && routing[hosterKey]) { - const routedProvider = routing[hosterKey]; + public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise { + const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings); + const selectionPlan = buildProviderSelectionPlan(settings, link, preferredLeadProvider); + const { hosterKey, routedProvider } = selectionPlan; + if (hosterKey && routedProvider) { if (this.isProviderSelectableFor(settings, routedProvider)) { logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`); try { @@ -4437,14 +4509,17 @@ export class DebridService { } } - if (isOneFichierLink(link) && this.isProviderSelectableFor(settings, "onefichier")) { - try { - const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal); - return { - ...result, - provider: "onefichier", - providerLabel: PROVIDER_LABELS["onefichier"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") - }; + for (const directProvider of selectionPlan.directProviders) { + if (!this.isProviderSelectableFor(settings, directProvider)) { + continue; + } + try { + const result = await this.unrestrictViaProvider(settings, directProvider, link, signal); + return { + ...result, + provider: directProvider, + providerLabel: PROVIDER_LABELS[directProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") + }; } catch (error) { const errorText = compactErrorText(error); if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { @@ -4453,32 +4528,10 @@ export class DebridService { if (!settings.autoProviderFallback) { throw error; } - } - } - - if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) { - try { - const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal); - return { - ...result, - provider: "ddownload", - providerLabel: PROVIDER_LABELS["ddownload"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "") - }; - } catch (error) { - const errorText = compactErrorText(error); - if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { - throw error; - } - if (!settings.autoProviderFallback) { - throw error; - } - } - } - - const baseOrder: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0) - ? uniqueProviderOrder(settings.providerOrder) - : toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary); - const order = leadProviderChainWith(baseOrder, preferredLeadProvider); + } + } + + const order = selectionPlan.order; const primary = order[0]; if (!settings.autoProviderFallback) { @@ -4584,9 +4637,9 @@ export class DebridService { throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`); } - private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean { - const effectiveProvider = resolveMegaDebridProvider(settings, provider); - if ((settings.disabledProviders || []).includes(provider) || (settings.disabledProviders || []).includes(effectiveProvider)) return false; + private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (isProviderDisabledForSelection(settings, provider)) return false; if (effectiveProvider === "realdebrid") { return this.getConfiguredRealDebridAccounts(settings).some((account) => account.enabled); } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 7e1521a..b146c7b 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -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, 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, isProviderDisabledForSelection, 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"; @@ -9017,7 +9017,7 @@ export class DownloadManager extends EventEmitter { private isProviderConfigured(provider: DebridProvider): boolean { this.ensureProviderDailyUsageFresh(nowMs()); const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; - if ((this.settings.disabledProviders || []).includes(provider) || (this.settings.disabledProviders || []).includes(effectiveProvider)) { + if (isProviderDisabledForSelection(this.settings, provider)) { return false; } if (isProviderDailyLimitReached(this.settings, effectiveProvider)) { diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts index f582b1b..7636699 100644 --- a/src/main/realdebrid-web.ts +++ b/src/main/realdebrid-web.ts @@ -80,9 +80,6 @@ async function raceWithAbort(promise: Promise, signal?: AbortSignal): Prom if (!signal) { return promise; } - if (signal.aborted) { - throw abortError(); - } return new Promise((resolve, reject) => { let settled = false; const onAbort = (): void => { @@ -109,6 +106,9 @@ async function raceWithAbort(promise: Promise, signal?: AbortSignal): Prom signal.removeEventListener("abort", onAbort); reject(error); }); + if (signal.aborted) { + onAbort(); + } }); } diff --git a/tests/alldebrid-web.test.ts b/tests/alldebrid-web.test.ts index 00a94f6..d097de9 100644 --- a/tests/alldebrid-web.test.ts +++ b/tests/alldebrid-web.test.ts @@ -181,6 +181,33 @@ describe("alldebrid-web", () => { await Promise.resolve(); }); + it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => { + mockFetch.mockResolvedValue(new Response(JSON.stringify({ + link: "https://alldebrid.direct/next.bin", + filename: "next.bin", + filesize: 666 + }), { status: 200 })); + const fallback = new AllDebridWebFallback(() => true); + const controller = new AbortController(); + controller.abort("before-queue"); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + await expect(fallback.unrestrict("https://rapidgator.net/file/pre-aborted", controller.signal)) + .rejects.toThrow("aborted:alldebrid-web"); + await new Promise((resolve) => setImmediate(resolve)); + await expect(fallback.unrestrict("https://rapidgator.net/file/next")).resolves.toMatchObject({ + directUrl: "https://alldebrid.direct/next.bin", + fileName: "next.bin" + }); + expect(unhandled).toEqual([]); + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + it("opens the login window after login_required and retries generation with the same session partition", async () => { mockFetch .mockResolvedValueOnce(new Response("login", { status: 200 })) diff --git a/tests/bestdebrid-web.test.ts b/tests/bestdebrid-web.test.ts index fcdcb61..42714b2 100644 --- a/tests/bestdebrid-web.test.ts +++ b/tests/bestdebrid-web.test.ts @@ -210,4 +210,38 @@ describe("bestdebrid-web", () => { rejectRequest(new Error("late bestdebrid rejection")); await Promise.resolve(); }); + + it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => { + const filePath = createCookieFile([ + "# Netscape HTTP Cookie File", + "bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session" + ].join("\n")); + tempFiles.push(filePath); + const fallback = new BestDebridWebFallback(() => true); + await fallback.importCookiesFromFile(filePath); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ + error: 0, + link: "https://bestdebrid.direct/next.bin", + filename: "next.bin", + size: "777 B" + }), { status: 200 })); + const controller = new AbortController(); + controller.abort("before-queue"); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + await expect(fallback.unrestrict("https://1fichier.com/?pre-aborted", controller.signal)) + .rejects.toThrow("aborted:bestdebrid-web"); + await new Promise((resolve) => setImmediate(resolve)); + await expect(fallback.unrestrict("https://1fichier.com/?next")).resolves.toMatchObject({ + directUrl: "https://bestdebrid.direct/next.bin", + fileName: "next.bin" + }); + expect(unhandled).toEqual([]); + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); }); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index ae8e2da..6f40421 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -998,6 +998,93 @@ describe("deterministic stop and restart lifecycle", () => { }); }); + it("uses an available secondary provider for cooldown projection even when automatic failure fallback is disabled", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-fallback-provider-context-")); + tempDirs.push(root); + const accountId = "rda_disabled_fallback_secondary"; + const manager = new DownloadManager( + { + ...defaultSettings(), + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]), + allDebridToken: "all-debrid-token", + providerOrder: ["realdebrid", "alldebrid"], + autoProviderFallback: false, + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + manager.addPackages([{ name: "secondary", links: ["https://rapidgator.net/file/secondary"] }]); + primeRealDebridRuntimeCooldownForTests(accountId, 60_000); + + expect(manager.getSnapshot()).toMatchObject({ + canStart: true, + lifecycle: { phase: "idle", retryAt: null } + }); + }); + + it.each([ + { + name: "1Fichier", + link: "https://1fichier.com/?direct123", + settings: { oneFichierApiKey: "onefichier-key" } + }, + { + name: "DDownload", + link: "https://ddownload.com/abc12345/direct.bin", + settings: { ddownloadLogin: "user", ddownloadPassword: "password" } + } + ])("does not project a Real-Debrid cooldown over the direct $name path", ({ link, settings: directSettings }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-direct-provider-context-")); + tempDirs.push(root); + const accountId = `rda_direct_${path.basename(root)}`; + const manager = new DownloadManager( + { + ...defaultSettings(), + ...directSettings, + realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]), + providerOrder: ["realdebrid"], + autoProviderFallback: false, + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + manager.addPackages([{ name: "direct", links: [link] }]); + primeRealDebridRuntimeCooldownForTests(accountId, 60_000); + + expect(manager.getSnapshot()).toMatchObject({ + canStart: true, + lifecycle: { phase: "idle", retryAt: null } + }); + }); + + it("does not project a Mega-Debrid retry when the shared Mega provider alias is disabled", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-mega-alias-context-")); + tempDirs.push(root); + const login = "disabled-mega@example.test"; + const accountId = getMegaDebridAccountId(login); + const manager = new DownloadManager( + { + ...defaultSettings(), + megaDebridApiCredentials: `${login}:password`, + megaDebridApiEnabled: true, + disabledProviders: ["megadebrid"], + providerOrder: ["megadebrid"], + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + manager.addPackages([{ name: "mega-disabled", links: ["https://rapidgator.net/file/mega-disabled"] }]); + primeMegaDebridRuntimeCooldownForTests(`${accountId}:api`, 60_000); + + expect(manager.getSnapshot()).toMatchObject({ + canStart: false, + 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); diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts index 6b02ce9..41ac170 100644 --- a/tests/realdebrid-web.test.ts +++ b/tests/realdebrid-web.test.ts @@ -231,6 +231,34 @@ describe("realdebrid-web", () => { await Promise.resolve(); }); + it("observes the raw rejection when the signal is already aborted and keeps the queue usable", async () => { + mockSessionFetch.mockResolvedValue(new Response("", { status: 200 })); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + download: "https://cdn.real-debrid.example/next.bin", + filename: "next.bin", + filesize: 555 + }), { status: 200 }))); + const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_pre_aborted", () => true); + const controller = new AbortController(); + controller.abort("before-queue"); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + await expect(fallback.unrestrict("https://rapidgator.net/file/pre-aborted", controller.signal)) + .rejects.toThrow("aborted:realdebrid-web"); + await new Promise((resolve) => setImmediate(resolve)); + await expect(fallback.unrestrict("https://rapidgator.net/file/next")).resolves.toMatchObject({ + directUrl: "https://cdn.real-debrid.example/next.bin", + fileName: "next.bin" + }); + expect(unhandled).toEqual([]); + expect(mockSessionFetch).toHaveBeenCalledTimes(1); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + it("does not open a login window for an authenticated account with a fair-use error", async () => { mockSessionFetch.mockResolvedValue(new Response("", { status: 200 })); vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({