Compare commits

..

7 Commits

Author SHA1 Message Date
Sucukdeluxe
bbb935589e Release v1.7.218 2026-06-17 11:00:47 +02:00
Sucukdeluxe
d2a1b831a0 Fix: festes Retry-Limit wird jetzt hart eingehalten — kein Endlos-Shelve-Loop mehr (Runde-6-Audit)
Bei einem FESTEN retryLimit (>=5) sind alle drei Per-Klasse-Retry-Caps
(stall/unrestrict/generic) gleich dem Limit. Die beiden "15-Fehler"-Shelve-Zweige
(download-manager.ts stall-Pfad + error-Pfad) feuern aber auf der hartkodierten
Summe >=15 OBERHALB der Per-Klasse-Terminal-Fail-Pruefungen und HALBIEREN danach
alle drei Zaehler. Dadurch wurden die Per-Klasse-Caps nie gleichzeitig
ueberschritten: der Eintrag erreichte nie status="failed", schliff endlos im
90s-Takt, item.retries wuchs weit ueber das konfigurierte Limit, der
Download-Slot blieb dauerhaft belegt, und das wiederholte
providerFailures.delete besiegte immer wieder den Provider-Circuit-Breaker →
Hoster-Hammering. resetStaleRetryState konnte nicht eingreifen, weil der
<=90s-Re-Admit item.updatedAt frisch haelt (nie 10min stale).

Der Auslieferungs-Default retryLimit=0 (= unendlich) ist NICHT betroffen — dort
ist der 15-Fehler-Shelve der gewollte Dauer-Park-Backstop und es gibt kein
endliches Budget zu verletzen.

Fix: in BEIDEN Shelve-Zweigen vor dem queueRetry eine harte Obergrenze —
`if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit)` failt den
Eintrag terminal (status="failed", recordRunOutcome, retryStateByItem.delete)
statt ihn neu zu queuen. Der ∞-Modus (retryLimit<=0) ueberspringt die Grenze und
bleibt unveraendert.

Runde-6-Audit (adversarisch verifiziert) hat ausserdem den deferred-LOW-Cluster
abgeschlossen: #10 HTTP416-shared-counter, #11 fresh-retry-preempt und #13
queue-wait→elapsedMs sind BENIGN; #12 war genau dieser Bug.

Rot-bewiesen: Single-Pass-Integrationstest seedet genericErrorRetries=15 vor und
injiziert einen generischen Fehler bei retryLimit=5 → ohne Fix requeued der
Shelve (status bleibt nicht "failed"), mit Fix wird terminal gefailt. Volle Suite
882 gruen, tsc unveraendert.
2026-06-17 10:58:18 +02:00
Sucukdeluxe
e49ed1ada0 docs(audit): Release v1.7.218 aufgeschoben (buendeln Runde 4+5+6, Advisor-Sign-off abwarten) 2026-06-17 10:39:41 +02:00
Sucukdeluxe
f375fe5a2e docs(audit): Runde 5 (reconnect/scheduler/persistenz) sauber + PP-SEM-1 dokumentiert; Synthese-Pass
Runde 5 ueber drei unberuehrte Subsysteme (auto-reconnect-Resume, Scheduler-
Fairness/Slot-Accounting, Crash/Persistenz/targetPath-Lifecycle): 0 bestaetigte
Bugs. Einzige Charakterisierung PP-SEM-1 (Post-Process-Semaphore Ueber-Admission
durch Microtask-Race) ist benign (Verschwendung, kein Strand/Loss) → bewusst
nicht live gefixt. Synthese: Retry/Cooldown/Rotation/Failover/Reconnect/Scheduler
end-to-end kohaerent self-heilend.
2026-06-17 10:37:27 +02:00
Sucukdeluxe
f1e35f5f41 Fix: Mega "bis Tagesreset gesperrt" parkt das Paket bis zum Reset statt es den ganzen Tag alle 2 min neu zu versuchen
Wenn ALLE Mega-Debrid-Accounts wegen wiederholt leerer/Server-loser Antworten
bis zum Tagesreset geparkt waren (in-memory untilRestart-Park aus Runde 3),
warf unrestrictWithAccounts einen reinen Klartext-Fehler ohne Maschinen-Token.
Im Manager-Catch fiel dieser durch parseMegaDebridCooldownRetry (kein
mega_debrid_cooldown:-Praefix) und isMegaDebridTransientResolveFailure und
landete im generischen isUnrestrictFailure-Zweig — nur weil der Text
"mega-debrid" enthaelt. Folge: das Item wurde den ganzen Tag etwa alle zwei
Minuten neu versucht (120s-Cap) und fuetterte dabei recordProviderFailure den
Provider-Circuit-Breaker, statt einmal bis zum Tagesreset zu parken. Bei
Standard-Einstellungen (retryLimit=0=unendlich) heilte es sich zwar um
Mitternacht selbst (kein Stranding), war aber unnoetige Log-Flut und Churn und
unterlief genau die untilRestart-Park-Absicht aus Runde 3.

Cross-Layer-Synthese-Pass (adversarisch verifiziert) hat parallel die
Advisor-Hypothese eines Key-Mismatch (Manager cool't aufgeloesten
"megadebrid-api", Fallback-Suche liest rohen "megadebrid") WIDERLEGT:
normalizeProviderOrder speichert immer den aufgeloesten Key, also faellt
Write/Check/Clear/Read auf denselben Key — der Routing-Fix (986fbab) ist fuer
den Mega-Fall nachweislich sicher.

Fix (konservativ, spiegelt den bereits korrekten mega_debrid_cooldown-Zweig):
- debrid.ts emittiert beim untilRestart-Park jetzt den Token
  mega_debrid_reset_park:<msBisReset>: (Delay aus megaDebridDailyParkExpiry,
  deckt sich exakt mit dem in-memory Park-Ablauf).
- download-manager.ts: neue reine parseMegaDebridResetPark (kein 15min-Clamp,
  26h-Cap) + Catch-Branch VOR der Cooldown-Klassifikation, der bis zum
  Tagesreset queued OHNE recordProviderFailure (ein geplanter Park ist kein
  Provider-Fehler).
- Token enthaelt weiter "mega_debrid" → jeder Fall-through landet schlimmstenfalls
  im heutigen Verhalten (sichere Untergrenze).

Tests: 5 neue (parseMegaDebridResetPark: parst/embedded/kein-15min-Clamp/Cap+Junk,
plus cooldown-Parser ignoriert den neuen Token) + die all-parked debrid-Assertion
prueft jetzt den Token. Volle Suite 881 gruen, tsc unveraendert.
2026-06-17 10:16:16 +02:00
Sucukdeluxe
03c908bd30 Test: No-Stranding-Gate fuer Provider-Fallback-Routing + Doku-Praezisierung
findFallbackProviderNotInCooldown-Charakterisierungstest sichert den Invariant
ab, auf dem der Routing-Fix (986fbab) seine Sicherheit aufbaut: bei kein
Cooldown den ersten Provider, bei einem Cooldown den naechsten gesunden, und
NULL erst wenn ALLE Provider im Cooldown sind. Dieses null ist der Punkt, an
dem der Aufrufer queueRetry statt Reorder macht — wuerde der Helper hier je
einen abgekuehlten Provider als letzten Notnagel zurueckgeben, liefe der
Reorder am Sicherheitsnetz vorbei. Der Test friert das ein.

Zusaetzlich: entscheidungen-offen.md praezisiert, dass Option A des Failover-
Timeouts ein Drehregler ueber den Pro-Provider-Wert ist (30s/90s, 60s/180s),
kein fixer Wert.
2026-06-17 06:31:37 +02:00
Sucukdeluxe
986fbab04f Fix: Provider-Cooldown-Fallback fuehrt die Kette jetzt wirklich mit dem Ersatz-Provider an (Routing-Slice)
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).
2026-06-17 06:23:44 +02:00
8 changed files with 418 additions and 9 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.217", "version": "1.7.218",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -1321,6 +1321,13 @@ function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvi
return uniqueProviderOrder(order); 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 { function isRapidgatorLink(link: string): boolean {
try { try {
const hostname = new URL(link).hostname.toLowerCase(); const hostname = new URL(link).hostname.toLowerCase();
@ -2158,7 +2165,8 @@ class MegaDebridClient {
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`); throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
} }
if (parkedUntilRestartSeen) { if (parkedUntilRestartSeen) {
throw new Error(`Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`); const resetParkMs = Math.max(1000, megaDebridDailyParkExpiry(Date.now()) - Date.now() + 1000);
throw new Error(`mega_debrid_reset_park:${resetParkMs}:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
} }
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar"); throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
} }
@ -3700,7 +3708,7 @@ export class DebridService {
return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`; return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
} }
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> { public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise<ProviderUnrestrictedLink> {
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings); const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
const routing = settings.hosterRouting || {}; const routing = settings.hosterRouting || {};
@ -3777,9 +3785,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) ? uniqueProviderOrder(settings.providerOrder)
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary); : toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
const order = leadProviderChainWith(baseOrder, preferredLeadProvider);
const primary = order[0]; const primary = order[0];
if (!settings.autoProviderFallback) { if (!settings.autoProviderFallback) {

View File

@ -659,6 +659,19 @@ export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: numb
return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() }; return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() };
} }
export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null {
const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is);
if (!match) {
return null;
}
const raw = Number(match[1]);
if (!Number.isFinite(raw) || raw <= 0) {
return null;
}
const delayMs = Math.max(1000, Math.min(26 * 60 * 60 * 1000, raw));
return { delayMs, detail: String(match[2] || "").trim() };
}
function parseDebridLinkTerminalFailure(errorText: string): { kind: "invalid_all" | "no_active_key"; detail: string } | null { function parseDebridLinkTerminalFailure(errorText: string): { kind: "invalid_all" | "no_active_key"; detail: string } | null {
const raw = String(errorText || ""); const raw = String(errorText || "");
const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i); const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i);
@ -8771,6 +8784,7 @@ export class DownloadManager extends EventEmitter {
} }
const cooldownProvider = this.getProviderFailureKeyForItem(item); const cooldownProvider = this.getProviderFailureKeyForItem(item);
const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider); const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider);
let preferredLeadProvider: DebridProvider | null = null;
if (cooldownMs > 0) { if (cooldownMs > 0) {
if (this.settings.autoProviderFallback) { if (this.settings.autoProviderFallback) {
const fallback = this.findFallbackProviderNotInCooldown(item); const fallback = this.findFallbackProviderNotInCooldown(item);
@ -8782,6 +8796,7 @@ export class DownloadManager extends EventEmitter {
fallback fallback
}); });
item.provider = null; item.provider = null;
preferredLeadProvider = fallback;
} else { } else {
this.logPackageForItem(item, "WARN", "Provider-Cooldown blockiert Unrestrict", { this.logPackageForItem(item, "WARN", "Provider-Cooldown blockiert Unrestrict", {
provider: cooldownProvider, provider: cooldownProvider,
@ -8825,7 +8840,7 @@ export class DownloadManager extends EventEmitter {
traceConversionNote("slots", this.describeSlotOccupancy()); traceConversionNote("slots", this.describeSlotOccupancy());
traceConversionNote("retry", Number(active.unrestrictRetries || 0)); traceConversionNote("retry", Number(active.unrestrictRetries || 0));
try { try {
return await this.debridService.unrestrictLink(item.url, unrestrictedSignal); return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider);
} catch (innerError) { } catch (innerError) {
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
traceConversionPhase({ traceConversionPhase({
@ -9150,6 +9165,20 @@ export class DownloadManager extends EventEmitter {
const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0); const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
if (totalFailures >= 15) { if (totalFailures >= 15) {
item.retries += 1; item.retries += 1;
if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit) {
item.status = "failed";
item.lastError = stallErrorText || "Wiederholt fehlgeschlagen";
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
this.recordRunOutcome(item.id, "failed");
this.retryStateByItem.delete(item.id);
const shelveFailPkgStall = this.session.packages[item.packageId];
if (shelveFailPkgStall) this.refreshPackageStatus(shelveFailPkgStall);
this.persistSoon();
this.emitState();
return;
}
active.stallRetries = Math.floor((active.stallRetries || 0) / 2); active.stallRetries = Math.floor((active.stallRetries || 0) / 2);
active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2); active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2); active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
@ -9336,6 +9365,20 @@ export class DownloadManager extends EventEmitter {
const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0); const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
if (totalNonStallFailures >= 15) { if (totalNonStallFailures >= 15) {
item.retries += 1; item.retries += 1;
if (configuredRetryLimit > 0 && item.retries >= configuredRetryLimit) {
item.status = "failed";
item.lastError = errorText || "Wiederholt fehlgeschlagen";
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
this.recordRunOutcome(item.id, "failed");
this.retryStateByItem.delete(item.id);
const shelveFailPkgErr = this.session.packages[item.packageId];
if (shelveFailPkgErr) this.refreshPackageStatus(shelveFailPkgErr);
this.persistSoon();
this.emitState();
return;
}
active.stallRetries = Math.floor((active.stallRetries || 0) / 2); active.stallRetries = Math.floor((active.stallRetries || 0) / 2);
active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2); active.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2); active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
@ -9393,6 +9436,23 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
const megaResetPark = parseMegaDebridResetPark(errorText);
if (megaResetPark && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
logger.warn(`Mega-Debrid bis Tagesreset gesperrt: item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaResetPark.delayMs}ms, link=${item.url.slice(0, 80)}`);
this.queueRetry(
item,
active,
megaResetPark.delayMs,
`Mega-Debrid bis Tagesreset gesperrt, Pause ${Math.ceil(megaResetPark.delayMs / 1000)}s`
);
item.lastError = megaResetPark.detail || errorText;
this.persistSoon();
this.emitState();
return;
}
const megaCooldownRetry = parseMegaDebridCooldownRetry(errorText); const megaCooldownRetry = parseMegaDebridCooldownRetry(errorText);
if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) { if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1; active.unrestrictRetries += 1;

View File

@ -106,6 +106,96 @@ 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. - #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. - 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, Drehregler ueber Pro-Provider-Wert) 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).
### SYNTHESE-Pass (Cross-Layer: Manager-Cooldown-Keys vs Debrid Account/Daily-Park) — Workflow wrfxpjudj
2 Kandidaten, 1 confirmed (3/3), 1 refuted (0/3). Advisor-Hypothese (Key-Mismatch) WIDERLEGT.
- **REFUTED (0/3) KSM-1 Key-Mismatch:** normalizeProviderOrder (storage.ts:144/411) speichert IMMER
den aufgeloesten 'megadebrid-api'/'-web', NIE den virtuellen 'megadebrid'. Also fallen
Cooldown-WRITE (recordProviderFailure), CHECK (getProviderFailureKeyForItem), CLEAR und READ
(findFallbackProviderNotInCooldown) auf denselben aufgeloesten Key → KEINE Divergenz. Der
986fbab-Routing-Fix ist fuer den Mega-Fall nachweislich sicher (No-Stranding haelt).
- **CONFIRMED (3/3) LOW MEGA-UNTILRESTART-MISCLASS (GEFIXT):** Der untilRestart-Park-Throw
(debrid.ts:2167) trug KEINEN Maschinen-Token → Manager-Catch klassifiziert ihn als generischen
Unrestrict-Fehler (isUnrestrictFailure matcht "mega_debrid") → Retry alle ~2min den ganzen Tag
+ recordProviderFailure (Circuit-Breaker-Verschmutzung), statt einmal bis Tagesreset zu parken.
Default-Config (retryLimit=0=∞) self-heilt um Mitternacht, KEIN Stranding. Genau die
Round-3-untilRestart-Park-Absicht, die hier unterlaufen wurde. Predates 986fbab.
Fix: debrid.ts emittiert jetzt `mega_debrid_reset_park:<msBisReset>:` (megaDebridDailyParkExpiry);
neue reine parseMegaDebridResetPark (kein 15min-Clamp, 26h-Cap) + Catch-Branch VOR der
Cooldown-Klassifikation queued bis Tagesreset OHNE recordProviderFailure. Rot-bewiesen
(Parser-Tests + debrid-Token-Assertion). tsc=6.
- **End-to-end-Verdikt:** Retry/Cooldown/Rotation/Failover ist unter Default-Config kohaerent
self-heilend (jeder Park hat Zeitgrenze + Auto-Clear). Einzige Rest-Inkohaerenz war die
Layering-Naht oben (in-memory untilRestart-Park unsichtbar fuer getAvailableMegaDebridAccounts
+ isProviderDailyLimited) — mit dem konservativen String-Klassifikations-Fix geschlossen.
Der breitere Fix (Selektierbarkeit cooldown-aware machen) bewusst NICHT gemacht (groesserer
Blast-Radius auf Live-Server).
## Release-Status (Stand Runde 5)
Round-4-Fixes (986fbab MED Routing + 03c908b No-Stranding-Test + f1e35f5 LOW Mega-Park) committet,
RELEASE BEWUSST AUFGESCHOBEN: beide MED/LOW (kein HIGH wie 216/217) → buendeln statt Thin-Release
(weniger Live-Server-Churn). Zusaetzlich Advisor (designierter Check vor Live-Deploy) ueber viele
Retries ueberlastet → kein Sign-off fuer den Mega-Park-Hot-Path-Klassifikations-Change. Fixes sind
durable. Plan: Runde 6 dazu buendeln, Advisor-Sign-off abwarten, dann v1.7.218 als Roll-up.
## Runde 6 (Retry/Backoff/Error-Klassifikations-State-Machine + Disk/IO) — Workflow wqurq83ma
3 Finder + adversarisch verifizieren. 1 confirmed (2/3), 3 refuted. Schliesst den deferred-LOW-Cluster ab.
- **CONFIRMED HIGH (GEFIXT) SHELVE-LOOP-RETRYLIMIT:** Bei FINITEM retryLimit>=5 sind alle drei Per-Klasse-
Caps = retryLimit; die 15-Failure-Shelve-Zweige (dl-mgr 9166 stall + 9352 error) feuern aber auf
hartkodiertem `sum>=15` OBERHALB der Per-Klasse-Terminal-Fails und HALBIEREN danach alle Counter →
Per-Klasse-Caps werden nie gleichzeitig ueberschritten → Item failt NIE, schleift ewig, item.retries
waechst ueber das konfigurierte Limit, Slot gestrandet, providerFailures.delete besiegt wiederholt den
Circuit-Breaker → Hoster-Hammering. resetStaleRetryState rettet nicht (<=90s-Re-Admit haelt updatedAt
frisch < 10min-Stale). Default retryLimit=0=∞ NICHT betroffen (Shelve ist dort der gewollte Park-Backstop).
Fix: in BEIDEN Shelve-Zweigen vor dem queueRetry `if (configuredRetryLimit > 0 && item.retries >=
configuredRetryLimit)` → terminal failen statt requeue (∞-Modus unberuehrt). Rot-bewiesen (Single-Pass-
Integrationstest: genericErrorRetries=15 vorgeseedet + injizierter Generic-Error → status "failed" statt
requeue). tsc=6.
- **Deferred-LOW-Cluster re-klassifiziert (aus Runden 1-2):** #10 HTTP416-shared-counter BENIGN (maxHttp416Retries
eigenes Budget, speist Shelve-Summe NICHT); #11 fresh-retry-preempt BENIGN (One-Shot-Booleans);
STALL-CAP-OFF-BY-ONE real aber +1 (Zutat von CONFIRMED-1, kein eigener Strand); #13 queue-wait→elapsedMs
BENIGN (queueRetry resettet attempts/updatedAt, Stall-Detektor misst nur aktiven Download). #12 = der
gefixte Bug. DISK-1 (ENOSPC/EACCES via Generic-Budget) kein eigener Bug (in finite vom Cap + diesem Fix
begrenzt; in ∞ by-design) → optionale Klassifikations-Erweiterung, deferred bis Nutzer Full-Disk-Hammering meldet.
## Runde 5 (unberuehrte Subsysteme: auto-reconnect, Scheduler-Fairness, Crash/Persistenz) — Workflow w7woztsxx
3 Finder (reconnect / scheduler / persistence) → adversarisch verifizieren (3 Lenses). 1 Kandidat, 0 confirmed.
**Sauberes „kein bestaetigter Bug"-Ergebnis.** Auto-reconnect-Resume, Slot-Accounting/Fairness und
Crash-Recovery/targetPath-Lifecycle sind solide (jeder Park/Slot hat Auto-Clear, stop/reset/abort drainen
Waiter + nullen Counter).
- **DOKUMENTIERT, nicht gefixt (LOW, benign): PP-SEM-1** Post-Process-Semaphore (acquire/releasePostProcessSlot,
~7269-7304). Mechanismus real: ein geweckter Waiter, dem ein Fast-Path-Queue-Jumper im Microtask-Gap den
Slot klaut, laeuft trotzdem (Re-Check nach next.resolve() nicht autoritativ) und released spaeter einen nie
inkrementierten Slot → packagePostProcessActive UNTER-zaehlt um 1/Vorkommen. Wirkung: **Ueber-Admission**
(mehr als maxParallelExtract parallele Extraktionen) = gebundene CPU/IO-Verschwendung, NIE Strand/Loss/
Korruption. Der einzige Strand-Pfad (`<=0`-Guard skippt Waiter-Wake) war NICHT organisch erreichbar (nur
durch manuelles Nullen des Counters). Self-heilt bei jedem stop/reset/drain. Fix bekannt (Re-Check
autoritativ machen ODER Increment in den Releaser ziehen) + rot-testbar, aber benign → kein Live-Server-
Hotfix wert (LOW-Fix-Schwelle: Wirkung ist Verschwendung, keine Korrektheit).
## Runde 2 (Download-Ausfuehrung: stream/resume/disk/integrity/extract/persist) — Workflow whspc8ddv ## 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 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. verifiziert (Zeilen zitiert) bevor gefixt. Jeder Fix mit rot-bewiesenem Test, tsc bleibt 6.

View File

@ -0,0 +1,93 @@
# 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 Budget.
Failover bekommt IMMER einen echten Versuch.
*Kosten:* Worst-Case-Wartezeit pro Item steigt. Das Budget ist dabei ein DREHregler, kein
fixer Wert: 60s/Provider = bis 180s Worst-Case (3 Provider), 30s/Provider = bis 90s usw.
Du akzeptierst langsameres Worst-Case-pro-Item für vollständigeres Failover — und stellst
über den Pro-Provider-Wert ein, wie viel langsamer.
- **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)
- **Endlos-Wiederholung bei festem Wiederholungslimit behoben:** Wenn du ein FESTES Retry-Limit
(z.B. 5) eingestellt hattest UND ein Link sprunghaft verschiedene Fehlerarten produzierte
(mal Umwandlungs-Timeout, mal Abbruch mitten im Download, mal allgemeiner Fehler), konnte ein
Eintrag in einer Endlosschleife hängen: eine interne „Viele-Fehler"-Pause halbierte die Zähler,
sodass das eingestellte Limit nie erreicht wurde — der Eintrag scheiterte nie, blockierte dauerhaft
einen Download-Slot und hämmerte den Anbieter (weil dabei auch die Anbieter-Sperre zurückgesetzt
wurde). Jetzt wird das von dir eingestellte Limit hart eingehalten: nach N Versuchen scheitert der
Eintrag sauber. Standard-Einstellung („unendlich", der Auslieferungs-Default) war nie betroffen.
Rot-bewiesener Test.
- **Mega „bis Tagesreset gesperrt" parkt jetzt wirklich (statt alle 2 min neu zu versuchen):**
Wenn ALLE Mega-Accounts wegen wiederholt leerer Antworten bis zum Tagesreset geparkt waren,
hat das Tool den Fehler bisher als normalen Umwandlungsfehler behandelt und den ganzen Tag
alle ~2 Minuten neu probiert (und dabei den Provider-Circuit-Breaker mit Fehlern vollgemüllt).
Jetzt erkennt es den Park und legt das Paket EINMAL bis zum Tagesreset schlafen — genau das,
was der „bis Tagesreset"-Park eigentlich erreichen sollte. Bei Standard-Einstellungen heilte
sich das vorher schon um Mitternacht selbst (kein Datenverlust), war aber unnötige Log-Flut
und Churn. Rot-bewiesener Test.
- **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.)

View File

@ -3,7 +3,7 @@ import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; 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; const originalFetch = globalThis.fetch;
@ -15,6 +15,21 @@ afterEach(() => {
vi.restoreAllMocks(); 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", () => { describe("debrid service", () => {
it("falls back to Mega web when Real-Debrid fails", async () => { it("falls back to Mega web when Real-Debrid fails", async () => {
const settings = { 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); 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<Response> => {
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 () => { 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"); const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
let usedAuthHeader = ""; let usedAuthHeader = "";
@ -1974,7 +2027,7 @@ describe("debrid service", () => {
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000); }, 20000);
it("fails terminally (no retry timer) when ALL Mega-Debrid accounts are parked until restart", async () => { it("emits an until-Tagesreset park token (so the manager parks, not 2min-retries) when ALL Mega-Debrid accounts are parked until restart", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@ -2001,7 +2054,10 @@ describe("debrid service", () => {
const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 })); const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 }));
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await expect(service.unrestrictLink("https://rapidgator.net/file/all-parked-test")).rejects.toThrow(/bis zum Tagesreset gesperrt/i); const err = await service.unrestrictLink("https://rapidgator.net/file/all-parked-test").then(() => null, (e: unknown) => e as Error);
expect(err).toBeInstanceOf(Error);
expect(err!.message).toMatch(/bis zum Tagesreset gesperrt/i);
expect(err!.message).toMatch(/mega_debrid_reset_park:\d+:/);
expect(megaWeb).not.toHaveBeenCalled(); expect(megaWeb).not.toHaveBeenCalled();
}, 20000); }, 20000);

View File

@ -229,6 +229,78 @@ describe("download manager", () => {
expect(historyEntries[0]?.downloadedBytes).toBe(90 * 1024 * 1024); expect(historyEntries[0]?.downloadedBytes).toBe(90 * 1024 * 1024);
}); });
it("findFallbackProviderNotInCooldown routes around cooled providers and returns null only when all are cooled", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fallback-route-"));
tempDirs.push(root);
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
debridLinkApiKeys: "dl-token",
providerOrder: ["realdebrid", "debridlink"],
providerPrimary: "realdebrid",
providerSecondary: "debridlink",
providerTertiary: "none",
autoProviderFallback: true
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
const item = { id: "fb-item", url: "https://hoster.example/file.bin", provider: "realdebrid" } as any;
expect((manager as any).findFallbackProviderNotInCooldown(item)).toBe("realdebrid");
(manager as any).applyProviderBusyBackoff("realdebrid", 60000);
expect((manager as any).findFallbackProviderNotInCooldown(item)).toBe("debridlink");
(manager as any).applyProviderBusyBackoff("debridlink", 60000);
expect((manager as any).findFallbackProviderNotInCooldown(item)).toBeNull();
});
it("fails an item terminally instead of looping forever when the shelve fires under a finite retryLimit", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shelve-loop-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const packageId = "shelve-pkg";
const itemId = "shelve-item";
const outputDir = path.join(root, "downloads", "Shelve");
const extractDir = path.join(root, "extract", "Shelve");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(extractDir, { recursive: true });
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId, name: "Shelve", outputDir, extractDir,
status: "downloading", itemIds: [itemId], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items[itemId] = {
id: itemId, packageId, url: "https://hoster.example/flaky.bin", provider: "realdebrid",
status: "downloading", retries: 5, speedBps: 0, downloadedBytes: 0, totalBytes: null,
progressPercent: 0, fileName: "flaky.bin", targetPath: "", resumable: true,
attempts: 0, lastError: "", fullStatus: "", createdAt: Date.now(), updatedAt: Date.now()
} as any;
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", retryLimit: 5, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session,
storagePaths
);
(manager as any).retryStateByItem.set(itemId, { freshRetryUsed: true, resumeHardResetUsed: true, stallRetries: 0, genericErrorRetries: 15, unrestrictRetries: 0 });
(manager as any).debridService.unrestrictLink = async () => { throw new Error("Download-Backend Fehler xyz"); };
const active = { itemId, packageId, abortController: new AbortController(), abortReason: "none", resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0 };
(manager as any).activeTasks.set(itemId, active);
await (manager as any).processItem(active);
expect(session.items[itemId].status).toBe("failed");
expect(session.items[itemId].retries).toBeLessThan(20);
});
it("keeps the quick post-process requeue once the final package items are finished", () => { it("keeps the quick post-process requeue once the final package items are finished", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-final-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-final-"));
tempDirs.push(root); tempDirs.push(root);

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry } from "../src/main/download-manager"; import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark } from "../src/main/download-manager";
describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => { describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
it("starts fast (<= 3s) instead of the 5s..120s exponential", () => { it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
@ -57,4 +57,33 @@ describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay
expect(parseMegaDebridCooldownRetry("Datei beim Hoster gerade nicht abrufbar")).toBeNull(); expect(parseMegaDebridCooldownRetry("Datei beim Hoster gerade nicht abrufbar")).toBeNull();
expect(parseMegaDebridCooldownRetry("debrid_link_cooldown:5000:x")).toBeNull(); expect(parseMegaDebridCooldownRetry("debrid_link_cooldown:5000:x")).toBeNull();
}); });
it("does NOT swallow the until-Tagesreset park token", () => {
expect(parseMegaDebridCooldownRetry("mega_debrid_reset_park:43200000:Alle Accounts bis zum Tagesreset gesperrt")).toBeNull();
});
});
describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => {
it("parses the encoded until-reset delay from the park token", () => {
const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)");
expect(r).not.toBeNull();
expect(r!.delayMs).toBe(43200000);
expect(r!.detail).toContain("bis zum Tagesreset gesperrt");
});
it("parses it when embedded in the aggregated provider-chain error", () => {
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_reset_park:7200000:Alle Accounts bis zum Tagesreset gesperrt";
expect(parseMegaDebridResetPark(aggregated)!.delayMs).toBe(7200000);
});
it("is NOT clamped to the 15min cooldown ceiling (can park multiple hours)", () => {
expect(parseMegaDebridResetPark("mega_debrid_reset_park:21600000:x")!.delayMs).toBe(21600000);
});
it("clamps to a sane [1s, 26h] window and rejects junk", () => {
expect(parseMegaDebridResetPark("mega_debrid_reset_park:1:x")!.delayMs).toBe(1000);
expect(parseMegaDebridResetPark("mega_debrid_reset_park:999999999999:x")!.delayMs).toBe(26 * 60 * 60 * 1000);
expect(parseMegaDebridResetPark("mega_debrid_cooldown:20330:x")).toBeNull();
expect(parseMegaDebridResetPark("kein Token hier")).toBeNull();
});
}); });