From 986fbab04fd1e54776d288b044ad83292ab9b202 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 17 Jun 2026 06:23:44 +0200 Subject: [PATCH] Fix: Provider-Cooldown-Fallback fuehrt die Kette jetzt wirklich mit dem Ersatz-Provider an (Routing-Slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wenn ein Provider in den Manager-Cooldown laeuft (>=20 Fehler in Folge) und auto-Fallback aktiv ist, berechnete der Manager zwar einen Ersatz-Provider (findFallbackProviderNotInCooldown), warf ihn dann aber weg: der unrestrictLink- Aufruf bekam keinen Hint, also baute debrid.ts die Provider-Kette frisch aus providerOrder und fuehrte erneut mit dem ausgebremsten Provider 1 an — ein meist in den 60s-Timeout laufender, verschwendeter Versuch, bevor ueberhaupt der gesunde Provider drankam. Jetzt wird der Ersatz-Provider als bevorzugter Lead durchgereicht. Die Kette wird UMSORTIERT, nicht beschnitten: der ausgebremste Provider bleibt als letzter Notnagel in der Reihenfolge, sodass kein Link gestrandet werden kann. Sind ALLE Provider im Cooldown, erreicht der Aufruf unrestrictLink ohnehin nicht (der else-Zweig queued einen Retry). Greift damit nur, wenn ein Provider bereits nachweislich degradiert ist — strikt besser, wenn aktiv, und ohne Timeout-, Cancel- oder Budget-Vertrag anzufassen. - debrid.ts: reine leadProviderChainWith(order, preferred) (export) + optionaler 4. Param preferredLeadProvider an unrestrictLink; Reorder direkt nach Aufbau der Reihenfolge. - download-manager.ts: Ersatz-Provider in preferredLeadProvider festhalten und an den unrestrictLink-Aufruf durchreichen. - Tests: Integration (ohne Hint fuehrt realdebrid, mit Hint fuehrt debridlink) + drei reine Helper-Tests fuer die No-Stranding-Invariante. Zwei verbleibende HIGH-Punkte sind bewusst NICHT autonom geaendert, sondern als Produkt-/UI-Entscheidung in tasks/entscheidungen-offen.md vorgelegt (globaler 60s-Failover-Timeout; gespiegelter Mega API/Web-Schalter). --- src/main/debrid.ts | 12 +++++- src/main/download-manager.ts | 4 +- tasks/audit-loop.md | 24 ++++++++++++ tasks/entscheidungen-offen.md | 70 +++++++++++++++++++++++++++++++++++ tests/debrid.test.ts | 55 ++++++++++++++++++++++++++- 5 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 tasks/entscheidungen-offen.md diff --git a/src/main/debrid.ts b/src/main/debrid.ts index a1813b5..83d78e0 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -1321,6 +1321,13 @@ function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvi return uniqueProviderOrder(order); } +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 { try { const hostname = new URL(link).hostname.toLowerCase(); @@ -3700,7 +3707,7 @@ export class DebridService { return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`; } - public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise { + 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 || {}; @@ -3777,9 +3784,10 @@ export class DebridService { } } - const order: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0) + 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 primary = order[0]; if (!settings.autoProviderFallback) { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 01a8b86..e6184b2 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -8771,6 +8771,7 @@ export class DownloadManager extends EventEmitter { } const cooldownProvider = this.getProviderFailureKeyForItem(item); const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider); + let preferredLeadProvider: DebridProvider | null = null; if (cooldownMs > 0) { if (this.settings.autoProviderFallback) { const fallback = this.findFallbackProviderNotInCooldown(item); @@ -8782,6 +8783,7 @@ export class DownloadManager extends EventEmitter { fallback }); item.provider = null; + preferredLeadProvider = fallback; } else { this.logPackageForItem(item, "WARN", "Provider-Cooldown blockiert Unrestrict", { provider: cooldownProvider, @@ -8825,7 +8827,7 @@ export class DownloadManager extends EventEmitter { traceConversionNote("slots", this.describeSlotOccupancy()); traceConversionNote("retry", Number(active.unrestrictRetries || 0)); try { - return await this.debridService.unrestrictLink(item.url, unrestrictedSignal); + return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider); } catch (innerError) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { traceConversionPhase({ diff --git a/tasks/audit-loop.md b/tasks/audit-loop.md index 2f7c99c..088fc22 100644 --- a/tasks/audit-loop.md +++ b/tasks/audit-loop.md @@ -106,6 +106,30 @@ unabhaengigen Code-Verifikation abgedeckt (60s-Timeout kappt Failover, debrid.ts - #R3-7 LOW (3/3) Update-Integritaet hash-only, kein Authenticode — ehrliche Grenze, faellt sicher aus. - REFUTIERT (0/3): all-accounts-parked wirft plain error ohne cooldown-retry-Token. +## Runde 4 (Failover-Routing-Slice + Entscheidungs-Doku) +Follow-on aus R3-Failover-Fund. Advisor-Disziplin: nur die SICHERE Scheibe autonom, der +Produkt-Tradeoff geht an den Nutzer (tasks/entscheidungen-offen.md). + +### Autonom gefixt (TDD, rot-bewiesen) +- [x] MED Failover-Routing: Manager berechnete bei Provider-Cooldown (>=20 Fehler in Folge, + auto-Fallback an) einen Ersatz-Provider (`findFallbackProviderNotInCooldown`), WARF ihn aber + weg — `unrestrictLink(item.url, signal)` ohne Hint → debrid.ts baut `order` neu aus + providerOrder und fuehrt WIEDER mit dem ausgebremsten Provider1 an (wahrsch. 60s-Timeout + verschwendet). Fix: reine `leadProviderChainWith(order, preferred)` (debrid.ts, export) + + 4. optionaler Param `preferredLeadProvider` an `unrestrictLink`; Manager reicht den Ersatz + durch (dl-mgr 8772/8828). REORDER nicht SKIP → ausgebremster Provider bleibt als letzter + Notnagel in der Kette, kein Stranding. All-cooled-Fall erreicht `unrestrictLink` gar nicht + (else-Zweig queueRetry'd). Tests: integration (control=realdebrid, preferred=debridlink) + + 3 reine Helper-Tests (null→unchanged, in-order→leads+keeps-all, not-in-order→unchanged). + Suite 875 gruen, tsc=6. Commit folgt; HALTEN fuer Roll-up-Release (kein HIGH/dringend). + +### An den Nutzer vorgelegt (NICHT autonom) → tasks/entscheidungen-offen.md +- 60s-Failover-Kappung (HIGH): A) pro-Provider-Timeout (Failover immer, aber bis 3×60s + Worst-Case) vs B) globales Budget mit Failover-Reserve (langsamer Provider1 frueher + abgeschnitten). Produkt-Tradeoff = Nutzerwahl. Post-214 weitgehend latent. +- Gespiegelter Mega API/Web-Schalter (HIGH, #R3-2): gemeinsamer Schalter vs unabhaengige + pro-Modus-Flags (Migration + UI-Redesign noetig, Nutzer UI-sensibel). + ## Runde 2 (Download-Ausfuehrung: stream/resume/disk/integrity/extract/persist) — Workflow whspc8ddv 14 confirmed / 7 refuted (>=2/3 adversarisch). Alle HIGH/MED unten unabhaengig am echten Code verifiziert (Zeilen zitiert) bevor gefixt. Jeder Fix mit rot-bewiesenem Test, tsc bleibt 6. diff --git a/tasks/entscheidungen-offen.md b/tasks/entscheidungen-offen.md new file mode 100644 index 0000000..7a72ceb --- /dev/null +++ b/tasks/entscheidungen-offen.md @@ -0,0 +1,70 @@ +# Offene Entscheidungen für dich (Audit-Loop 2026-06-17) + +Diese zwei Punkte habe ich BEWUSST nicht autonom „gefixt", weil jede Lösung +einen Produkt-/Geschmacks-Kompromiss enthält, den du entscheiden solltest, nicht ich. +Beide sind verifiziert (Code-Zitat + Szenario), nur die Richtung ist deine Wahl. + +--- + +## 1. Failover-Kappung durch globalen 60-Sekunden-Timeout (HIGH) + +**Was passiert (belegt):** +`download-manager.ts` baut EINEN Timeout fürs gesamte Unrestrict: +``` +const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs()); // 60s +const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]); +``` +Dieses EINE Signal geht an die komplette Provider-Kette in `debrid.ts`. Die 60s sind +also ein Budget für ALLE Provider zusammen, nicht pro Provider. Wenn Provider 1 +(z.B. Mega-Web mit Account-Queue) das Budget verbraucht, dann sieht Provider 2 ein +bereits abgelaufenes Signal → `debrid.ts` wertet den Abbruch als „kein Failover" und +wirft, ohne Provider 2 echt zu versuchen. + +**Aktuell weitgehend latent:** Seit v1.7.214 wird die API zuerst probiert (schneller Pfad), +Mega-Web ist nur noch Fallback. Das Szenario „langsamer Provider 1 hungert Provider 2 aus" +trifft in der Produktion derzeit selten. Deshalb dokumentiert statt dringend gefixt. + +**Deine Entscheidung — zwei Richtungen (gleiche Spannung, andere Seite):** + +- **A) Pro-Provider-Timeout:** Jeder Provider bekommt sein eigenes frisches 60s-Budget. + Failover bekommt IMMER einen echten Versuch. + *Kosten:* Worst-Case-Wartezeit pro Item steigt (3 Provider × 60s = bis zu 180s, bevor ein + Item aufgibt). Du akzeptierst langsameres Worst-Case-pro-Item für vollständigeres Failover. + +- **B) Globales Budget behalten, aber Slice für Failover reservieren:** Provider 1 wird auf + z.B. 35s gedeckelt, damit garantiert Zeit für Provider 2 bleibt. + *Kosten:* Ein legitim langsamer Provider 1 (Mega-Web-Account-Queue bis ~90s) wird früher + abgeschnitten → mehr Failover, auch wenn Provider 1 noch erfolgreich gewesen wäre. + +Kernfrage, die nur du beantworten kannst: **Wie lange darf ein Item bei einem langsamen +aber funktionierenden Provider hängen, bevor wir ihn zugunsten des nächsten aufgeben?** + +--- + +## 2. Gespiegelter Mega API/Web-Schalter (HIGH, in Runde 3 als 2/3 bestätigt) + +**Was passiert (belegt):** Die Mega-Debrid API- und Web-Account-Zeilen teilen sich EINE +login-only Enable-Flag (`hasMegaDebridCredentials` gilt für beide; die Pro-Modus-Auswahl +läuft über `isMegaDebridModeEnabled(settings, "api"|"web")`). Dein Report: „API ausschalten +schaltet Web an" — weil der Schalter sich spiegelt. + +**Warum ich es NICHT autonom geändert habe:** +- Daten-Modell-Fix (echte unabhängige Flags) braucht eine Settings-Migration, die + deaktivierte Accounts versehentlich re-aktivieren könnte. +- Die saubere UI-Variante (zwei echte unabhängige Schalter) ist ein Layout-Redesign — und + du bist UI-Geschmack-sensibel; das will ich nicht ungefragt umbauen. + +**Deine Entscheidung:** Ein gemeinsamer Schalter (API+Web zusammen an/aus, klar beschriftet) +ODER zwei unabhängige Pro-Modus-Schalter (mehr Kontrolle, aber UI + Migration nötig)? + +--- + +## Erledigt in dieser Runde (zur Info, kein Handlungsbedarf) + +- **MED Failover-Routing:** Wenn ein Provider in den Manager-Cooldown läuft (≥20 Fehler in + Folge) und auto-Fallback an ist, hat der Manager bisher zwar einen Ersatz-Provider berechnet, + ihn aber WEGGEWORFEN — die Kette führte trotzdem wieder mit dem ausgebremsten Provider an. + Jetzt wird der Ersatz-Provider als „Lead" durchgereicht und die Kette führt mit ihm an, OHNE + einen Provider zu verlieren (der ausgebremste bleibt als letzter Notnagel in der Kette). + Greift nur, wenn der Provider bereits nachweislich degradiert ist → strikt-besser-wenn-aktiv, + kein Timeout/Cancel-Vertrag berührt. Rot-bewiesener Test. (Hält für Roll-up-Release bereit.) diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 7d70c02..4c51765 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -3,7 +3,7 @@ import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; -import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; +import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; const originalFetch = globalThis.fetch; @@ -15,6 +15,21 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe("leadProviderChainWith", () => { + it("leaves the order unchanged when no preferred provider is given", () => { + expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], null)).toEqual(["realdebrid", "debridlink", "alldebrid"]); + expect(leadProviderChainWith(["realdebrid", "debridlink"], undefined)).toEqual(["realdebrid", "debridlink"]); + }); + + it("leads with the preferred provider but keeps every other provider as a later fallback", () => { + expect(leadProviderChainWith(["realdebrid", "debridlink", "alldebrid"], "alldebrid")).toEqual(["alldebrid", "realdebrid", "debridlink"]); + }); + + it("does not drop or strand any provider when the preferred one is not in the order", () => { + expect(leadProviderChainWith(["realdebrid", "debridlink"], "alldebrid")).toEqual(["realdebrid", "debridlink"]); + }); +}); + describe("debrid service", () => { it("falls back to Mega web when Real-Debrid fails", async () => { const settings = { @@ -134,6 +149,44 @@ describe("debrid service", () => { expect(calledUrls.some((url) => url.includes("api.real-debrid.com/rest/1.0/unrestrict/link"))).toBe(false); }); + it("leads the provider chain with the preferred (non-cooled) provider when one is hinted", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + debridLinkApiKeys: "dl-token", + providerOrder: ["realdebrid", "debridlink"] as const, + providerPrimary: "realdebrid" as const, + providerSecondary: "debridlink" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + return new Response(JSON.stringify({ + success: true, + value: { downloadUrl: "https://debrid-link.example/file.bin", name: "file.bin", size: 1234 } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ + download: "https://rd.example/file.bin", + filename: "file.bin", + filesize: 1234 + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const control = await service.unrestrictLink("https://hoster.example/lead-control.bin"); + expect(control.provider).toBe("realdebrid"); + + const preferred = await service.unrestrictLink("https://hoster.example/lead-pref.bin", undefined, undefined, "debridlink"); + expect(preferred.provider).toBe("debridlink"); + }); + it("uses the next Debrid-Link key when the first key hit its local daily limit", async () => { const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two"); let usedAuthHeader = "";