Compare commits
7 Commits
bc50e4285d
...
bbb935589e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb935589e | ||
|
|
d2a1b831a0 | ||
|
|
e49ed1ada0 | ||
|
|
f375fe5a2e | ||
|
|
f1e35f5f41 | ||
|
|
03c908bd30 | ||
|
|
986fbab04f |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.7.217",
|
||||
"version": "1.7.218",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -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();
|
||||
@ -2158,7 +2165,8 @@ class MegaDebridClient {
|
||||
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
|
||||
}
|
||||
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");
|
||||
}
|
||||
@ -3700,7 +3708,7 @@ export class DebridService {
|
||||
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 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)
|
||||
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
|
||||
const order = leadProviderChainWith(baseOrder, preferredLeadProvider);
|
||||
|
||||
const primary = order[0];
|
||||
if (!settings.autoProviderFallback) {
|
||||
|
||||
@ -659,6 +659,19 @@ export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: numb
|
||||
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 {
|
||||
const raw = String(errorText || "");
|
||||
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 cooldownMs = this.getProviderCooldownRemaining(cooldownProvider);
|
||||
let preferredLeadProvider: DebridProvider | null = null;
|
||||
if (cooldownMs > 0) {
|
||||
if (this.settings.autoProviderFallback) {
|
||||
const fallback = this.findFallbackProviderNotInCooldown(item);
|
||||
@ -8782,6 +8796,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 +8840,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({
|
||||
@ -9150,6 +9165,20 @@ export class DownloadManager extends EventEmitter {
|
||||
const totalFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
|
||||
if (totalFailures >= 15) {
|
||||
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.unrestrictRetries = Math.floor((active.unrestrictRetries || 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);
|
||||
if (totalNonStallFailures >= 15) {
|
||||
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.unrestrictRetries = Math.floor((active.unrestrictRetries || 0) / 2);
|
||||
active.genericErrorRetries = Math.floor((active.genericErrorRetries || 0) / 2);
|
||||
@ -9393,6 +9436,23 @@ export class DownloadManager extends EventEmitter {
|
||||
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);
|
||||
if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) {
|
||||
active.unrestrictRetries += 1;
|
||||
|
||||
@ -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.
|
||||
- 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
|
||||
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.
|
||||
|
||||
93
tasks/entscheidungen-offen.md
Normal file
93
tasks/entscheidungen-offen.md
Normal 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.)
|
||||
@ -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<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 () => {
|
||||
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
||||
let usedAuthHeader = "";
|
||||
@ -1974,7 +2027,7 @@ describe("debrid service", () => {
|
||||
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||
}, 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 = {
|
||||
...defaultSettings(),
|
||||
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 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();
|
||||
}, 20000);
|
||||
|
||||
|
||||
@ -229,6 +229,78 @@ describe("download manager", () => {
|
||||
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", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-final-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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)", () => {
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user