From ba144f323bd15473406fb63bbb0b31e1631627a8 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 17 Jun 2026 20:23:28 +0200 Subject: [PATCH] Fix: Statistik-Doppelzaehlung bei Retry + Debrid-Link Key-Cooldown bei Abbruch (Nutzer-Nachforderung Audit) BYTE-DROP-RETRY-1 (MED): Integritaets-/Zu-klein-/Tiny-Neuversuche zaehlten die volle Dateigroesse pro Versuch erneut in die Byte-Statistik. Ursache: die 3 rm-dann-frisch-Sites (Integrity-Fail 8979, too-small 9020, tiny 10486) riefen dropItemContribution() auf, das den itemContributedBytes-Eintrag loescht, den die einzige Reconciliation (9991, writeMode 'w') zum Subtrahieren braucht -> Subtraktion tot -> Re-Download addiert erneut. Fix (Mechanismus a): dropItemContribution an diesen 3 Sites entfernt, der Eintrag ueberlebt, 9991 subtrahiert ihn korrekt (selbst-korrigierend nach writeMode). Zusaetzlich am selben Punkt totalDownloadedAllTime subtrahiert (wurde nie subtrahiert -> doppelte bei JEDEM frischen Re-Download). BEWUSST ausgeklammert: recordProviderDownloadedBytes/ providerDailyUsageBytes, da diese isProviderDailyLimited (= Verhalten) steuern und nicht provider-keyed sind. Reine Telemetrie-Korrektur, kein Slot/Admission betroffen. DL-1 (LOW): Ein abort-ohne-timeout (User-Cancel) setzte am DebridLink-Rotations- Catch via classifyKeyFailure einen 15s-Key-Cooldown (und konnte ueber Keys zu einer providerweiten Kaskade fuehren). Fix: Mega-Gate (2072) am Catch (2789) gespiegelt - abort-ohne-timeout + elapsedMs < getMegaDebridAbortMinRunMs() -> kein Cooldown; ran-long-enough -> 120s (Retry rotiert); throw bailt die Rotation. DL-CONCURRENCY-PILEUP (MED): untersucht -> bereits strukturell geloest (getSerializedValidatingLimit = nutzbare Accounts + shouldDelayStartForItem 8526; MW-1 haelt das Limit hoch). Kein Eingriff (Nutzer-Entscheidung). Je rot-bewiesener Test (per Temp-Revert, nicht-vakuum; BYTE-DROP beide Beine einzeln). Volle Suite 893 gruen, tsc unveraendert (6 vorbestehende Fehler). --- src/main/debrid.ts | 22 ++++++++ src/main/download-manager.ts | 4 +- tasks/audit-loop.md | 31 +++++++++++ tasks/entscheidungen-offen.md | 23 +++++++++ tests/debrid.test.ts | 74 +++++++++++++++++++++++++- tests/download-manager.test.ts | 94 ++++++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 4 deletions(-) diff --git a/src/main/debrid.ts b/src/main/debrid.ts index b959c6f..23315f2 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -123,6 +123,11 @@ export function getDebridLinkKeyRuntimeStateForTests(keyId: string): DebridLinkR return status ? status.state : null; } +export function getDebridLinkKeyCooldownStateForTests(keyId: string, now = Date.now()): { remainingMs: number; message: string } | null { + const state = getDebridLinkKeyCooldownState(keyId, now); + return state ? { remainingMs: state.remainingMs, message: state.message } : null; +} + function clearDebridLinkKeyCooldownState(keyId: string): void { debridLinkKeyCooldowns.delete(keyId); debridLinkKeyCooldownDetails.delete(keyId); @@ -2787,6 +2792,23 @@ class DebridLinkClient { } catch (error) { const failure = await this.classifyKeyFailure(error, apiKey, link, signal); const elapsedMs = Date.now() - testStartedAt; + const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); + if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { + const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); + if (ranLongEnough) { + setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_KEY_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary"); + } else { + clearDebridLinkKeyCooldownState(apiKey.id); + } + failures.push(`Debrid-Link${keyLabel}: ${abortText}`); + logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", { + elapsedMs, + reason: abortText, + cooldownSec: ranLongEnough ? Math.ceil(DEBRID_LINK_KEY_COOLDOWN_MS / 1000) : 0, + next: "naechster Key beim Retry" + }); + throw new Error(`Debrid-Link${keyLabel}: ${abortText}`); + } attemptedKeyFailures.push({ message: `Debrid-Link${keyLabel}: ${failure.message}`, cooldownMs: failure.cooldownMs, diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index c7a42f6..ac7c496 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -8976,7 +8976,6 @@ export class DownloadManager extends EventEmitter { if (item.attempts < maxAttempts) { item.status = "integrity_check"; item.progressPercent = 0; - this.dropItemContribution(item.id); item.downloadedBytes = 0; item.totalBytes = unrestricted.fileSize; this.emitState(); @@ -9017,7 +9016,6 @@ export class DownloadManager extends EventEmitter { } catch { } this.releaseTargetPath(item.id); - this.dropItemContribution(item.id); item.downloadedBytes = 0; item.progressPercent = 0; item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null; @@ -9993,6 +9991,7 @@ export class DownloadManager extends EventEmitter { if (previouslyContributed > 0) { this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - previouslyContributed); this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - previouslyContributed); + this.settings.totalDownloadedAllTime = Math.max(0, Number(this.settings.totalDownloadedAllTime || 0) - previouslyContributed); this.itemContributedBytes.set(active.itemId, 0); } if (existingBytes > 0) { @@ -10483,7 +10482,6 @@ export class DownloadManager extends EventEmitter { await fs.promises.rm(effectiveTargetPath, { force: true }); } catch { } this.releaseTargetPath(active.itemId); - this.dropItemContribution(active.itemId); item.downloadedBytes = 0; item.progressPercent = 0; throw new Error(`Download zu klein (${written} B) – Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`); diff --git a/tasks/audit-loop.md b/tasks/audit-loop.md index 4e9cdcf..89afc5d 100644 --- a/tasks/audit-loop.md +++ b/tasks/audit-loop.md @@ -120,6 +120,37 @@ MW-1 (HIGH, Web-Selbstcooldown) + SET-MIG-01 (MED, Legacy-Mega-Demotion). Gitea Dokumentiert-nicht-gefixt: DL-1, DL-CONCURRENCY-PILEUP, BYTE-DROP-RETRY-1, extractor.ts-Passwort-Cache-Seam. Beim Nutzer (nicht autonom): 60s-Failover-Kappung + gespiegelter Mega-API/Web-Schalter (entscheidungen-offen.md). +## Nutzer-Nachforderung (nach Goal-Abschluss): die 3 dokumentierten Funde DOCH umsetzen → v1.7.221 +Nutzer: "dann mach das beides erstmal" (BYTE-DROP-RETRY-1 + DL-1 + DL-CONCURRENCY-PILEUP). Nicht relitigiert OB, +nur WIE (Advisor-gefuehrt, je rot-bewiesen, je full-suite gruen + tsc=6 single-pass). +- **BYTE-DROP-RETRY-1 (MED) GEFIXT.** Scope-Disziplin (Advisor): NUR Session-Counter + totalDownloadedAllTime, + NICHT recordProviderDownloadedBytes/providerDailyUsageBytes (das gated isProviderDailyLimited = Verhalten, + und ist nicht provider-keyed → naive Subtraktion wuerde den falschen Provider-Bucket korrumpieren → bewusst + ausgeklammert). Mechanismus (a): an den 3 bestaetigten rm-dann-frisch-Sites (Integrity 8979, too-small 9020, + tiny 10486) den `dropItemContribution`-Aufruf ENTFERNT → der itemContributedBytes-Eintrag ueberlebt → die + bestehende writeMode-"w"-Reconciliation (9991) subtrahiert ihn korrekt (selbst-korrigierend nach writeMode, + Append undercounted nicht). Plus: am selben Punkt (9991) `totalDownloadedAllTime -= previouslyContributed` + ergaenzt (spiegelt den Add bei 10311; All-Time wurde NIE subtrahiert → doppelte bei JEDEM frischen Re-Download, + nicht nur den dropItemContribution-Pfaden). KEINE 23-Site-Reklassifikation (Advisor: Provider-Usage off-limits + → jede Restfehlklassifikation ist bounded Telemetrie). Guard-Test dl-mgr.test.ts:6152 (Completion-Removal behaelt + Session-Total) bleibt gruen. Rot-bewiesen NICHT-vakuum, BEIDE Beine einzeln: Integration durch echten + Integrity-Fail-Retry (.md5-Manifest, lokaler HTTP-Server serviert wrong-dann-correct), Assert session==1x UND + allTime==1x; Bein 1 (All-Time-Zeile raus) → allTime rot (2x) session gruen; Bein 2 (dropItemContribution zurueck) + → session rot (2x). +- **DL-1 (LOW) GEFIXT.** Advisor revidierte den frueheren "braucht neue Oberflaeche"-Call: am Rotations-Catch (2789) + ist elapsedMs bereits da → Mega-Gate (2072) gespiegelt. abort-ohne-timeout + elapsedMs < getMegaDebridAbortMinRunMs() + → KEIN Key-Cooldown (User-Cancel bestraft den Key nicht); ran-long-enough → DEBRID_LINK_KEY_COOLDOWN_MS (120s) + damit der Retry rotiert; throw bailt die Rotation (verhindert auch die zuvor moegliche Transport-Kaskade ueber + mehrere Keys bei aborted-Signal). Neuer Test-Getter getDebridLinkKeyCooldownStateForTests. Rot-bewiesen + (quick-cancel → null; ohne Fix 15s gesetzt; long-abort → >60s, ohne Fix 15s). +- **DL-CONCURRENCY-PILEUP (MED): untersucht → BEREITS STRUKTURELL GELOEST, kein Eingriff (Nutzer-Entscheidung + "Akzeptieren").** getSerializedValidatingLimit("megadebrid-web") = Anzahl nutzbarer (nicht-gecoolter) Accounts + (dl-mgr 8047-8055); shouldDelayStartForItem erzwingt es in der Kandidatenwahl (8526) → Ueberschuss-Konvertierungen + warten als "queued" im Scheduler, NICHT in den per-Account-Single-Flight-Queues; Depth-Spread (debrid 1981-1986) + verteilt die erlaubten 1-pro-Account. MW-1 haelt usableAccounts (= das Limit) korrekt hoch. Ein weiterer + Scheduler-Eingriff = redundant ODER schaedlich (Ueber-Admission = echter Pileup) → Advisor-4.-Bug-Risiko. Dem + Nutzer vorgelegt (AskUserQuestion) → "Akzeptieren, kein Eingriff". + ## Runde 1 (laeuft) - Discover+Verify-Workflow ueber 7 Subsysteme (scheduler-slots, unrestrict-retry, mega-rotation, classify-cooldown, mega-web-token, provider-chain-timeout, account-availability). diff --git a/tasks/entscheidungen-offen.md b/tasks/entscheidungen-offen.md index 78042e8..6e458ef 100644 --- a/tasks/entscheidungen-offen.md +++ b/tasks/entscheidungen-offen.md @@ -63,6 +63,29 @@ ODER zwei unabhängige Pro-Modus-Schalter (mehr Kontrolle, aber UI + Migration n ## Erledigt in dieser Runde (zur Info, kein Handlungsbedarf) +- **Download-Statistik zählt eine Datei nach einem Integritäts-/Zu-klein-Neuversuch nicht mehr doppelt:** + Schlug eine Datei die CRC-/Hash-Prüfung fehl (oder kam zu klein an) und wurde komplett neu geladen, wurde die + Dateigröße bisher pro Versuch erneut in die Statistik addiert — die Anzeige „insgesamt heruntergeladen" (Session + und Gesamt-Zähler) sowie die daraus berechnete Durchschnittsgeschwindigkeit waren dadurch bei flatterhaften Hostern + um die jeweilige Dateigröße aufgebläht (bei großen Archiven mit wiederholten CRC-Fehlern um mehrere GB). Jetzt zählt + jede gelieferte Datei genau einmal. Reine Anzeige-/Statistik-Korrektur — Slot-Vergabe, Tageslimits und der + Download-Ablauf waren nie betroffen (die Tageslimit-Zähler werden bewusst nicht angefasst, da sie die Provider-Auswahl + steuern und der echte Datenverkehr über die Leitung ging). Rot-bewiesener Test, beide Zähler einzeln geprüft. + +- **Debrid-Link: ein abgebrochener Vorgang sperrt den Key nicht mehr unnötig:** + Wenn du einen Vorgang abgebrochen hast (oder der Gesamttimeout zuschlug), bevor echte Arbeit lief, bekam der + Debrid-Link-Key bisher trotzdem eine 15-Sekunden-Sperre — bei mehreren Abbrüchen in Folge konnte das sogar über + mehrere Keys kaskadieren und eine längere providerweite Sperre auslösen. Jetzt wird ein schneller Abbruch (vor der + Mindest-Laufzeit) nicht mehr als Key-Fehler gewertet: keine Sperre. Lief der Vorgang dagegen lange genug und brach + dann ab (echter langsamer/hängender Key), wird er weiterhin gesperrt, damit der nächste Versuch sauber auf den + nächsten Key rotiert. Spiegelt exakt das Verhalten, das es bei Mega-Debrid schon gibt. Rot-bewiesener Test. + +- **Mega-Konvertierungs-Stau (geprüft, kein Eingriff nötig):** Die Zahl gleichzeitiger Mega-Umwandlungen ist bereits + auf die Anzahl nutzbarer Accounts gedeckelt — Überschuss wartet sauber im Scheduler statt sich in den Account- + Warteschlangen zu stapeln, und der oben beschriebene Mega-Web-Fix hält dieses Limit jetzt korrekt hoch. Ein + zusätzlicher Eingriff wäre überflüssig oder würde durch Über-Vergabe erst echten Stau erzeugen. Auf deine + Entscheidung hin daher bewusst NICHT verändert. + - **Alte Konfiguration: Mega-Debrid fällt nach einem Upgrade nicht mehr still aus der Provider-Reihenfolge:** Eine Konfigurationsdatei, die noch von einer sehr alten Version (vor v1.6.90) stammt, kannte die getrennten Mega-Debrid „API aktiv"/„Web aktiv"-Schalter noch nicht. Beim Laden wurden diese fehlenden Schalter still auf diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index ad3559c..353583c 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, leadProviderChainWith, 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, getDebridLinkKeyCooldownStateForTests, 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; @@ -572,6 +572,78 @@ describe("debrid service", () => { expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready"); }); + it("does NOT cool down a Debrid-Link key on a quick user-cancel abort (below the min-run threshold)", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "", + megaPassword: "", + megaCredentials: "", + debridLinkApiKeys: "dl-key-one", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + const controller = new AbortController(); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/downloader/add")) { + controller.abort(); + throw new Error("aborted"); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id; + const service = new DebridService(settings); + await expect( + service.unrestrictLink("https://rapidgator.net/file/dl-quick-cancel", controller.signal) + ).rejects.toThrow(); + + expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull(); + }); + + it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => { + process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "", + megaPassword: "", + megaCredentials: "", + debridLinkApiKeys: "dl-key-one", + providerOrder: ["debridlink"] as const, + providerPrimary: "debridlink" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + const controller = new AbortController(); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/downloader/add")) { + controller.abort(); + throw new Error("aborted"); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id; + const service = new DebridService(settings); + await expect( + service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal) + ).rejects.toThrow(); + + const cooldown = getDebridLinkKeyCooldownStateForTests(keyId); + expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000); + }); + it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => { const settings = { ...defaultSettings(), diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 72cdd15..8ed1a1e 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -1373,6 +1373,100 @@ describe("download manager", () => { } }); + it("counts a file once in session and all-time totals across an integrity-fail retry (no telemetry double-count)", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-byteacct-")); + tempDirs.push(root); + const size = 512 * 1024; + const correct = Buffer.alloc(size, 0); + for (let i = 0; i < size; i += 1) { + correct[i] = (i * 17 + 3) & 0xff; + } + const wrong = Buffer.alloc(size, 0); + for (let i = 0; i < size; i += 1) { + wrong[i] = (i * 17 + 99) & 0xff; + } + const md5 = crypto.createHash("md5").update(correct).digest("hex"); + + const pkgDir = path.join(root, "downloads", "byteacct"); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, "byteacct.md5"), `${md5} *movie.mkv\n`, "utf8"); + + let fullServes = 0; + const server = http.createServer((req, res) => { + const range = String(req.headers.range || ""); + const m = range.match(/bytes=(\d+)-/i); + const start = m ? Number(m[1]) : 0; + if (start === 0) { + fullServes += 1; + } + const body = fullServes <= 1 ? wrong : correct; + const chunk = body.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${body.length - 1}/${body.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const base = `http://127.0.0.1:${address.port}`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ download: `${base}/file`, filename: "movie.mkv", filesize: size }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: true, + totalDownloadedAllTime: 0 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "byteacct", links: ["https://dummy/byteacct"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(fullServes).toBeGreaterThanOrEqual(2); + + const internal = manager as unknown as { + session: { totalDownloadedBytes: number }; + settings: { totalDownloadedAllTime: number }; + }; + expect(internal.session.totalDownloadedBytes).toBe(size); + expect(internal.settings.totalDownloadedAllTime).toBe(size); + } finally { + server.close(); + await once(server, "close"); + } + }, 40000); + it("requests a fresh direct link after repeated same-link download failures", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);