Compare commits

..

29 Commits

Author SHA1 Message Date
Sucukdeluxe
a8c4dcc69b Release v1.7.232 2026-06-23 16:13:49 +02:00
Sucukdeluxe
93a85a0255 HTTP 416: erschoepftes Range-Konflikt-Item macht bounded Clean-Restart statt dauerhaft zu haengen
Nutzer-Report (ultracode-Diagnose, 6-Agenten): ein Item mit "Fehler: HTTP 416"
(Range Not Satisfiable) wurde gar nicht mehr erneut versucht und blockierte das
ganze Paket ("Entpacken - Ausstehend" bei allen fertigen Geschwistern). Befund
code-belegt: nach dem In-Run-416-Budget (maxHttp416Retries) lief das Item in
einen Endzustand:
- retryLimit>0 (bare "HTTP 416", directLinkRetryMatch=false): direkt terminal
  "failed" (download-manager ~9442). Der einzige Re-Retry-Pfad
  recoverRetryableItems() laeuft NUR bei Boot/Start, nie mid-run -> mit
  autoReconnect=false bleibt es bis zum Neustart "failed". Und da jeder
  Extract-Zweig auf failed===0 gated, friert EIN failed-Item das ganze Paket
  ein -> exakt das Screenshot-Symptom.
- retryLimit=0 (wrapped "direct_link_retry_exhausted:HTTP 416"): faellt nach dem
  416-Budget in den generischen Retry (9349) mit maxGenericErrorRetries=
  MAX_SAFE_INTEGER -> unbegrenzte futile Re-Unrestricts (haemmert den Hoster,
  Item kommt nie weiter).

Fix: bei erschoepftem 416-Budget an BEIDEN Stellen (gewrappt @9320, bare @9442)
nicht mehr terminalisieren bzw. endlos generisch retrien, sondern via neuem
escalateHttp416OrFail() einen kompletten Neu-Download von 0 anstossen: Partial
loeschen (rmSync), Bytes/Progress/Budget zuruecksetzen, mit Delay re-queuen. Da
416 ein abgelehnter Resume-Range ist, ist der korrekte Weg ein frischer
Download ohne Range-Header (kein 416 mehr) statt erneuter Resume. Hart gedeckelt
ueber http416FreshRestartByItem (MAX_HTTP416_FRESH_RESTARTS=2, retryLimit-
unabhaengig) -> ein wirklich toter Link faellt nach begrenzten sauberen
Neustarts terminal, kein Endlos-Hammering. Zaehler wird in start() und beim
terminalen Fail geleert. Fresh-Restart-Delay via RD_HTTP416_FRESH_RESTART_DELAY_MS
testbar.

Sobald das Item den failed-Zustand verlaesst, entsperrt die bestehende
failed===0-Logik das Paket-Entpacken von selbst. (Die optionale Haertung
"fertige Archiv-Sets trotz failed-Geschwister entpacken" + "Voll-Datei-416 als
fertig werten statt neu laden" sind bewusst als Folgeschritt offen.)

Tests: Recovery (416 erschoepft -> Clean-Restart -> Item completed, retryLimit>0
bare) + Bounded-Fail (persistentes 416 -> begrenzte Neustarts -> failed,
retryLimit=0 wrapped, terminiert statt Endlos). Suite 945 gruen, tsc=6.
2026-06-23 16:12:52 +02:00
Sucukdeluxe
22926d63e3 Release v1.7.231 2026-06-23 13:43:04 +02:00
Sucukdeluxe
88993bccaa Backup-Export: Standard-Dateiname auf TT-MM-JJJJ-mdd-backup.mdd umgestellt
Bisher schlug der Speichern-Dialog "mdd-backup-2026-06-23.mdd" vor (Jahr-Monat-
Tag). Auf Nutzer-Wunsch jetzt "23-06-2026-mdd-backup.mdd" (Tag-Monat-Jahr vorn).
Gleiche UTC-Datumsquelle, nur Reihenfolge gedreht und das Datum nach vorn.
2026-06-23 13:42:16 +02:00
Sucukdeluxe
72b081d749 Release v1.7.230 2026-06-22 03:33:40 +02:00
Sucukdeluxe
c541f1cc92 Mega-Debrid: nicht mehr nach 3 transienten Leer-Antworten faelschlich aufs Tageslimit sperren
Live-Ferndiagnose zeigte: beide Mega-Accounts wurden nach ~363 Downloads
(~800GB) "bis zum Tagesreset" gesperrt, obwohl das echte Limit hoeher liegt
(Nutzer: frueher ~1.3-1.4TB ueber Mega-Web). Ursache: die Web-Aufloesung
liefert eine leere Antwort, wenn Rapidgator gerade keinen Server hat (transient)
ODER ein Link offline/tot ist - beides per-Link/per-Hoster, KEIN Account-
Tageslimit. Der Account loeste zwischendurch nachweislich weiter Links auf
(result=OK), war also nicht erschoepft. Schon 3 solcher Leer-Antworten in Folge
eskalierten aber zur harten 21h-Sperre ("Tageslimit erreicht"), die dann die
ganze Liste lahmlegte und den Account ~500GB vor seinem echten Limit stoppte.

Fix: die Schwelle fuer die "bis zum Tagesreset"-Sperre von 3 auf 10 aufeinander-
folgende Leer-Antworten angehoben. Unterhalb der Schwelle greift weiter der
bestehende 20s-Cooldown (schnelles Re-Probe), und jede erfolgreiche Aufloesung
setzt die Zaehlung zurueck - so ueberstehen verstreute transiente Leer-Antworten
bzw. einzelne tote Links die Sperre nicht mehr, und der Account laeuft bis zu
seinem echten Limit. Eine wirklich anhaltende Leere (10x ohne einen Erfolg
dazwischen = echte Erschoepfung) sperrt weiterhin bis zum Tagesreset.

OFFEN (Folgeschritt, bewusst getrennt): tote/offline Rapidgator-Links auf dem
Provider-Ketten-Pfad praezise als "Link offline" kennzeichnen statt als Hoster-/
Tageslimit, und fuer reine Per-Link-Fehler den Account gar nicht erst kuehlen
(braucht Per-Link-Kontext quer durch die Aufloesungs-Kette).
2026-06-22 03:33:00 +02:00
Sucukdeluxe
3acaf178ed Release v1.7.229 2026-06-22 02:37:47 +02:00
Sucukdeluxe
8e0ae77aee Mega-Debrid: schnelle API-Aufloesung darf langsame Web-Aufloesung ueberlappen (Single-Account-Durchsatz)
Bei nur einem Mega-Debrid-Account war die Link-Aufloesung global seriell:
getSerializedValidatingLimit = max(1, nutzbare Accounts) = 1. Da ~die Haelfte
der Rapidgator-Links von der Mega-API faelschlich als "Fichier supprimé"
abgelehnt wird (bestaetigt: die offizielle Mega-Debrid-Download-Station-
Integration nutzt dieselbe getLink-API voellig ohne Fallback), fallen sie auf
den Web-Pfad, der pro Account zwingend single-flight ist (eine Web-Session =
ein Request). Eine langsame/haengende Web-Aufloesung (live 11s gesehen, im
Extremfall bis zum 60s-Timeout) hielt so den einzigen Aufloesungs-Slot und
liess die Download-Slots leerlaufen (live gemessen: active faellt von 8 auf 2,
Tempo von ~183 auf 71 MB/s, obwohl hunderte Items warteten).

Fix: der Scheduler erlaubt jetzt pro Account EINE zusaetzliche gleichzeitige
Aufloesung, solange bereits eine im (langsamen) Web-Pfad steckt - so zieht eine
schnelle API-Aufloesung (~0.6s) an einer haengenden Web-Aufloesung vorbei statt
dahinter zu warten. Es entstehen NIE zwei gleichzeitige API-Aufrufe pro
Account: die Aufweitung greift nur, wenn megaDebridInFlight im Web-Modus aktiv
ist (der erste also nicht mehr in der API-Phase), und der synchron in startItem
gesetzte validating-Status begrenzt die Gesamtzahl race-frei auf 1 API + 1 Web
pro Account. Skaliert mit mehr Accounts auf N+N - der sauberste Hebel bleibt,
weitere Accounts hinzuzufuegen (parallele Web-Queues).

getMegaDebridInFlightCountForMode liest die bestehenden :web-Zaehler;
shouldDelayStartForItem nutzt sie nur zum Aufweiten der Obergrenze, nie zum
Verschaerfen.

Tests: 4 Faelle (Erststart frei / kein zweiter API waehrend API-Phase /
Overlap sobald Web-Phase aktiv / Deckel bei 1 API + 1 Web).
2026-06-22 02:37:03 +02:00
Sucukdeluxe
ce573fe7b6 Release v1.7.228 2026-06-21 23:19:18 +02:00
Sucukdeluxe
39fbc54818 Fix: Start-Konflikt-Waechter friert nach Update nicht mehr die ganze Liste ein
Nach einem Auto-Update-Neustart blieb die komplette Warteschlange stehen
(running=false, hunderte Items wartend), obwohl Auto-Resume aktiv war.
Ursache war der Start-Konflikt-Waechter:

- getStartConflicts() meldete jedes Paket als Konflikt, dessen
  paketspezifischer Entpack-Ordner bereits Dateien enthielt. Ein nur
  teilweise heruntergeladenes Paket hat dort aber seine EIGENEN fertigen
  Teile liegen (z.B. 15 von 26 fertig und entpackt) - das ist kein
  Konflikt, sondern Wiederaufnahme.
- Der Auto-Resume-Waechter war binaer: ein einziger gemeldeter Konflikt
  hat ALLE Pakete blockiert ("Auto-Resume uebersprungen: Start-Konflikte
  erkannt"). Zwei teilfertige Pakete haben so 30 Pakete eingefroren.

Fix:
- Diskriminator in getStartConflicts(): ein Paket mit eigenen fertigen
  ("completed") Items zaehlt nicht mehr als Konflikt - die Dateien im
  Entpack-Ordner sind seine eigene Ausgabe, kein Fremdbestand.
- Selektives Auto-Resume: start() akzeptiert excludePackageIds und laesst
  diese Pakete aus dem Lauf-Set (runPackageIds, auf das der Scheduler
  filtert). beginAutoResume() startet die sauberen Pakete und haelt nur
  echte Konflikte zurueck, statt alles zu blockieren; die zurueckgehaltenen
  Pakete werden im Log namentlich genannt.

Die beiden Aenderungen greifen ineinander: der Diskriminator garantiert,
dass ein als Konflikt gemeldetes Paket keine fertigen Teile hat, also auch
keine ausstehende Nachbearbeitung - das defensive runPackageIds.delete kann
daher keine Entpack-Arbeit haengen lassen.

Tests: Diskriminator (eigene Ausgabe nicht geflaggt / Fremdbestand
geflaggt) plus selektives Resume (ausgeschlossenes Paket bleibt aus dem
Lauf-Set, laeuft nicht an).
2026-06-21 23:17:56 +02:00
Sucukdeluxe
d73b446d68 Release v1.7.227 2026-06-21 21:42:08 +02:00
Sucukdeluxe
5bb04cb920 Test: /providers + /logs/conversion ueber authentifiziertes HTTP
Schliesst die Coverage-Luecke auf dem eigentlichen 227-Deliverable: startet den
Debug-Server, ruft /providers (200, Snapshot-Shape: capturedAtMs, megaDebrid.accounts,
rotationCursor, debridLink.keys) und /logs/conversion (200, lines[], available) ab und
prueft, dass /providers ohne Token 401 liefert. Beweist Endpoint-Wiring + Auth +
Serialisierung + den debrid->debug-server-Import zur Laufzeit.
2026-06-21 21:41:35 +02:00
Sucukdeluxe
4543ac3c1a Ferndiagnose (MCP) erweitert: Live-Provider-Laufzeitzustand + Conversion-Log
Motiviert durch die Mega-Debrid-Cooldown-Diagnose: der account-weite Cooldown musste
aus Log-Arithmetik rekonstruiert werden (alle Fehlerzeilen zeigten ms-genau auf
dieselbe Deadline). Genau diese Sicht-Luecke wird geschlossen.

Neu:
- GET /providers (debrid.ts getProviderRuntimeSnapshot): Live-Laufzeitzustand pro
  Mega-Account / Debrid-Link-Key — AKTIVER Cooldown (untilMs/remainingMs/Grund/
  Kategorie/untilRestart), in-flight-Tiefe, Mega-Rotationscursor + Sticky-Count,
  Empty-Response-Streaks, Debrid-Link Key-/Host-Cooldowns + Runtime-Status. Account-
  Keys sind nicht-umkehrbare Hashes (mda_<fnv1a64>), keine Logins/Tokens.
- GET /logs/conversion: der pro-Item Link-Aufloesungs-Lebenszyklus (Token, API
  getLink, Web, Account-Rotation, Abbrueche mit Zeiten) aus conversion.log.
- Beide auch im /diagnostics-Aggregat (providers + logs.conversion) -> rd_diagnostics
  zeigt die Cooldowns jetzt direkt.
- MCP-Bridge: neues Tool rd_providers + "conversion" in der rd_logs-Enum + README.

Test: getProviderRuntimeSnapshot spiegelt einen geprimten Account-Cooldown
(until/remaining/Grund) und ist vorher null. Suite 935 gruen, tsc 6.
2026-06-21 21:37:52 +02:00
Sucukdeluxe
471e40b87f Release v1.7.226 2026-06-21 21:22:41 +02:00
Sucukdeluxe
26df55f7ba Fix: Mega-Debrid Einzel-Account friert bei 60s-Timeout nicht mehr die ganze Liste ein
Symptom (per Ferndiagnose live verifiziert): Bei nur EINEM Mega-Debrid-Account lief
der Download eine Weile sauber, dann standen schlagartig ALLE Items ~120s im
"Mega-Debrid Cooldown" — obwohl der Account voellig gesund war (andere Links loesten
zeitgleich in 13-18s auf). Jede Cooldown-Zeile zeigte exakt dieselbe Deadline
(20:52:55.901) -> ein einziger account-weiter Cooldown, einmal gesetzt.

Ursache: Laeuft eine Web-Aufloesung laenger als das 60s-Gesamt-Timeout, feuert der
Abbruch-Pfad (debrid.ts) einen 120s-Account-Cooldown. Dessen einziger Zweck (laut
Code-Kommentar) ist, den Retry auf den NAECHSTEN Account rotieren zu lassen. Bei nur
einem Account gibt es keinen naechsten -> stattdessen findet jedes folgende Item den
einzigen Account im Cooldown und wird bis zu 120s geparkt -> die ganze Liste steht.
Ein 60s-Timeout ist ein Signal fuer einen LANGSAMEN LINK, nicht fuer einen
ungesunden Account.

Fix: Der Account-Cooldown wird nur noch gesetzt, wenn es tatsaechlich einen anderen
nutzbaren Account zum Rotieren gibt. Ohne Rotationsziel (Einzel-Account / alle
anderen belegt) wird der Account NICHT mehr eingefroren; stattdessen wird nur der
langsame Link selbst geparkt (mega_debrid_slow_link -> Item-Retry), waehrend alle
anderen Items weiter ueber den gesunden Account laufen. Das Mehr-Account-Verhalten
(Rotation per Account-Cooldown) bleibt unveraendert.

Der baugleiche Debrid-Link-Pfad (Einzel-Key, debrid.ts) ist derselbe Muster-Typ,
aber ein separater, hier nicht genutzter Provider mit eigener Nachbehandlung -
bewusst nicht mitgebuendelt.

Tests: debrid.test.ts (Einzel-Account-Abbruch parkt nur den Link, KEIN
Account-Cooldown, zweites Item loest weiter auf) + unrestrict-retry.test.ts
(parseMegaDebridSlowLinkRetry, keine Token-Kollision). Suite 934 gruen, tsc 6.
2026-06-21 21:21:34 +02:00
Sucukdeluxe
d594c5082b Release v1.7.225 2026-06-19 22:42:42 +02:00
Sucukdeluxe
6cde08dac3 Fix: Komplett leere Liste nach unsauberem Neustart (Datenverlust bei Stromausfall/Crash waehrend Download)
Ursache: Session-Writes (writeFile + atomic rename) liefen ohne fsync. Waehrend eines
Downloads feuert persistSoon alle 700ms-3s, die Session-Datei bleibt damit dauerhaft
dirty im OS-Cache. Bei hartem Stromausfall auf NTFS ist die rename-Metadatentransaktion
journaled (durable), aber die Datenbloecke der temp-Datei sind nicht geflusht -> primary
zeigt nach Reboot auf Null/Garbage. Die .bak-Kopie stammt per copyFileSync aus einer
ebenfalls ungeflushten primary -> ebenfalls korrupt. loadSession faellt durch
primary -> bak -> temp auf emptySession() durch, und der naechste persistSoon speichert
diese leere Session ueber die Platte -> dauerhaft leer. Tritt nur bei UNSAUBEREM Neustart
auf (sauberes Beenden flusht ohnehin).

Fix in drei Schichten:
- Durable atomic write: temp wird vor dem rename gefsynct. Reihenfolge zwingend
  write -> fsync -> close -> rename (NTFS kann eine Datei mit offenem Handle nicht renamen).
  Sync-Pfad via openSync/writeSync/fsyncSync/closeSync, Async-Pfad via FileHandle.sync()
  (laeuft auf dem libuv-Threadpool, blockiert den Hot-Path nicht). Kein Throttle: ein
  throttle-skip wuerde eine ungeflushte temp ueber die durable primary renamen und das
  Korruptionsfenster wieder oeffnen.
- Read-Retry: readSessionFile wiederholt bei transienten Sperren (EBUSY/EPERM/EAGAIN, z.B.
  Virenscanner/Disk-not-ready beim Boot) 5x mit Backoff. EACCES und JSON-Parse-Fehler
  werden nicht wiederholt.
- Empty-Clobber-Guard: loadSessionWithStatus meldet, ob alle Tiers unlesbar waren (Status
  empty-unreadable). In dem Fall blockiert der DownloadManager das Speichern einer leeren
  Session ueber vorhandene Daten, bis wieder echte Daten vorliegen; die erste nicht-leere
  Speicherung hebt den Schutz auf.

Tests: tests/session-restart-loss.test.ts um Status-Klassifizierung, fsync-Nachweis,
async-Roundtrip (close-before-rename), EBUSY-Retry und Guard-Clear-Pfad erweitert.
Suite 929 gruen, tsc unveraendert bei 6 Baseline-Fehlern.
2026-06-19 22:42:05 +02:00
Sucukdeluxe
8f3681b160 Release v1.7.224 2026-06-19 19:49:22 +02:00
Sucukdeluxe
d9657a5459 Ferndiagnose-Backup: auch Full-Backup-Import schreibt die MCP-Dateien
Advisor-Fund: der Settings-only-Import stellte die Ferndiagnose-Einstellungen wieder
her, der Full-Backup-Import (Download-Liste mitgesichert) aber NICHT — er liess die
debug_*-Dateien unangetastet, und der nachfolgende Relaunch bootete mit den alten
Werten. Damit waere "MCP-Settings im Backup" auf dem Full-Backup-Pfad still gescheitert.

Fix: restoreMcpRemoteFromBackup nimmt jetzt restartNow. Settings-only ruft mit true
(laufenden Server sofort neu laden), Full-Backup mit false (nur Dateien schreiben — der
Relaunch-Boot liest sie via startDebugServer). Beide Pfade tragen die Sektion jetzt.

Zusatz-Tests: normalizeSettings bewahrt backupIncludeMcp (der Dreh- und Angelpunkt —
ohne das wuerde der Schalter bei jedem Save auf false zuruecksetzen und das Feature
tot sein); Full-Backup-Write-Pfad schreibt debug_host/port/allowlist auf Platte ohne
Restart (In-Memory bleibt stale → Boot uebernimmt). Suite 919 gruen, tsc=6.
2026-06-19 19:48:44 +02:00
Sucukdeluxe
a35ddf68b2 Ferndiagnose: Allowlist/Port/Modus im Backup mitsichern (Token bleibt pro Server)
Neuer Schalter "Ferndiagnose-Einstellungen mitsichern" (Einstellungen, Default aus,
spiegelt "Download-Liste mitsichern"). Damit reisen die wiederkehrenden, server-
unabhaengigen MCP-Einstellungen im Backup-Export/Import mit, statt sie auf jedem der
5-6 Server neu einzutippen.

Mitgesichert wird NUR was identisch ueber alle Server ist und kein Geheimnis/keine
Identitaet traegt: Allowlist, Port, Freigabemodus (lokal/Netzwerk). Bewusst NICHT
mitgesichert:
- Token: wird pro Server automatisch frisch erzeugt (nie vom Nutzer getippt → kein
  Tipp-Aufwand gespart). Der Backup-Schluessel ist eine fest verdrahtete Konstante →
  ein geleaktes .mdd duerfte sonst Lesezugriff auf ALLE Server geben. Backstop: ohne
  Token ist ein frisch importierter Server inert (checkAuth weist alles ab), bis der
  Nutzer einmal "Aktivieren" drueckt und ein Token erzeugt — keine stille Freigabe.
- Oeffentliche Adresse + Name: pro Server verschieden (sonst zeigte der erzeugte
  Verbindungscode des Zielservers auf den falschen Host).

Mechanik:
- mcpRemote als eigene Top-Level-Sektion im BackupPayload (NICHT in settings —
  normalizeSettings ist ein Whitelist-Rebuild und wuerde unbekannte Keys verwerfen).
  Form {allowlist, port, hostMode} erzwingt die Policy strukturell (kein Token-Feld).
  Kein version-Bump (Import ist lenient, additiv vor-/rueckwaertskompatibel).
- Export sammelt aus getDebugServerRuntimeStatus()+getDebugAllowlist(), nur wenn der
  Schalter an ist; liest bewusst KEIN Token, KEINE Remote-Meta.
- Import wendet die Sektion NUR im Settings-only-Zweig an (writeDebugServerConfig +
  restartDebugServer, damit der laufende Server die Allowlist sofort uebernimmt). Im
  Full-Backup-Zweig nicht — die App relauncht dort, der Boot liest die Dateien neu.
- Sicherheitsguard: Netzwerk-Modus mit LEERER Allowlist bindet local (127.0.0.1) statt
  0.0.0.0 — nie stille Freigabe aus korrupten Daten.
- backupIncludeMcp in AppSettings + defaultSettings + normalizeSettings (sonst wuerde
  der Schalter beim Speichern verworfen).

Tests (backup-mcp.test.ts, 11): Export-Gating + Policy-Guard (Sektion traegt nur
allowlist/port/hostMode), resolveMcpRemoteRestore-Matrix (hostMode-Mapping, Port-
Grenzen, Allowlist-Filter, Netzwerk-ohne-Allowlist→local, null bei fehlend), und der
load-bearing Live-Round-Trip: Export → Resolve → Apply → restartDebugServer →
getDebugAllowlist()/Status spiegeln die importierten Werte (beweist dass der Restart
feuert), Token unangetastet, kein debug_remote.json. Volle Suite 917 gruen, tsc=6.
2026-06-19 19:40:39 +02:00
Sucukdeluxe
c79a031be8 todo: MCP-Ferndiagnose Review (v1.7.223 released, P5 = Nutzer-Acceptance) 2026-06-19 17:26:16 +02:00
Sucukdeluxe
6b52678e7e Bridge-README: MCP-Client-neutral formuliert (kein Tool-spezifischer Name) 2026-06-19 17:19:35 +02:00
Sucukdeluxe
41f2925b13 Release v1.7.223 2026-06-19 17:13:58 +02:00
Sucukdeluxe
95d284f687 Sicherheit: Allowlist anhand des echten Socket-Peers statt X-Forwarded-For
Die Allowlist-Pruefung nutzte extractDebugClientIp, das X-Forwarded-For zuerst
auswertet - ein angreiferkontrollierter Header. Damit konnte ein entfernter Client
auf einem 0.0.0.0-Bind die Allowlist komplett umgehen (X-Forwarded-For: 127.0.0.1 ->
als Loopback gewertet). Die Allowlist bot so keinerlei Schutz; nur das Token blieb.

Fix: Enforcement liest jetzt ausschliesslich req.socket.remoteAddress (getPeerIp),
das vom Kernel aus der TCP-Verbindung gesetzt wird und nicht per Header faelschbar ist.
X-Forwarded-For wird nur noch fuer das Trace-Log (Beobachtbarkeit) verwendet, nie fuer
die Zugriffsentscheidung. Reine, exportierte Matcher-Funktion evaluateClientAllowed.

Tests umgebaut auf den Threat-Model statt den faelschbaren Kanal: Unit-Tests fuer
Loopback/exakt/CIDR/fail-closed + expliziter Forged-XFF-Test (socket.remoteAddress
8.8.8.8 + X-Forwarded-For 127.0.0.1 -> verweigert). Integration: Loopback verbindet,
gespooftes X-Forwarded-For wird ignoriert, Auth bleibt erzwungen, Live-Reload.
2026-06-19 17:10:11 +02:00
Sucukdeluxe
c8beaf96ed Ferndiagnose (MCP): Verbindungscode + abgesicherter Fernzugriff + stdio-Bridge
Neue Funktion, um Diagnose eines laufenden Servers aus der Ferne zu ermoeglichen:
Hilfe -> Remote-Support -> "Ferndiagnose (MCP)". Erzeugt einen Verbindungscode,
den der Assistent nutzt, um Status, Logs, Fehler und Accounts read-only zu lesen.

App-Seite:
- Live (re)startbarer Debug-Server ohne App-Neustart (restartDebugServer wartet auf
  'close' + closeAllConnections, behandelt EADDRINUSE).
- IP-Allowlist (debug_allowlist.txt, exakte IP + CIDR), erzwungen VOR der Auth.
  Fail-closed: Netzwerk-Bind (0.0.0.0) ohne Allowlist akzeptiert nur Loopback.
- One-Click Aktivieren/Aktualisieren/Deaktivieren + Token-Rotation (alter Code sofort
  ungueltig). Sichtbarkeit waehlbar: "Nur lokal" (Tunnel-Empfehlung) vs "Im Netzwerk".
- Verbindungscode rddiag:v1:base64url({v,h,p,t,n?,fp?,s?}); oeffentlicher Host frei
  waehlbar, Netzwerk-IPs als Schnellauswahl.
- Neue IPC: get/enable/disable/rotate Remote-Diagnostics; Controller-Methoden; Typen.

Bridge (tools/rd-diagnostics-mcp, standalone, KEINE App-Dependency):
- stdio MCP-Server (@modelcontextprotocol/sdk) mit 14 Tools, proxyt die bestehende
  HTTP-Debug-API. Multi-Server ueber code/server/RDDIAG_CODE/RDDIAG_SERVERS.
- TLS-Fingerprint-Pinning auf secureConnect (vor Token-Versand), falls https genutzt.
- test/harness.mjs: faehrt einen Fake-Debug-Server hoch und treibt die Bridge als
  echten stdio-Child per JSON-RPC -> voller Protokollpfad gruen.

Sicherheit (Audit): keine persistenten Secrets in den Logs der Debug-API (Passwoerter
redigiert, keine Debrid-Keys/aufgeloesten Download-URLs geloggt; settings/accounts
redigiert). Empfohlener Transport: Loopback + privater Tunnel; Direkt-Bind nur mit
Allowlist in vertrauenswuerdigen Netzen.

Tests: connection-code-Cross-Check (App-Encoder <-> Bridge-Decoder), Allowlist-Matrix
(Loopback, exakt, CIDR, fail-closed, Live-Restart). Volle Suite 905 gruen, tsc=6.
2026-06-19 17:05:28 +02:00
Sucukdeluxe
838bd2ee7a Release v1.7.222 2026-06-17 20:39:09 +02:00
Sucukdeluxe
3d82d00e54 Fix: Mega-Debrid 'Linkgenerierung lieferte kein Ergebnis' nicht mehr als 30s-Cooldown (schneller Transient)
Eine leere Mega-Debrid-API-Antwort (success, aber kein Download-Link ->
debrid.ts:1927 'Linkgenerierung lieferte kein Ergebnis') fiel in
classifyAccountFailure in den Default-Zweig -> 30s Account-Cooldown
('Mega-Debrid Cooldown, neuer Versuch in 31s'). Das ist KEIN Limit-/Quota-Signal,
sondern ein transienter API-Blip, der praktisch sofort wieder funktioniert (vom
Nutzer live beobachtet: danach ging es). Fix: eigener Zweig klassifiziert die leere
Antwort wie die anderen transienten Resolve-Fehler (cooldownMs 0, kein
Account-Cooldown) -> der Download-Manager nimmt den schnellen Transient-Retry-Pfad
(3s, eskaliert auf max 10s) statt 31s zu warten. Kein limitSignal (kein
until-restart-Park). Rot-bewiesener Test (ohne Fix cooldownMs 30000; mit Fix 0 +
Meldung trifft isMegaDebridTransientResolveFailure). Volle Suite 894 gruen, tsc=6.
2026-06-17 20:38:32 +02:00
Sucukdeluxe
50bc608419 Release v1.7.221 2026-06-17 20:24:13 +02:00
Sucukdeluxe
ba144f323b 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).
2026-06-17 20:23:28 +02:00
35 changed files with 4282 additions and 145 deletions

View File

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

View File

@ -1,4 +1,5 @@
import path from "node:path"; import path from "node:path";
import os from "node:os";
import v8 from "node:v8"; import v8 from "node:v8";
import { app } from "electron"; import { app } from "electron";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
@ -9,9 +10,11 @@ import {
DebridAccountStatus, DebridAccountStatus,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
ParsedPackageInput, ParsedPackageInput,
RemoteDiagnosticsInfo,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
@ -36,12 +39,13 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
import { MegaWebFallback } from "./mega-web-fallback"; import { MegaWebFallback } from "./mega-web-fallback";
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { runInstallWithResume } from "./update-install-flow"; import { runInstallWithResume } from "./update-install-flow";
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
import { encryptBackup, decryptBackup } from "./backup-crypto"; import { encryptBackup, decryptBackup } from "./backup-crypto";
import { buildBackupPayload, planBackupImport } from "./backup-payload"; import { buildBackupPayload, planBackupImport, resolveMcpRemoteRestore, BackupMcpRemote } from "./backup-payload";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log"; import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
import { initConversionLog, shutdownConversionLog } from "./conversion-trace"; import { initConversionLog, shutdownConversionLog } from "./conversion-trace";
@ -108,7 +112,8 @@ export class AppController {
initTraceLog(this.storagePaths.baseDir); initTraceLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths); this.settings = loadSettings(this.storagePaths);
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const session = loadSession(this.storagePaths); const loadResult = loadSessionWithStatus(this.storagePaths);
const session = loadResult.session;
this.megaWebFallback = new MegaWebFallback(() => ({ this.megaWebFallback = new MegaWebFallback(() => ({
login: this.settings.megaLogin, login: this.settings.megaLogin,
password: this.settings.megaPassword password: this.settings.megaPassword
@ -122,6 +127,7 @@ export class AppController {
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
protectEmptyClobber: loadResult.status === "empty-unreadable",
onHistoryEntry: (entry: HistoryEntry) => { onHistoryEntry: (entry: HistoryEntry) => {
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits()); addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits());
} }
@ -174,21 +180,13 @@ export class AppController {
if (this.settings.autoResumeOnStart) { if (this.settings.autoResumeOnStart) {
const snapshot = this.manager.getSnapshot(); const snapshot = this.manager.getSnapshot();
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
if (hasPending) { if (hasPending && this.hasAnyProviderToken(this.settings)) {
void this.manager.getStartConflicts().then((conflicts) => {
const hasConflicts = conflicts.length > 0;
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
if (this.onStateHandler) { if (this.onStateHandler) {
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)"); this.beginAutoResume();
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
} else { } else {
this.autoResumePending = true; this.autoResumePending = true;
logger.info("Auto-Resume beim Start vorgemerkt"); logger.info("Auto-Resume beim Start vorgemerkt");
} }
} else if (hasConflicts) {
logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt");
}
}).catch((err) => logger.warn(`getStartConflicts Fehler (constructor): ${String(err)}`));
} }
} }
} }
@ -245,14 +243,27 @@ export class AppController {
handler(this.manager.getSnapshot()); handler(this.manager.getSnapshot());
if (this.autoResumePending) { if (this.autoResumePending) {
this.autoResumePending = false; this.autoResumePending = false;
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`)); this.beginAutoResume();
logger.info("Auto-Resume beim Start aktiviert");
} else { } else {
this.manager.triggerIdleExtractions(); this.manager.triggerIdleExtractions();
} }
} }
} }
private beginAutoResume(): void {
void this.manager.getStartConflicts().then((conflicts) => {
const excludePackageIds = new Set(conflicts.map((conflict) => conflict.packageId));
if (excludePackageIds.size > 0) {
const names = conflicts.map((conflict) => conflict.packageName).join(", ");
logger.info(`Auto-Resume: ${excludePackageIds.size} Paket(e) mit Start-Konflikt zurückgehalten (${names}); übrige Pakete starten`);
} else {
logger.info("Auto-Resume beim Start aktiviert (keine Start-Konflikte)");
}
void this.manager.start(excludePackageIds.size > 0 ? { excludePackageIds } : undefined)
.catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
}).catch((err) => logger.warn(`Auto-Resume Konflikt-Check Fehler: ${String(err)}`));
}
public getSnapshot(): UiSnapshot { public getSnapshot(): UiSnapshot {
return this.manager.getSnapshot(); return this.manager.getSnapshot();
} }
@ -291,6 +302,100 @@ export class AppController {
return rotated; return rotated;
} }
private getSuggestedRemoteHosts(): string[] {
const hosts: string[] = [];
try {
const interfaces = os.networkInterfaces();
for (const entry of Object.values(interfaces)) {
for (const net of entry || []) {
if (net.family === "IPv4" && !net.internal && net.address) {
hosts.push(net.address);
}
}
}
} catch {
}
return [...new Set(hosts)];
}
public getRemoteDiagnostics(): RemoteDiagnosticsInfo {
const status = getDebugServerRuntimeStatus();
const meta = loadRemoteMeta(this.storagePaths.baseDir);
const token = getActiveDebugToken();
const allowlist = getDebugAllowlist();
const suggestedHosts = this.getSuggestedRemoteHosts();
const host = meta.publicHost
|| (status.localOnly ? "127.0.0.1" : (suggestedHosts[0] || status.host));
const code = (status.hasToken && token && host)
? encodeConnectionCode({ host, port: status.port, token, name: meta.name || undefined })
: null;
return {
status,
code,
publicHost: meta.publicHost,
name: meta.name,
allowlist,
suggestedHosts
};
}
public async enableRemoteDiagnostics(input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> {
const baseDir = this.storagePaths.baseDir;
const port = input.port && Number.isInteger(input.port) && input.port >= 1024 && input.port <= 65535
? input.port
: 9868;
const bindHost = input.hostMode === "network" ? "0.0.0.0" : "127.0.0.1";
const allowlist = (input.allowlist || []).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
if (input.hostMode === "network" && allowlist.length === 0) {
throw new Error("Netzwerk-Freigabe erfordert mindestens eine erlaubte IP oder CIDR in der Allowlist.");
}
let token = getActiveDebugToken();
if (!token || input.rotateToken) {
token = rotateDebugToken(baseDir).token;
}
writeDebugServerConfig({ host: bindHost, port, allowlist });
saveRemoteMeta(baseDir, { publicHost: (input.publicHost || "").trim(), name: (input.name || "").trim() });
await restartDebugServer();
this.audit("WARN", "Ferndiagnose aktiviert", {
host: bindHost,
port,
allowlistCount: allowlist.length,
localOnly: input.hostMode === "local"
});
return this.getRemoteDiagnostics();
}
public async disableRemoteDiagnostics(): Promise<RemoteDiagnosticsInfo> {
clearDebugToken();
await restartDebugServer();
this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)");
return this.getRemoteDiagnostics();
}
public async rotateRemoteDiagnosticsToken(): Promise<RemoteDiagnosticsInfo> {
rotateDebugToken(this.storagePaths.baseDir);
await restartDebugServer();
this.audit("WARN", "Ferndiagnose-Token rotiert");
return this.getRemoteDiagnostics();
}
private restoreMcpRemoteFromBackup(section: unknown, restartNow: boolean): void {
const restore = resolveMcpRemoteRestore(section);
if (!restore) {
return;
}
writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist });
if (restartNow) {
void restartDebugServer().catch(() => {});
}
this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", {
port: restore.port ?? null,
allowlistCount: restore.allowlist?.length ?? 0,
host: restore.host ?? "unveraendert",
restartNow
});
}
public getDebugSetupCheck(): DebugSetupCheckResult { public getDebugSetupCheck(): DebugSetupCheckResult {
return getDebugSetupCheck(this.storagePaths.baseDir); return getDebugSetupCheck(this.storagePaths.baseDir);
} }
@ -643,13 +748,22 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
} }
public exportBackup(): Buffer { public exportBackup(): Buffer {
const includeDownloads = Boolean(this.settings.backupIncludeDownloads); let mcpRemote: BackupMcpRemote | undefined;
if (Boolean(this.settings.backupIncludeMcp)) {
const status = getDebugServerRuntimeStatus();
mcpRemote = {
allowlist: getDebugAllowlist(),
port: status.port,
hostMode: status.host === "0.0.0.0" ? "network" : "local"
};
}
const payloadObj = buildBackupPayload({ const payloadObj = buildBackupPayload({
settings: { ...this.settings }, settings: { ...this.settings },
appVersion: APP_VERSION, appVersion: APP_VERSION,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
session: this.manager.getSession(), session: this.manager.getSession(),
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()) history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
mcpRemote
}); });
this.audit("INFO", "Backup exportiert", { this.audit("INFO", "Backup exportiert", {
kind: payloadObj.kind, kind: payloadObj.kind,
@ -723,6 +837,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
this.settings = restoredSettings; this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true }); this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, true);
this.audit("INFO", "Backup importiert (nur Einstellungen)", { this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings) accountSummary: buildAccountSummary(this.settings)
}); });
@ -759,6 +874,8 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, false);
this.manager.skipShutdownPersist = true; this.manager.skipShutdownPersist = true;
this.manager.blockAllPersistence = true; this.manager.blockAllPersistence = true;
logger.info("Backup wiederhergestellt — App startet automatisch neu"); logger.info("Backup wiederhergestellt — App startet automatisch neu");

View File

@ -2,6 +2,12 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
export type BackupKind = "full" | "settings-only"; export type BackupKind = "full" | "settings-only";
export interface BackupMcpRemote {
allowlist: string[];
port: number;
hostMode: "local" | "network";
}
export interface BackupPayload { export interface BackupPayload {
version: 2; version: 2;
kind: BackupKind; kind: BackupKind;
@ -10,6 +16,7 @@ export interface BackupPayload {
settings: AppSettings; settings: AppSettings;
session?: SessionState; session?: SessionState;
history?: HistoryEntry[]; history?: HistoryEntry[];
mcpRemote?: BackupMcpRemote;
} }
export interface BuildBackupInput { export interface BuildBackupInput {
@ -19,6 +26,7 @@ export interface BuildBackupInput {
/** Only bundled when includeDownloads is true. */ /** Only bundled when includeDownloads is true. */
session: SessionState; session: SessionState;
history: HistoryEntry[]; history: HistoryEntry[];
mcpRemote?: BackupMcpRemote;
} }
/** /**
@ -40,9 +48,39 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
base.session = input.session; base.session = input.session;
base.history = input.history; base.history = input.history;
} }
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) {
base.mcpRemote = input.mcpRemote;
}
return base; return base;
} }
export interface McpRemoteRestore {
host?: "127.0.0.1" | "0.0.0.0";
port?: number;
allowlist?: string[];
}
export function resolveMcpRemoteRestore(section: unknown): McpRemoteRestore | null {
if (!section || typeof section !== "object") {
return null;
}
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
const allowlist = Array.isArray(s.allowlist)
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
: undefined;
const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined;
let host: "127.0.0.1" | "0.0.0.0" | undefined;
if (s.hostMode === "network") {
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
} else if (s.hostMode === "local") {
host = "127.0.0.1";
}
if (host === undefined && port === undefined && allowlist === undefined) {
return null;
}
return { host, port, allowlist };
}
export interface ImportPlan { export interface ImportPlan {
valid: boolean; valid: boolean;
/** Restore the download list (session + history) and relaunch. */ /** Restore the download list (session + history) and relaunch. */

View File

@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
const PREFIX = "rddiag:v1:";
function base64urlEncode(value: string): string {
return Buffer.from(value, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export interface ConnectionCodeInput {
host: string;
port: number;
token: string;
name?: string;
scheme?: "http" | "https";
fingerprint?: string;
}
export function encodeConnectionCode(input: ConnectionCodeInput): string {
const host = String(input.host || "").trim();
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
const port = Number(input.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode");
if (!input.token) throw new Error("Token fehlt fuer Verbindungscode");
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
if (input.name) payload.n = String(input.name);
if (input.fingerprint) payload.fp = String(input.fingerprint);
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
return PREFIX + base64urlEncode(JSON.stringify(payload));
}
export interface RemoteMeta {
publicHost: string;
name: string;
}
function remoteMetaPath(baseDir: string): string {
return path.join(baseDir, "debug_remote.json");
}
export function loadRemoteMeta(baseDir: string): RemoteMeta {
try {
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
return {
publicHost: String(parsed.publicHost || ""),
name: String(parsed.name || "")
};
} catch {
return { publicHost: "", name: "" };
}
}
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
}

View File

@ -109,6 +109,7 @@ export function defaultSettings(): AppSettings {
hideExtractedItems: true, hideExtractedItems: true,
confirmDeleteSelection: true, confirmDeleteSelection: true,
backupIncludeDownloads: false, backupIncludeDownloads: false,
backupIncludeMcp: false,
notifyUrl: "", notifyUrl: "",
notifyMention: "", notifyMention: "",
notifyOnPackageCompleted: false, notifyOnPackageCompleted: false,

View File

@ -123,6 +123,11 @@ export function getDebridLinkKeyRuntimeStateForTests(keyId: string): DebridLinkR
return status ? status.state : null; 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 { function clearDebridLinkKeyCooldownState(keyId: string): void {
debridLinkKeyCooldowns.delete(keyId); debridLinkKeyCooldowns.delete(keyId);
debridLinkKeyCooldownDetails.delete(keyId); debridLinkKeyCooldownDetails.delete(keyId);
@ -283,6 +288,7 @@ type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporar
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean }; type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>(); const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000;
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000; const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
// A Mega-Web account abort (the shared unrestrict timeout firing while this // A Mega-Web account abort (the shared unrestrict timeout firing while this
@ -296,7 +302,7 @@ function getMegaDebridAbortMinRunMs(): number {
} }
const megaDebridEmptyResponseStreaks = new Map<string, number>(); const megaDebridEmptyResponseStreaks = new Map<string, number>();
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3; export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;
let megaDebridRotationCursor = 0; let megaDebridRotationCursor = 0;
let megaDebridStickyCount = 0; let megaDebridStickyCount = 0;
@ -333,6 +339,25 @@ export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridInFlight.clear(); megaDebridInFlight.clear();
} }
export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number {
const suffix = `:${mode}`;
let total = 0;
for (const [key, count] of megaDebridInFlight) {
if (key.endsWith(suffix)) {
total += count;
}
}
return total;
}
export function primeMegaDebridInFlightForTests(key: string, count: number): void {
if (count <= 0) {
megaDebridInFlight.delete(key);
return;
}
megaDebridInFlight.set(key, count);
}
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number { export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
let removed = 0; let removed = 0;
const grace = 60 * 60 * 1000; const grace = 60 * 60 * 1000;
@ -417,6 +442,106 @@ export function getMegaDebridAccountCooldownState(
}; };
} }
export interface ProviderRuntimeCooldown {
untilMs: number;
remainingMs: number;
message: string;
category: string;
untilRestart?: boolean;
}
export interface ProviderRuntimeSnapshot {
capturedAtMs: number;
megaDebrid: {
rotationCursor: number;
stickyCount: number;
accounts: Array<{
key: string;
cooldown: ProviderRuntimeCooldown | null;
inFlight: number;
emptyResponseStreak: number;
}>;
};
debridLink: {
keys: Array<{
keyId: string;
cooldown: ProviderRuntimeCooldown | null;
runtimeStatus: { state: string; detail: string; updatedAt: number } | null;
}>;
hostCooldowns: Array<{ key: string; cooldown: ProviderRuntimeCooldown }>;
};
}
export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot {
const megaKeys = new Set<string>([
...megaDebridAccountCooldowns.keys(),
...megaDebridInFlight.keys(),
...megaDebridEmptyResponseStreaks.keys()
]);
const megaAccounts = [...megaKeys].sort().map((key) => {
const detail = megaDebridAccountCooldowns.get(key);
return {
key,
cooldown: detail
? {
untilMs: detail.until,
remainingMs: Math.max(0, detail.until - now),
message: detail.message,
category: detail.category,
untilRestart: detail.untilRestart === true
}
: null,
inFlight: megaDebridInFlight.get(key) ?? 0,
emptyResponseStreak: megaDebridEmptyResponseStreaks.get(key) ?? 0
};
});
const dlKeyIds = new Set<string>([
...debridLinkKeyCooldowns.keys(),
...debridLinkKeyRuntimeStatuses.keys()
]);
const dlKeys = [...dlKeyIds].sort().map((keyId) => {
const until = Number(debridLinkKeyCooldowns.get(keyId) || 0);
const detail = debridLinkKeyCooldownDetails.get(keyId);
const status = debridLinkKeyRuntimeStatuses.get(keyId) || null;
return {
keyId,
cooldown: until > 0
? {
untilMs: until,
remainingMs: Math.max(0, until - now),
message: detail?.message ?? "",
category: detail?.category ?? "temporary"
}
: null,
runtimeStatus: status ? { state: status.state, detail: status.detail, updatedAt: status.updatedAt } : null
};
});
const dlHostCooldowns = [...debridLinkKeyHostCooldowns].map(([key, until]) => {
const detail = debridLinkKeyHostCooldownDetails.get(key);
return {
key,
cooldown: {
untilMs: until,
remainingMs: Math.max(0, until - now),
message: detail?.message ?? "",
category: detail?.category ?? "temporary"
}
};
});
return {
capturedAtMs: now,
megaDebrid: {
rotationCursor: megaDebridRotationCursor,
stickyCount: megaDebridStickyCount,
accounts: megaAccounts
},
debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns }
};
}
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api"; const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
const PROVIDER_LABELS: Record<DebridProvider, string> = { const PROVIDER_LABELS: Record<DebridProvider, string> = {
@ -2063,15 +2188,33 @@ class MegaDebridClient {
} catch (error) { } catch (error) {
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Timeout/abort on THIS account (the shared unrestrict signal fired). Cool // Timeout/abort on THIS account (the shared unrestrict timeout fired). The
// the account down — if it actually ran, not a quick user-cancel — so the // account-wide cooldown exists ONLY to make the retry rotate to another
// download-manager's retry rotates to the NEXT account instead of hammering // account — so it is set only when another usable account actually exists.
// this one. The shared signal is now aborted, so we stop this pass; the // With no rotation target (single account / all others busy), cooling the
// retry runs the rotation fresh with this account skipped. A genuine cancel // sole account would freeze EVERY queued item while the account is healthy;
// is not retried by the caller, so the cooldown is harmless there. // a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so
// we park just this link (mega_debrid_slow_link) and leave the account free
// for other items. A quick user-cancel (below the min run) parks nothing.
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
if (ranLongEnough) { const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
if (candidate.account.id === account.id) {
return count;
}
if (isMegaDebridAccountDisabled(settings, candidate.account.id)) {
return count;
}
if (isMegaDebridAccountDailyLimitReached(settings, candidate.account.id)) {
return count;
}
if (getMegaDebridAccountCooldownState(`${candidate.account.id}:${mode}`)) {
return count;
}
return count + 1;
}, 0);
const rotateToAnotherAccount = ranLongEnough && otherUsableAccounts > 0;
if (rotateToAnotherAccount) {
setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary"); setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
} }
traceConversionPhase({ traceConversionPhase({
@ -2080,15 +2223,18 @@ class MegaDebridClient {
account: rotationLabel, account: rotationLabel,
workMs: elapsedMs, workMs: elapsedMs,
outcome: "aborted", outcome: "aborted",
detail: `${abortText}${ranLongEnough ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ""}` detail: `${abortText}${rotateToAnotherAccount ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ranLongEnough ? ` slowlink=${Math.ceil(MEGA_DEBRID_SLOW_LINK_RETRY_MS / 1000)}s` : ""}`
}); });
failures.push(`Mega-Debrid${accountLabel}: ${abortText}`); failures.push(`Mega-Debrid${accountLabel}: ${abortText}`);
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", { logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
elapsedMs, elapsedMs,
reason: abortText, reason: abortText,
cooldownSec: ranLongEnough ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0, cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
next: "naechster Account beim Retry" next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
}); });
if (ranLongEnough && !rotateToAnotherAccount) {
throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
}
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
} }
const failure = MegaDebridClient.classifyAccountFailure(error); const failure = MegaDebridClient.classifyAccountFailure(error);
@ -2108,7 +2254,7 @@ class MegaDebridClient {
const streak = recordMegaDebridEmptyResponseStreak(cooldownKey); const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) { if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
parkUntilRestart = true; parkUntilRestart = true;
parkMessage = `Tageslimit erreicht (${streak}x kein Server/leere Antwort) — bis zum Tagesreset gesperrt`; parkMessage = `Tageslimit erreicht (${streak}x leere Antwort in Folge) — bis zum Tagesreset gesperrt`;
} }
} else { } else {
clearMegaDebridEmptyResponseStreak(cooldownKey); clearMegaDebridEmptyResponseStreak(cooldownKey);
@ -2202,6 +2348,10 @@ class MegaDebridClient {
return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" }; return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" };
} }
if (/lieferte kein ergebnis|linkgenerierung[^.]*kein ergebnis/i.test(errorText)) {
return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" };
}
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) { if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" }; return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
} }
@ -2787,6 +2937,23 @@ class DebridLinkClient {
} catch (error) { } catch (error) {
const failure = await this.classifyKeyFailure(error, apiKey, link, signal); const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
const elapsedMs = Date.now() - testStartedAt; 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({ attemptedKeyFailures.push({
message: `Debrid-Link${keyLabel}: ${failure.message}`, message: `Debrid-Link${keyLabel}: ${failure.message}`,
cooldownMs: failure.cooldownMs, cooldownMs: failure.cooldownMs,

View File

@ -15,6 +15,8 @@ import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log"; import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
import { getConversionLogPath } from "./conversion-trace";
import { getProviderRuntimeSnapshot } from "./debrid";
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager"; import type { DownloadManager } from "./download-manager";
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
@ -43,12 +45,14 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." }, { method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." },
{ method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." }, { method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." },
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." }, { method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
{ method: "GET", path: "/logs/conversion", queryExample: "lines=100&grep=keyword", description: "Reads the per-item link conversion/unrestrict lifecycle log (token, API getLink, web, account rotation, aborts with timings)." },
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." }, { method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
{ method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." }, { method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
{ method: "GET", path: "/trace/config", queryExample: "enable=1&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." }, { method: "GET", path: "/trace/config", queryExample: "enable=1&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
{ method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." },
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." }, { method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
{ method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." }, { method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." },
{ method: "GET", path: "/status", description: "Returns a live high-level status overview." }, { method: "GET", path: "/status", description: "Returns a live high-level status overview." },
@ -65,6 +69,16 @@ let authToken = "";
let bindHost = DEFAULT_HOST; let bindHost = DEFAULT_HOST;
let bindPort = DEFAULT_PORT; let bindPort = DEFAULT_PORT;
let runtimeBaseDir = ""; let runtimeBaseDir = "";
let allowlist: string[] = [];
export interface DebugServerRuntimeStatus {
running: boolean;
host: string;
port: number;
hasToken: boolean;
localOnly: boolean;
allowlistCount: number;
}
function getStoragePaths() { function getStoragePaths() {
return createStoragePaths(runtimeBaseDir); return createStoragePaths(runtimeBaseDir);
@ -140,6 +154,94 @@ function getHost(baseDir: string): string {
return DEFAULT_HOST; return DEFAULT_HOST;
} }
function getAllowlistPath(baseDir: string = runtimeBaseDir): string {
return path.join(baseDir, "debug_allowlist.txt");
}
function loadAllowlist(baseDir: string): string[] {
try {
return fs.readFileSync(getAllowlistPath(baseDir), "utf8")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"));
} catch {
return [];
}
}
function normalizeIp(ip: string): string {
return String(ip || "").trim().replace(/^::ffff:/i, "").toLowerCase();
}
function isLoopbackIp(ip: string): boolean {
const x = normalizeIp(ip);
return x === "::1" || x === "localhost" || x.startsWith("127.");
}
function ipv4ToInt(ip: string): number | null {
const parts = ip.split(".");
if (parts.length !== 4) {
return null;
}
let result = 0;
for (const part of parts) {
const value = Number(part);
if (!Number.isInteger(value) || value < 0 || value > 255) {
return null;
}
result = (result * 256) + value;
}
return result >>> 0;
}
function matchIpRule(clientIp: string, rule: string): boolean {
const client = normalizeIp(clientIp);
const r = rule.trim().toLowerCase();
if (!r) {
return false;
}
if (r === "*" || r === "0.0.0.0/0") {
return true;
}
if (r === client) {
return true;
}
const slash = r.indexOf("/");
if (slash > 0) {
const baseInt = ipv4ToInt(r.slice(0, slash));
const clientInt = ipv4ToInt(client);
const bits = Number(r.slice(slash + 1));
if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
return false;
}
if (bits === 0) {
return true;
}
const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0;
return (clientInt & mask) === (baseInt & mask);
}
return false;
}
export function evaluateClientAllowed(clientIp: string, rules: string[]): boolean {
const client = normalizeIp(clientIp);
if (isLoopbackIp(client) || client === "") {
return true;
}
if (rules.length === 0) {
return false;
}
return rules.some((rule) => matchIpRule(client, rule));
}
export function getPeerIp(req: http.IncomingMessage): string {
return normalizeIp(req.socket?.remoteAddress || "");
}
function isClientAllowed(clientIp: string): boolean {
return evaluateClientAllowed(clientIp, allowlist);
}
function checkAuth(req: http.IncomingMessage): boolean { function checkAuth(req: http.IncomingMessage): boolean {
if (!authToken) { if (!authToken) {
return false; return false;
@ -258,6 +360,7 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.", "Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
"Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.", "Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.",
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.", "Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.",
"For provider stalls/cooldowns, call /providers for the live cooldown state (until/remaining/reason per account/key) and /logs/conversion for the per-item resolve lifecycle (token, API, web, rotation, aborts with timings).",
"If a full handoff is needed, download /support/bundle as a ZIP." "If a full handoff is needed, download /support/bundle as a ZIP."
], ],
auth: { auth: {
@ -461,6 +564,19 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
const peerIp = getPeerIp(req);
if (!isClientAllowed(peerIp)) {
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", {
peerIp,
forwardedFor: extractDebugClientIp(req),
url: sanitizeRequestUrlForTrace(req.url || "/")
});
}
jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp: peerIp });
return;
}
if (!checkAuth(req)) { if (!checkAuth(req)) {
if (traceConfig.enabled && traceConfig.logDebugRequests) { if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("WARN", "debug-http", "Unauthorized request", { logTraceEvent("WARN", "debug-http", "Unauthorized request", {
@ -596,6 +712,25 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
if (pathname === "/logs/conversion") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const logPath = getConversionLogPath();
const lines = logPath ? filterLines(readLogTailFromFile(logPath, count), grep) : [];
jsonResponse(res, 200, {
path: logPath,
available: Boolean(logPath),
lines,
count: lines.length
});
return;
}
if (pathname === "/providers") {
jsonResponse(res, 200, getProviderRuntimeSnapshot());
return;
}
if (pathname === "/trace/config") { if (pathname === "/trace/config") {
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
const enabled = toBooleanQuery(url.searchParams.get("enable")); const enabled = toBooleanQuery(url.searchParams.get("enable"));
@ -881,6 +1016,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
settings: buildRedactedSettingsPayload(readSupportSettings()), settings: buildRedactedSettingsPayload(readSupportSettings()),
stats: buildStatsPayload(snapshot), stats: buildStatsPayload(snapshot),
accounts: buildAccountSummary(readSupportSettings()), accounts: buildAccountSummary(readSupportSettings()),
providers: getProviderRuntimeSnapshot(),
history: { history: {
total: readSupportHistory().length, total: readSupportHistory().length,
recent: readSupportHistory() recent: readSupportHistory()
@ -912,6 +1048,10 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
path: sessionLogPath, path: sessionLogPath,
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep) lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
}, },
conversion: {
path: getConversionLogPath(),
lines: getConversionLogPath() ? filterLines(readLogTailFromFile(getConversionLogPath() as string, lineCount), grep) : []
},
package: selectedPackage ? { package: selectedPackage ? {
path: packageLogPath, path: packageLogPath,
lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep) lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep)
@ -927,32 +1067,126 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
}); });
} }
export function startDebugServer(mgr: DownloadManager, baseDir: string): void { function openServerSocket(): Promise<void> {
runtimeBaseDir = baseDir; return new Promise((resolve) => {
authToken = loadToken(baseDir); authToken = loadToken(runtimeBaseDir);
bindPort = getPort(baseDir); bindPort = getPort(runtimeBaseDir);
bindHost = getHost(baseDir); bindHost = getHost(runtimeBaseDir);
writeAiManifest(baseDir); allowlist = loadAllowlist(runtimeBaseDir);
writeAiManifest(runtimeBaseDir);
if (!authToken) { if (!authToken) {
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet"); logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
resolve();
return; return;
} }
if (bindHost === "0.0.0.0" && allowlist.length === 0) {
manager = mgr; logger.warn("Debug-Server: Netzwerk-Bind ohne Allowlist - nur Loopback-Clients werden akzeptiert (fail-closed)");
}
server = http.createServer(handleRequest); const srv = http.createServer(handleRequest);
server.listen(bindPort, bindHost, () => { let settled = false;
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`); const settle = (): void => {
}); if (!settled) {
server.on("error", (err) => { settled = true;
resolve();
}
};
srv.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
logger.warn(`Debug-Server: Port ${bindPort} belegt (EADDRINUSE) - Server nicht gestartet`);
} else {
logger.warn(`Debug-Server Fehler: ${String(err)}`); logger.warn(`Debug-Server Fehler: ${String(err)}`);
}
if (server === srv) {
server = null; server = null;
}
settle();
}); });
srv.listen(bindPort, bindHost, () => {
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort} (Allowlist: ${allowlist.length})`);
settle();
});
server = srv;
});
}
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
runtimeBaseDir = baseDir;
manager = mgr;
void openServerSocket();
}
export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> {
const old = server;
if (old) {
server = null;
await new Promise<void>((resolve) => {
let settled = false;
const done = (): void => {
if (!settled) {
settled = true;
resolve();
}
};
old.close(() => done());
try {
old.closeAllConnections?.();
} catch {
}
setTimeout(done, 1500);
});
}
await openServerSocket();
return getDebugServerRuntimeStatus();
}
export function getDebugServerRuntimeStatus(): DebugServerRuntimeStatus {
return {
running: Boolean(server && server.listening),
host: bindHost,
port: bindPort,
hasToken: Boolean(authToken),
localOnly: isLoopbackIp(bindHost) || bindHost === "127.0.0.1",
allowlistCount: allowlist.length
};
}
export function getActiveDebugToken(): string {
return authToken || loadToken(runtimeBaseDir);
}
export function getDebugAllowlist(): string[] {
return [...allowlist];
}
export function writeDebugServerConfig(opts: { host?: string; port?: number; allowlist?: string[] }): void {
if (opts.host !== undefined) {
fs.writeFileSync(path.join(runtimeBaseDir, "debug_host.txt"), `${opts.host}\n`, "utf8");
}
if (opts.port !== undefined) {
fs.writeFileSync(path.join(runtimeBaseDir, "debug_port.txt"), `${opts.port}\n`, "utf8");
}
if (opts.allowlist !== undefined) {
const body = opts.allowlist.length > 0 ? opts.allowlist.join("\n") + "\n" : "";
fs.writeFileSync(getAllowlistPath(), body, "utf8");
}
}
export function clearDebugToken(): void {
try {
fs.unlinkSync(getDebugTokenPath());
} catch {
}
authToken = "";
writeAiManifest(runtimeBaseDir);
} }
export function stopDebugServer(): void { export function stopDebugServer(): void {
if (server) { if (server) {
server.close(); server.close();
try {
server.closeAllConnections?.();
} catch {
}
server = null; server = null;
logger.info("Debug-Server gestoppt"); logger.info("Debug-Server gestoppt");
} }

View File

@ -52,7 +52,7 @@ function releaseTlsSkip(): void {
} }
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error"; import { classifyDiskError } from "./fs-error";
@ -131,6 +131,17 @@ const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000;
const MAX_SAME_DIRECT_URL_ATTEMPTS = 3; const MAX_SAME_DIRECT_URL_ATTEMPTS = 3;
const MAX_HTTP416_FRESH_RESTARTS = 2;
const HTTP416_FRESH_RESTART_DELAY_MS = 8000;
function getHttp416FreshRestartDelayMs(): number {
const fromEnv = Number(process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 0 && fromEnv <= 600000) {
return Math.floor(fromEnv);
}
return HTTP416_FRESH_RESTART_DELAY_MS;
}
const RESUME_REWIND_BYTES = 256 * 1024; const RESUME_REWIND_BYTES = 256 * 1024;
const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024; const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024;
@ -363,6 +374,7 @@ type DownloadManagerOptions = {
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
invalidateMegaSession?: () => void; invalidateMegaSession?: () => void;
onHistoryEntry?: HistoryEntryCallback; onHistoryEntry?: HistoryEntryCallback;
protectEmptyClobber?: boolean;
}; };
function generateHistoryId(): string { function generateHistoryId(): string {
@ -659,6 +671,20 @@ 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 parseMegaDebridSlowLinkRetry(errorText: string): { delayMs: number; detail: string } | null {
const text = String(errorText || "");
const match = text.match(/mega_debrid_slow_link:(\d+)/i);
if (!match) {
return null;
}
const raw = Number(match[1]);
if (!Number.isFinite(raw) || raw <= 0) {
return null;
}
const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, raw));
return { delayMs, detail: text.replace(/mega_debrid_slow_link:\d+:/i, "").trim() };
}
export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null { export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null {
const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is); const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is);
if (!match) { if (!match) {
@ -1688,6 +1714,10 @@ export class DownloadManager extends EventEmitter {
public blockAllPersistence = false; public blockAllPersistence = false;
private protectAgainstEmptyClobber = false;
private emptyClobberProtectionLogged = false;
private debridService: DebridService; private debridService: DebridService;
private invalidateMegaSessionFn?: () => void; private invalidateMegaSessionFn?: () => void;
@ -1810,6 +1840,8 @@ export class DownloadManager extends EventEmitter {
unrestrictRetries: number; unrestrictRetries: number;
}>(); }>();
private http416FreshRestartByItem = new Map<string, number>();
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>(); private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>(); private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
@ -1831,6 +1863,10 @@ export class DownloadManager extends EventEmitter {
this.session = session; this.session = session;
this.itemCount = Object.keys(this.session.items).length; this.itemCount = Object.keys(this.session.items).length;
this.storagePaths = storagePaths; this.storagePaths = storagePaths;
this.protectAgainstEmptyClobber = Boolean(options.protectEmptyClobber);
if (this.protectAgainstEmptyClobber) {
logger.warn("Session-Schutz aktiv: Start mit unlesbarer Session — leere Speicherungen blockiert, bis echte Daten vorliegen");
}
this.debridService = new DebridService(settings, { this.debridService = new DebridService(settings, {
megaWebUnrestrict: options.megaWebUnrestrict, megaWebUnrestrict: options.megaWebUnrestrict,
allDebridWebUnrestrict: options.allDebridWebUnrestrict, allDebridWebUnrestrict: options.allDebridWebUnrestrict,
@ -2988,6 +3024,14 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
const hasOwnCompletedOutput = pkg.itemIds.some((itemId) => {
const item = this.session.items[itemId];
return Boolean(item && item.status === "completed");
});
if (hasOwnCompletedOutput) {
continue;
}
if (!this.isPackageSpecificExtractDir(pkg)) { if (!this.isPackageSpecificExtractDir(pkg)) {
continue; continue;
} }
@ -5539,7 +5583,7 @@ export class DownloadManager extends EventEmitter {
}); });
} }
public async start(): Promise<void> { public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> {
if (this.session.running) { if (this.session.running) {
return; return;
} }
@ -5580,6 +5624,9 @@ export class DownloadManager extends EventEmitter {
if (item.status !== "queued" && item.status !== "reconnect_wait") { if (item.status !== "queued" && item.status !== "reconnect_wait") {
return false; return false;
} }
if (options?.excludePackageIds?.has(item.packageId)) {
return false;
}
const pkg = this.session.packages[item.packageId]; const pkg = this.session.packages[item.packageId];
return Boolean(pkg && !pkg.cancelled && pkg.enabled); return Boolean(pkg && !pkg.cancelled && pkg.enabled);
}); });
@ -5644,9 +5691,15 @@ export class DownloadManager extends EventEmitter {
this.providerStartReservations.clear(); this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear(); this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear(); this.retryStateByItem.clear();
this.http416FreshRestartByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
if (options?.excludePackageIds) {
for (const excluded of options.excludePackageIds) {
this.runPackageIds.delete(excluded);
}
}
this.session.running = true; this.session.running = true;
this.session.paused = false; this.session.paused = false;
@ -5821,7 +5874,9 @@ export class DownloadManager extends EventEmitter {
const itemCount = Object.keys(this.session.items).length; const itemCount = Object.keys(this.session.items).length;
logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`); logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`);
this.foldRuntimeIntoSettings(nowMs()); this.foldRuntimeIntoSettings(nowMs());
if (!this.guardBlocksSessionSave()) {
saveSession(this.storagePaths, this.session); saveSession(this.storagePaths, this.session);
}
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
} else { } else {
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`); logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
@ -6124,10 +6179,29 @@ export class DownloadManager extends EventEmitter {
}, delay); }, delay);
} }
private guardBlocksSessionSave(): boolean {
if (!this.protectAgainstEmptyClobber) {
return false;
}
const isEmpty = Object.keys(this.session.packages).length === 0 && Object.keys(this.session.items).length === 0;
if (isEmpty) {
if (!this.emptyClobberProtectionLogged) {
logger.warn("Leere Session-Speicherung uebersprungen (Schutz nach unlesbarem Start) — vorhandene Datei bleibt unangetastet");
this.emptyClobberProtectionLogged = true;
}
return true;
}
this.protectAgainstEmptyClobber = false;
logger.info("Session-Schutz aufgehoben: nicht-leere Session wird wieder normal gespeichert");
return false;
}
private persistNow(): void { private persistNow(): void {
const now = nowMs(); const now = nowMs();
this.lastPersistAt = now; this.lastPersistAt = now;
if (!this.guardBlocksSessionSave()) {
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`)); void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
}
if (now - this.lastSettingsPersistAt >= 30000) { if (now - this.lastSettingsPersistAt >= 30000) {
this.foldRuntimeIntoSettings(now); this.foldRuntimeIntoSettings(now);
this.lastSettingsPersistAt = now; this.lastSettingsPersistAt = now;
@ -6141,7 +6215,9 @@ export class DownloadManager extends EventEmitter {
const itemCount = Object.keys(this.session.items).length; const itemCount = Object.keys(this.session.items).length;
logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`); logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`);
this.foldRuntimeIntoSettings(nowMs()); this.foldRuntimeIntoSettings(nowMs());
if (!this.guardBlocksSessionSave()) {
saveSession(this.storagePaths, this.session); saveSession(this.storagePaths, this.session);
}
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
} }
@ -8150,7 +8226,13 @@ export class DownloadManager extends EventEmitter {
const provider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(item)); const provider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(item));
const serializedValidatingLimit = this.getSerializedValidatingLimit(provider); const serializedValidatingLimit = this.getSerializedValidatingLimit(provider);
if (provider && Number.isFinite(serializedValidatingLimit) && serializedValidatingLimit < Number.MAX_SAFE_INTEGER) { if (provider && Number.isFinite(serializedValidatingLimit) && serializedValidatingLimit < Number.MAX_SAFE_INTEGER) {
return this.getProviderValidatingTaskCount(provider, item.id) >= serializedValidatingLimit; const validating = this.getProviderValidatingTaskCount(provider, item.id);
if (provider === "megadebrid-api") {
const webInFlight = getMegaDebridInFlightCountForMode("web");
const overlapAllowance = Math.min(serializedValidatingLimit, webInFlight);
return validating >= serializedValidatingLimit + overlapAllowance;
}
return validating >= serializedValidatingLimit;
} }
if (provider !== "alldebrid") { if (provider !== "alldebrid") {
return false; return false;
@ -8659,6 +8741,53 @@ export class DownloadManager extends EventEmitter {
this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`); this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
} }
private escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): void {
const freshRestarts = this.http416FreshRestartByItem.get(item.id) || 0;
if (freshRestarts < MAX_HTTP416_FRESH_RESTARTS) {
this.http416FreshRestartByItem.set(item.id, freshRestarts + 1);
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
if (resetTargetPath) {
try {
fs.rmSync(resetTargetPath, { force: true });
} catch {
}
}
this.releaseTargetPath(item.id);
this.dropItemContribution(item.id);
item.retries += 1;
item.downloadedBytes = 0;
item.totalBytes = null;
item.progressPercent = 0;
item.speedBps = 0;
item.lastError = "";
active.genericErrorRetries = 0;
active.freshRetryUsed = false;
active.resumeHardResetUsed = false;
logger.warn(
`HTTP 416 Budget erschöpft: item=${item.fileName || item.id}, ` +
`kompletter Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS} (Partial verworfen, kein Resume), provider=${item.provider || "?"}`
);
this.queueRetry(item, active, getHttp416FreshRestartDelayMs(), `Range-Konflikt (HTTP 416): Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS}`);
this.persistSoon();
this.emitState();
return;
}
this.http416FreshRestartByItem.delete(item.id);
item.status = "failed";
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
const failPkg = this.session.packages[item.packageId];
if (failPkg) {
this.refreshPackageStatus(failPkg);
}
this.persistSoon();
this.emitState();
this.retryStateByItem.delete(item.id);
}
private startItem(packageId: string, itemId: string): void { private startItem(packageId: string, itemId: string): void {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
@ -8976,7 +9105,6 @@ export class DownloadManager extends EventEmitter {
if (item.attempts < maxAttempts) { if (item.attempts < maxAttempts) {
item.status = "integrity_check"; item.status = "integrity_check";
item.progressPercent = 0; item.progressPercent = 0;
this.dropItemContribution(item.id);
item.downloadedBytes = 0; item.downloadedBytes = 0;
item.totalBytes = unrestricted.fileSize; item.totalBytes = unrestricted.fileSize;
this.emitState(); this.emitState();
@ -9017,7 +9145,6 @@ export class DownloadManager extends EventEmitter {
} catch { } catch {
} }
this.releaseTargetPath(item.id); this.releaseTargetPath(item.id);
this.dropItemContribution(item.id);
item.downloadedBytes = 0; item.downloadedBytes = 0;
item.progressPercent = 0; item.progressPercent = 0;
item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null; item.totalBytes = (item.totalBytes || 0) > 0 ? item.totalBytes : null;
@ -9251,12 +9378,16 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
} }
if (isHttp416Text(exhaustedReason) && active.genericErrorRetries < maxHttp416Retries) { if (isHttp416Text(exhaustedReason)) {
if (active.genericErrorRetries < maxHttp416Retries) {
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath); this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
return; return;
} }
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
return;
}
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) { if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
active.resumeHardResetUsed = true; active.resumeHardResetUsed = true;
item.retries += 1; item.retries += 1;
@ -9311,17 +9442,7 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
return; return;
} }
item.status = "failed"; this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
const failPkg416 = this.session.packages[item.packageId];
if (failPkg416) this.refreshPackageStatus(failPkg416);
this.persistSoon();
this.emitState();
this.retryStateByItem.delete(item.id);
return; return;
} }
if (shouldFreshRetry) { if (shouldFreshRetry) {
@ -9437,6 +9558,24 @@ export class DownloadManager extends EventEmitter {
} }
const megaRawError = error instanceof Error ? String(error.message || "") : String(error || ""); const megaRawError = error instanceof Error ? String(error.message || "") : String(error || "");
const megaSlowLinkRetry = parseMegaDebridSlowLinkRetry(megaRawError);
if (megaSlowLinkRetry && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
item.provider = null;
logger.warn(`Mega-Debrid Link langsam (Timeout): item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaSlowLinkRetry.delayMs}ms, link=${item.url.slice(0, 80)}`);
this.queueRetry(
item,
active,
megaSlowLinkRetry.delayMs,
`Mega-Debrid: Link zu langsam, Einzel-Retry in ${Math.ceil(megaSlowLinkRetry.delayMs / 1000)}s`
);
item.lastError = megaSlowLinkRetry.detail || errorText;
this.persistSoon();
this.emitState();
return;
}
const megaCooldownRetry = parseMegaDebridCooldownRetry(megaRawError); const megaCooldownRetry = parseMegaDebridCooldownRetry(megaRawError);
if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) { if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1; active.unrestrictRetries += 1;
@ -9993,6 +10132,7 @@ export class DownloadManager extends EventEmitter {
if (previouslyContributed > 0) { if (previouslyContributed > 0) {
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - previouslyContributed); this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - previouslyContributed);
this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - 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); this.itemContributedBytes.set(active.itemId, 0);
} }
if (existingBytes > 0) { if (existingBytes > 0) {
@ -10483,7 +10623,6 @@ export class DownloadManager extends EventEmitter {
await fs.promises.rm(effectiveTargetPath, { force: true }); await fs.promises.rm(effectiveTargetPath, { force: true });
} catch { } } catch { }
this.releaseTargetPath(active.itemId); this.releaseTargetPath(active.itemId);
this.dropItemContribution(active.itemId);
item.downloadedBytes = 0; item.downloadedBytes = 0;
item.progressPercent = 0; item.progressPercent = 0;
throw new Error(`Download zu klein (${written} B) Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`); throw new Error(`Download zu klein (${written} B) Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`);

View File

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron"; import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
import { AddLinksPayload, AppSettings, DebridProvider, UpdateInstallProgress } from "../shared/types"; import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types";
import { AppController } from "./app-controller"; import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc"; import { IPC_CHANNELS } from "../shared/ipc";
import { getLogFilePath, logger } from "./logger"; import { getLogFilePath, logger } from "./logger";
@ -571,7 +571,7 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
const options = { const options = {
defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`, defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
filters: [{ name: "MDD Backup", extensions: ["mdd"] }] filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
}; };
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
@ -671,6 +671,33 @@ function registerIpcHandlers(): void {
return { path: rotated.path }; return { path: rotated.path };
}); });
ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => {
return controller.getRemoteDiagnostics();
});
ipcMain.handle(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => {
if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) {
throw new Error("hostMode muss 'local' oder 'network' sein");
}
const allowlist = Array.isArray(input.allowlist) ? input.allowlist.map((entry) => String(entry)) : [];
return controller.enableRemoteDiagnostics({
hostMode: input.hostMode,
publicHost: String(input.publicHost || ""),
port: input.port ? Number(input.port) : undefined,
allowlist,
name: input.name ? String(input.name) : undefined,
rotateToken: Boolean(input.rotateToken)
});
});
ipcMain.handle(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => {
return controller.disableRemoteDiagnostics();
});
ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => {
return controller.rotateRemoteDiagnosticsToken();
});
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
validateString(itemId, "itemId"); validateString(itemId, "itemId");
const logPath = controller.getItemLogPath(itemId); const logPath = controller.getItemLogPath(itemId);

View File

@ -461,6 +461,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems, hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads, backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
backupIncludeMcp: settings.backupIncludeMcp !== undefined ? Boolean(settings.backupIncludeMcp) : defaults.backupIncludeMcp,
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl, notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
notifyMention: asText(settings.notifyMention) || defaults.notifyMention, notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted, notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
@ -889,21 +890,50 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
return session; return session;
} }
const TRANSIENT_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]);
function sleepSyncMs(ms: number): void {
if (ms <= 0) {
return;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function readSessionFile(filePath: string): SessionState | null { function readSessionFile(filePath: string): SessionState | null {
let raw: string | null = null;
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try { try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; raw = fs.readFileSync(filePath, "utf8");
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || "";
if (TRANSIENT_READ_CODES.has(code) && attempt < maxAttempts) {
const backoffMs = 100 * 2 ** (attempt - 1);
logger.warn(`Session-Datei vorübergehend gesperrt (${code}), Versuch ${attempt}/${maxAttempts}, warte ${backoffMs}ms: ${filePath}`);
sleepSyncMs(backoffMs);
continue;
}
if (code === "EACCES" || code === "EPERM") {
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
} else {
logger.error(`Session-Datei nicht lesbar (${code || "?"}): ${filePath}: ${String(error)}`);
}
return null;
}
}
if (raw === null) {
return null;
}
try {
const parsed = JSON.parse(raw) as unknown;
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed)); const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
const pkgCount = Object.keys(session.packages).length; const pkgCount = Object.keys(session.packages).length;
const itemCount = Object.keys(session.items).length; const itemCount = Object.keys(session.items).length;
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`); logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
return session; return session;
} catch (error) { } catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || ""; logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
if (code === "EACCES" || code === "EPERM") {
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
} else {
logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`);
}
return null; return null;
} }
} }
@ -1003,17 +1033,31 @@ export function emptySession(): SessionState {
}; };
} }
export function loadSession(paths: StoragePaths): SessionState { export type SessionLoadStatus =
| "ok"
| "recovered-backup"
| "recovered-temp"
| "empty-fresh"
| "empty-unreadable";
export interface SessionLoadResult {
session: SessionState;
status: SessionLoadStatus;
}
export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
const backupFile = sessionBackupPath(paths.sessionFile); const backupFile = sessionBackupPath(paths.sessionFile);
const syncTempFile = sessionTempPath(paths.sessionFile, "sync");
const asyncTempFile = sessionTempPath(paths.sessionFile, "async");
const primaryExists = fs.existsSync(paths.sessionFile); const primaryExists = fs.existsSync(paths.sessionFile);
const backupExists = fs.existsSync(backupFile);
const anyTempExists = fs.existsSync(syncTempFile) || fs.existsSync(asyncTempFile);
if (!primaryExists) { if (!primaryExists) {
const hasRecoverable = fs.existsSync(backupFile) if (!backupExists && !anyTempExists) {
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
|| fs.existsSync(sessionTempPath(paths.sessionFile, "async"));
if (!hasRecoverable) {
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session"); logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
return emptySession(); return { session: emptySession(), status: "empty-fresh" };
} }
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht"); logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
} }
@ -1022,7 +1066,7 @@ export function loadSession(paths: StoragePaths): SessionState {
if (primary) { if (primary) {
const primaryPkgCount = Object.keys(primary.packages).length; const primaryPkgCount = Object.keys(primary.packages).length;
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) { if (primaryPkgCount === 0 && backupExists) {
const backup = readSessionFile(backupFile); const backup = readSessionFile(backupFile);
if (backup) { if (backup) {
const backupPkgCount = Object.keys(backup.packages).length; const backupPkgCount = Object.keys(backup.packages).length;
@ -1030,29 +1074,27 @@ export function loadSession(paths: StoragePaths): SessionState {
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`); logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
try { try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync"); fs.writeFileSync(syncTempFile, payload, "utf8");
fs.writeFileSync(tempPath, payload, "utf8"); syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch { } catch {
} }
return backup; return { session: backup, status: "recovered-backup" };
} }
} }
} }
return primary; return { session: primary, status: "ok" };
} }
const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null; const backup = backupExists ? readSessionFile(backupFile) : null;
if (backup) { if (backup) {
logger.warn("Session defekt, Backup-Datei wird verwendet"); logger.warn("Session defekt, Backup-Datei wird verwendet");
try { try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync"); fs.writeFileSync(syncTempFile, payload, "utf8");
fs.writeFileSync(tempPath, payload, "utf8"); syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch { } catch {
} }
return backup; return { session: backup, status: "recovered-backup" };
} }
for (const kind of ["sync", "async"] as const) { for (const kind of ["sync", "async"] as const) {
@ -1066,13 +1108,21 @@ export function loadSession(paths: StoragePaths): SessionState {
fs.writeFileSync(paths.sessionFile, payload, "utf8"); fs.writeFileSync(paths.sessionFile, payload, "utf8");
} catch { } catch {
} }
return tmpSession; return { session: tmpSession, status: "recovered-temp" };
} }
} }
} }
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)"); if (primaryExists || backupExists || anyTempExists) {
return emptySession(); logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv");
return { session: emptySession(), status: "empty-unreadable" };
}
return { session: emptySession(), status: "empty-fresh" };
}
export function loadSession(paths: StoragePaths): SessionState {
return loadSessionWithStatus(paths).session;
} }
export function saveSession(paths: StoragePaths, session: SessionState): void { export function saveSession(paths: StoragePaths, session: SessionState): void {
@ -1087,7 +1137,13 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync"); const tempPath = sessionTempPath(paths.sessionFile, "sync");
try { try {
fs.writeFileSync(tempPath, payload, "utf8"); const fd = fs.openSync(tempPath, "w");
try {
fs.writeSync(fd, payload);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
syncRenameWithExdevFallback(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch (error) { } catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { } try { fs.rmSync(tempPath, { force: true }); } catch { }
@ -1103,7 +1159,13 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
await fs.promises.mkdir(paths.baseDir, { recursive: true }); await fs.promises.mkdir(paths.baseDir, { recursive: true });
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {}); await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
const tempPath = sessionTempPath(paths.sessionFile, "async"); const tempPath = sessionTempPath(paths.sessionFile, "async");
await fsp.writeFile(tempPath, payload, "utf8"); const handle = await fsp.open(tempPath, "w");
try {
await handle.writeFile(payload, "utf8");
await handle.sync();
} finally {
await handle.close();
}
if (generation < syncSaveGeneration) { if (generation < syncSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {}); await fsp.rm(tempPath, { force: true }).catch(() => {});
return; return;

View File

@ -7,8 +7,10 @@ import {
DebridLinkHostLimitInfo, DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
RemoteDiagnosticsInfo,
RendererErrorReport, RendererErrorReport,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
@ -74,6 +76,10 @@ const api: ElectronApi = {
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG), getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes), setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN), rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS),
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),

View File

@ -16,6 +16,7 @@ import type {
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackageEntry, PackageEntry,
RemoteDiagnosticsInfo,
StartConflictEntry, StartConflictEntry,
UiSnapshot, UiSnapshot,
UpdateCheckResult, UpdateCheckResult,
@ -855,7 +856,7 @@ const emptySnapshot = (): UiSnapshot => ({
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeMcp: false,
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
@ -1761,6 +1762,14 @@ export function App(): ReactElement {
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null); const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null); const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null); const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
const [remoteDiagOpen, setRemoteDiagOpen] = useState(false);
const [remoteDiagBusy, setRemoteDiagBusy] = useState(false);
const [rdHostMode, setRdHostMode] = useState<"local" | "network">("local");
const [rdPublicHost, setRdPublicHost] = useState("");
const [rdPort, setRdPort] = useState("9868");
const [rdAllowlist, setRdAllowlist] = useState("");
const [rdName, setRdName] = useState("");
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]); const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
@ -4328,6 +4337,89 @@ export function App(): ReactElement {
}); });
}; };
const applyRemoteDiagInfo = (info: RemoteDiagnosticsInfo): void => {
setRemoteDiag(info);
setRdHostMode(info.status.localOnly ? "local" : "network");
setRdPublicHost(info.publicHost || (info.status.localOnly ? "" : (info.suggestedHosts[0] || "")));
setRdPort(String(info.status.port || 9868));
setRdAllowlist(info.allowlist.join("\n"));
setRdName(info.name || "");
};
const onOpenRemoteDiagnostics = async (): Promise<void> => {
closeMenus();
try {
const info = await window.rd.getRemoteDiagnostics();
applyRemoteDiagInfo(info);
setRemoteDiagOpen(true);
} catch (error) {
showToast(`Ferndiagnose-Status fehlgeschlagen: ${String(error)}`, 3000);
}
};
const onSubmitRemoteDiagnostics = async (): Promise<void> => {
const port = Number(rdPort) || 9868;
const allowlist = rdAllowlist.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
if (rdHostMode === "network" && allowlist.length === 0) {
showToast("Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", 3600);
return;
}
setRemoteDiagBusy(true);
try {
const info = await window.rd.enableRemoteDiagnostics({
hostMode: rdHostMode,
publicHost: rdPublicHost,
port,
allowlist,
name: rdName
});
applyRemoteDiagInfo(info);
showToast(info.status.running ? "Ferndiagnose aktiv" : "Ferndiagnose konfiguriert", 2600);
} catch (error) {
showToast(`Aktivieren fehlgeschlagen: ${String(error)}`, 3600);
} finally {
setRemoteDiagBusy(false);
}
};
const onDisableRemoteDiagnostics = async (): Promise<void> => {
setRemoteDiagBusy(true);
try {
const info = await window.rd.disableRemoteDiagnostics();
applyRemoteDiagInfo(info);
showToast("Ferndiagnose deaktiviert", 2400);
} catch (error) {
showToast(`Deaktivieren fehlgeschlagen: ${String(error)}`, 3000);
} finally {
setRemoteDiagBusy(false);
}
};
const onRotateRemoteDiagnosticsToken = async (): Promise<void> => {
setRemoteDiagBusy(true);
try {
const info = await window.rd.rotateRemoteDiagnosticsToken();
applyRemoteDiagInfo(info);
showToast("Neues Token - alter Verbindungscode ist ungueltig", 3000);
} catch (error) {
showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000);
} finally {
setRemoteDiagBusy(false);
}
};
const onCopyRemoteDiagnosticsCode = async (): Promise<void> => {
if (!remoteDiag?.code) {
return;
}
try {
await navigator.clipboard.writeText(remoteDiag.code);
showToast("Verbindungscode kopiert", 2200);
} catch {
showToast("Kopieren fehlgeschlagen", 2200);
}
};
const onMenuRestart = (): void => { const onMenuRestart = (): void => {
closeMenus(); closeMenus();
void window.rd.restart(); void window.rd.restart();
@ -4718,6 +4810,7 @@ export function App(): ReactElement {
<button className="menu-submenu-trigger">Remote-Support</button> <button className="menu-submenu-trigger">Remote-Support</button>
{openSubmenu === "hilfe-remote" && ( {openSubmenu === "hilfe-remote" && (
<div className="menu-submenu-dropdown"> <div className="menu-submenu-dropdown">
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose (MCP) </span></button>
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button> <button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button> <button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
</div> </div>
@ -5349,6 +5442,8 @@ export function App(): ReactElement {
<div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div> <div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label>
<div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div> <div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeMcp} onChange={(e) => setBool("backupIncludeMcp", e.target.checked)} /> Ferndiagnose-Einstellungen mitsichern</label>
<div className="setting-hint">Allowlist, Port und Freigabemodus (lokal/Netzwerk) reisen mit. Verbindungs-Token und eigene Adresse bleiben pro Server nach dem Import einmal Aktivieren" drücken.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => { <label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
const next = e.target.checked ? "light" : "dark"; const next = e.target.checked ? "light" : "dark";
settingsDraftRevisionRef.current += 1; settingsDraftRevisionRef.current += 1;
@ -5934,6 +6029,97 @@ export function App(): ReactElement {
</div> </div>
)} )}
{remoteDiagOpen && (
<div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>Ferndiagnose (MCP)</h3>
<p>Aktiviert einen abgesicherten Lesezugriff auf Status, Logs und Fehler dieses Servers. Den Verbindungscode dem Assistenten geben - er verbindet sich, sieht alles und behebt Probleme.</p>
<div className="rd-status-line">
<span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} />
<span>
{remoteDiag?.status.running
? `Aktiv auf ${remoteDiag.status.host}:${remoteDiag.status.port}${remoteDiag.status.localOnly ? " (nur lokal)" : ` (Allowlist: ${remoteDiag.status.allowlistCount})`}`
: "Inaktiv"}
</span>
</div>
<div className="rd-field">
<label>Sichtbarkeit</label>
<div className="rd-seg">
<button className={rdHostMode === "local" ? "active" : ""} onClick={() => setRdHostMode("local")}>Nur lokal</button>
<button className={rdHostMode === "network" ? "active" : ""} onClick={() => setRdHostMode("network")}>Im Netzwerk</button>
</div>
<span className="rd-hint">
{rdHostMode === "local"
? "Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante."
: "Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen."}
</span>
</div>
<div className="rd-field">
<label>Oeffentliche Adresse (fuer den Verbindungscode)</label>
<input
value={rdPublicHost}
placeholder={rdHostMode === "local" ? "127.0.0.1 oder Tunnel-Adresse" : "Server-IP oder DNS-Name"}
onChange={(event) => setRdPublicHost(event.target.value)}
/>
{remoteDiag && remoteDiag.suggestedHosts.length > 0 && (
<div className="rd-chips">
{remoteDiag.suggestedHosts.map((host) => (
<button key={host} className="rd-chip" onClick={() => setRdPublicHost(host)}>{host}</button>
))}
</div>
)}
</div>
<div className="rd-field rd-field-inline">
<div className="rd-field">
<label>Port</label>
<input value={rdPort} onChange={(event) => setRdPort(event.target.value.replace(/[^0-9]/g, ""))} />
</div>
<div className="rd-field">
<label>Name (optional)</label>
<input value={rdName} placeholder="z.B. Server-Berlin" onChange={(event) => setRdName(event.target.value)} />
</div>
</div>
{rdHostMode === "network" && (
<div className="rd-field">
<label>Allowlist - erlaubte IPs/CIDR (eine pro Zeile)</label>
<textarea
value={rdAllowlist}
placeholder={"203.0.113.5\n10.0.0.0/24"}
onChange={(event) => setRdAllowlist(event.target.value)}
/>
<span className="rd-hint">Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.</span>
</div>
)}
{remoteDiag?.status.running && remoteDiag.code && (
<div className="rd-field">
<label>Verbindungscode</label>
<div className="rd-code">{remoteDiag.code}</div>
<div className="rd-chips">
<button className="btn" onClick={() => { void onCopyRemoteDiagnosticsCode(); }}>Kopieren</button>
<button className="btn" onClick={() => { void onRotateRemoteDiagnosticsToken(); }} disabled={remoteDiagBusy}>Token neu (Code ungueltig machen)</button>
</div>
<span className="rd-hint">Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.</span>
</div>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setRemoteDiagOpen(false)}>Schliessen</button>
{remoteDiag?.status.running && (
<button className="btn danger" onClick={() => { void onDisableRemoteDiagnostics(); }} disabled={remoteDiagBusy}>Deaktivieren</button>
)}
<button className="btn accent" onClick={() => { void onSubmitRemoteDiagnostics(); }} disabled={remoteDiagBusy}>
{remoteDiag?.status.running ? "Aktualisieren" : "Aktivieren"}
</button>
</div>
</div>
</div>
)}
{deleteConfirm && (() => { {deleteConfirm && (() => {
const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length; const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length;
const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length; const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length;

View File

@ -3360,3 +3360,110 @@ td {
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; } .rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
.rotation-event .rotation-body strong { font-weight: 600; } .rotation-event .rotation-body strong { font-weight: 600; }
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); } .rotation-event .rotation-reason { color: var(--muted, #a59c8e); }
.rd-status-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text);
}
.rd-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
flex: 0 0 auto;
}
.rd-dot.on {
background: #3fb950;
}
.rd-field {
display: grid;
gap: 5px;
}
.rd-field > label {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.rd-field input,
.rd-field textarea {
width: 100%;
box-sizing: border-box;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
font-family: inherit;
}
.rd-field input:focus,
.rd-field textarea:focus {
outline: none;
border-color: var(--accent);
}
.rd-field textarea {
resize: vertical;
min-height: 62px;
}
.rd-field-inline {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.rd-hint {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
.rd-seg {
display: flex;
width: fit-content;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.rd-seg button {
background: transparent;
color: var(--muted);
border: none;
padding: 7px 14px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.rd-seg button.active {
background: var(--accent);
color: #1a1206;
}
.rd-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rd-chip {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
color: var(--muted);
}
.rd-chip:hover {
color: var(--text);
border-color: var(--accent);
}
.rd-code {
font-family: ui-monospace, "Cascadia Code", monospace;
font-size: 12px;
word-break: break-all;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 10px;
max-height: 120px;
overflow-y: auto;
color: var(--text);
}

View File

@ -52,6 +52,10 @@ export const IPC_CHANNELS = {
GET_TRACE_CONFIG: "app:get-trace-config", GET_TRACE_CONFIG: "app:get-trace-config",
SET_TRACE_ENABLED: "app:set-trace-enabled", SET_TRACE_ENABLED: "app:set-trace-enabled",
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token", ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics",
ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics",
DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics",
ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token",
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login", OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",

View File

@ -7,8 +7,10 @@ import type {
DebridLinkHostLimitInfo, DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
RemoteDiagnosticsInfo,
RendererErrorReport, RendererErrorReport,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
@ -71,6 +73,10 @@ export interface ElectronApi {
getTraceConfig: () => Promise<SupportTraceConfig>; getTraceConfig: () => Promise<SupportTraceConfig>;
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>; setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
rotateDebugToken: () => Promise<{ path: string }>; rotateDebugToken: () => Promise<{ path: string }>;
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
openRealDebridLogin: () => Promise<void>; openRealDebridLogin: () => Promise<void>;
openAllDebridLogin: () => Promise<void>; openAllDebridLogin: () => Promise<void>;
importBestDebridCookies: () => Promise<number>; importBestDebridCookies: () => Promise<number>;

View File

@ -134,6 +134,7 @@ export interface AppSettings {
hideExtractedItems: boolean; hideExtractedItems: boolean;
confirmDeleteSelection: boolean; confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean; backupIncludeDownloads: boolean;
backupIncludeMcp: boolean;
notifyUrl: string; notifyUrl: string;
notifyMention: string; notifyMention: string;
notifyOnPackageCompleted: boolean; notifyOnPackageCompleted: boolean;
@ -537,3 +538,30 @@ export interface RendererErrorReport {
column?: number; column?: number;
componentStack?: string; componentStack?: string;
} }
export interface RemoteDiagnosticsStatus {
running: boolean;
host: string;
port: number;
hasToken: boolean;
localOnly: boolean;
allowlistCount: number;
}
export interface RemoteDiagnosticsInfo {
status: RemoteDiagnosticsStatus;
code: string | null;
publicHost: string;
name: string;
allowlist: string[];
suggestedHosts: string[];
}
export interface EnableRemoteDiagnosticsInput {
hostMode: "local" | "network";
publicHost: string;
port?: number;
allowlist: string[];
name?: string;
rotateToken?: boolean;
}

View File

@ -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. 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). 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) ## Runde 1 (laeuft)
- Discover+Verify-Workflow ueber 7 Subsysteme (scheduler-slots, unrestrict-retry, mega-rotation, - Discover+Verify-Workflow ueber 7 Subsysteme (scheduler-slots, unrestrict-retry, mega-rotation,
classify-cooldown, mega-web-token, provider-chain-timeout, account-availability). classify-cooldown, mega-web-token, provider-chain-timeout, account-availability).

View File

@ -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) ## 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:** - **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 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 Mega-Debrid „API aktiv"/„Web aktiv"-Schalter noch nicht. Beim Laden wurden diese fehlenden Schalter still auf

View File

@ -1,37 +1,42 @@
# Massives Conversion-Logging (v1.7.213) + Failover-Fix # MCP-Ferndiagnose (Goal, ultracode) — v1.7.223
## Problem (live belegt, 2026-06-17) ## Ziel (Nutzer)
- Links haengen mit "Unrestrict Timeout nach 60s", R53/R64, halten Download-Slot → Stop-and-Go. "Baue massive Diagnose-Funktionen ein (MCP), so dass ich MCP auf nem Windows-Server aktivieren kann und du
- Web-first: 1 globaler 60s-Timeout um die GANZE Provider-Kette → Web frisst das Budget, auf den Server zugreifst und WIRKLICH ALLES siehst (State, Fehler, Logs, Probleme). 5-6 Server, ueber
API-Failover wird NIE versucht (debrid.ts 3801 `signal.aborted` → throw). Retry startet wieder bei Web. Verbindungscode. Du verbindest dich → liest alles → behebst Probleme direkt."
- API-first laeuft "um einiges fluessiger" (User bestaetigt: API resolved UND downloaded auf dem Server).
- ABER: API-Token-Fehler ("Token error, please log-in") + "Login oder Unrestrict fehlgeschlagen"
→ beide Accounts kriegen Cooldown → Doom-Loop. connectApi single-flightet Logins schon (pendingConnects),
also ist die Token-Ursache NICHT trivial → braucht Token-Lifecycle-Logging.
## Kern-Blindstelle ## Architektur (Advisor-bestaetigt v1)
Bestehende Logs (account-rotation.log) zeigen `elapsedMs` aber NICHT: - Standalone **stdio MCP-Bridge** auf MEINER (Claude-Code-)Maschine, proxyt zur bestehenden HTTP `debug-server.ts`
- queue-wait vs aktive Arbeit (war der 60s-Timeout Warten in der Queue oder echtes Arbeiten?) jedes Servers via **Verbindungscode**. Eine Bridge bedient alle 5-6 Server.
- Token-Lifecycle (cacheHit/freshLogin/invalidation) → woher "Token error"? - KEIN eingebettetes MCP-over-HTTP (verworfen: hand-rolled Protokoll auf Internet-Oberflaeche = mehr Risiko).
- Provider-Ketten-Entscheidung pro Item (welche Provider, welches Budget, warum Stopp) - Bridge-Code identisch fuer Direkt-IP vs spaeterer Tunnel (proxyt host:port).
- Was war in-flight als der Caller-60s-Timeout feuerte (Provider/Account/Phase) + Slot-Belegung - Verbindungscode: `rddiag:v1:<base64url(JSON {v,h:host,p:port,t:token,n?:name,fp?:certFp})>`. Nutzer liefert
oeffentlichen Host (nicht auto-detecten).
## Plan (Release 213 = NUR Logging, bewusst KEINE Verhaltensaenderung — damit das naechste Bundle das ECHTE aktuelle Verhalten zeigt) ## Sicherheitsmodell (Advisor, first-class)
- [x] `src/main/conversion-trace.ts`: AsyncLocalStorage-Trace + dedizierte `conversion.log`. EIN strukturierter Block pro Unrestrict-Versuch. Plain HTTP + Bearer ueber Internet = sniffbares Token mit Lesezugriff auf sensible Logs. Mitigations:
- [x] Wiring: init in app-controller, shutdown, support-bundle. - App-seitige IP-Allowlist (extractDebugClientIp existiert schon).
- [x] Instrumentiert (nur tracePhase-Calls, additiv, no-op ohne aktiven Trace): - `/trace/config` MUTIERT → "read-only"-Claim auditieren: Writes von Remote-Oberflaeche gaten oder umlabeln.
- download-manager unrestrict-Boundary: runWithConversionTrace + Caller-Timeout-Attribution + describeSlotOccupancy. - Opt-in + sofort widerrufbar (Token-Rotation killt Zugang).
- debrid.ts Provider-Kette: chain-try/chain-ok/chain-failed/chain-aborted (zeigt ob Failover feuert). - debug_token.txt in userData bestaetigen (ueberlebt Auto-Update).
- debrid.ts Mega-Rotation: mega-account workMs + outcome (ok/failed/fatal/aborted) + cooldown. - Optional self-signed Cert + Fingerprint im Code gepinnt (NICHT v1-blockierend).
- MegaDebridClient connectApi/doConnectApi/unrestrictViaApi: token cached/pending-join/fresh-login + connectMs + getLink response_code/text (DAS klaert "Token error").
- mega-web-fallback runExclusive: web-queue queueWaitMs + workMs (DAS klaert ob 60s = Warten oder Arbeit).
- [x] Test: conversion-trace.test.ts (Formatter + ALS-Kontext). tsc unveraendert 6.
- [ ] Build + Suite gruen. Release 213 (Gitea + Mirror).
## DEFERRED auf 214 (erst NACH Logs, kein Blind-Fix mehr) ## Phasen
- 60s global → per-Provider-Budget (Failover feuert) — proven, aber erst messen: tritt der 60s ueberhaupt bei API-first auf, und ist es Queue oder Arbeit? - [x] **P0 Vertical Slice (Diskriminator) — ERLEDIGT, harness ALL PASS:** Bridge → debug-server via stdio JSON-RPC, echte Daten zurueck.
- API Token-Error Doom-Loop ("Token error, please log-in") — Mechanismus per conversion.log verifizieren, DANN fixen (evtl. per-Account-Serialisierung fuer API wie bei Web). - [x] MCP SDK API holen (context7) → @modelcontextprotocol/sdk 1.29.0, registerTool(name,{inputSchema:zodShape},cb)
- Config-Realitaet an User: 2. Account (xe) lief abgelaufen/deaktiviert → 212-Parallelitaet griff nicht; API-first ist der schnelle Pfad. - [x] Bridge in `tools/rd-diagnostics-mcp/` (eigenes package.json, NICHT in App-Bundle): code.mjs/http.mjs/bridge.mjs/gen-code.mjs
- [x] 14 Tools: rd_servers/rd_ping/rd_diagnostics/rd_status/rd_items/rd_packages/rd_errors/rd_logs/rd_history/rd_accounts/rd_host/rd_self_check/rd_get + Multi-Server (code|server|RDDIAG_CODE|RDDIAG_SERVERS)
- [x] Verbindungscode-Codec rddiag:v1:base64url({v,h,p,t,n?,fp?,s?})
- [x] Test-Harness (test/harness.mjs): fake debug-server (auth+routes+query-echo) + Bridge als stdio-Child → 19 Checks gruen (handshake, tools/list, ping, diagnostics+query-passthrough, logs-mapping, errors, escape-hatch, 401, missing-code, unreachable+hint)
- [x] **P1 Security-Hardening — ERLEDIGT:** IP-Allowlist (exakt+CIDR), erzwungen VOR Auth am ECHTEN Socket-Peer (req.socket.remoteAddress), NICHT X-Forwarded-For (Advisor: XFF faelschbar → Bypass; gefixt+Threat-Test). Fail-closed. /trace/config belassen (zeitbegrenzt). userData/runtime verifiziert. Log-Audit: keine Secrets in /logs/*.
- [x] **P2 One-Click-Enable + Code — ERLEDIGT:** restartDebugServer ('close'+closeAllConnections, EADDRINUSE). IPC get/enable/disable/rotate + Controller + Typen. Flache Modal-UI (Hilfe→Remote-Support→"Ferndiagnose (MCP)"): Status, lokal/netzwerk, Public-Host+Chips, Allowlist, Code+Copy+Token-Rotation+Deaktivieren.
- [x] **P3 — durch bestehende Endpunkte abgedeckt:** /accounts (Cooldown/Rotation), /errors, /status, /diagnostics via Bridge. Kein neuer Endpunkt noetig.
- [x] **P4 Verify — ERLEDIGT:** Suite 906 gruen, tsc=6, Harness gruen, Advisor (fing XFF-Bypass). Release v1.7.223 Gitea (6b52678, 4 Assets) + Mirror (24485be, 4 Assets, Claude-frei). Bridge `claude mcp add` (user, ✔ Connected).
- [ ] **P5 Reachability (NUTZER):** Ferndiagnose auf 1 Server an → Code an mich → ich verbinde. Entscheid: Tunnel (sicherste, "Nur lokal") vs Direkt-Bind 0.0.0.0+Allowlist (nur vertrauenswuerdiges Netz/VPN; Token reist plain HTTP).
## Review ## Review
Logging-Release: pure Diagnose, null Verhaltensrisiko. Naechster Schritt: User reproduziert, schickt Bundle, conversion.log zeigt Queue-vs-Arbeit + Token-Lifecycle eindeutig → praeziser Fix in 214. Vertikaler Slice zuerst (Bridge gegen Fake-Debug-Server, JSON-RPC stdio), dann App-Seite load-bearing-first
(Backend+Tests vor UI). Advisor fing einen releaseblockierenden Bug: Allowlist nutzte extractDebugClientIp
(X-Forwarded-For zuerst = angreiferkontrolliert) → Bypass per `X-Forwarded-For: 127.0.0.1`; Tests maskierten es
(injizierten die IP per genau dem Header). Fix: Enforcement am Socket-Peer, XFF nur fuers Log; Threat-Test
(socket 8.8.8.8 + XFF 127.0.0.1 → denied). Empfohlener Transport: Loopback+Tunnel; Direkt-Bind nur mit Allowlist.

232
tests/backup-mcp.test.ts Normal file
View File

@ -0,0 +1,232 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { buildBackupPayload, resolveMcpRemoteRestore, BackupMcpRemote } from "../src/main/backup-payload";
import { defaultSettings } from "../src/main/constants";
import { normalizeSettings } from "../src/main/storage";
import {
startDebugServer,
stopDebugServer,
restartDebugServer,
writeDebugServerConfig,
getDebugAllowlist,
getDebugServerRuntimeStatus
} from "../src/main/debug-server";
import type { DownloadManager } from "../src/main/download-manager";
import type { AppSettings, SessionState } from "../src/shared/types";
const tempDirs: string[] = [];
function input(settingsOverride: Partial<AppSettings>, mcpRemote?: BackupMcpRemote) {
return {
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
appVersion: "1.7.224",
exportedAt: "2026-06-19T00:00:00.000Z",
session: {} as unknown as SessionState,
history: [],
mcpRemote
};
}
async function getFreePort(): Promise<number> {
const probe = http.createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const address = probe.address();
if (!address || typeof address === "string") {
throw new Error("port probe failed");
}
probe.close();
await once(probe, "close");
return address.port;
}
async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`debug server not ready: ${url}`);
}
afterEach(() => {
stopDebugServer();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("backup mcpRemote export gating", () => {
it("includes mcpRemote when backupIncludeMcp is on", () => {
const section: BackupMcpRemote = { allowlist: ["10.0.0.5", "192.168.1.0/24"], port: 9999, hostMode: "network" };
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, section));
expect(payload.mcpRemote).toEqual(section);
});
it("omits mcpRemote when the toggle is off even if a section is provided", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
expect(payload.mcpRemote).toBeUndefined();
});
it("omits mcpRemote when toggle on but no section gathered", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, undefined));
expect(payload.mcpRemote).toBeUndefined();
});
it("the mcpRemote section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
expect(payload.mcpRemote && Object.keys(payload.mcpRemote).sort()).toEqual(["allowlist", "hostMode", "port"]);
const sectionJson = JSON.stringify(payload.mcpRemote);
expect(sectionJson.toLowerCase()).not.toContain("token");
expect(sectionJson).not.toContain("publicHost");
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
});
});
describe("backupIncludeMcp settings persistence", () => {
it("normalizeSettings preserves backupIncludeMcp (the toggle survives save/load)", () => {
expect(normalizeSettings({ backupIncludeMcp: true } as unknown as AppSettings).backupIncludeMcp).toBe(true);
expect(normalizeSettings({ backupIncludeMcp: false } as unknown as AppSettings).backupIncludeMcp).toBe(false);
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeMcp).toBe(false);
});
});
describe("resolveMcpRemoteRestore", () => {
it("maps network + non-empty allowlist to 0.0.0.0", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
});
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
expect(resolveMcpRemoteRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
});
it("maps local to 127.0.0.1", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
});
it("rejects an out-of-range or non-integer port", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
});
it("filters non-string and blank allowlist entries and trims", () => {
const r = resolveMcpRemoteRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
});
it("returns null for missing or empty/invalid sections", () => {
expect(resolveMcpRemoteRestore(undefined)).toBeNull();
expect(resolveMcpRemoteRestore(null)).toBeNull();
expect(resolveMcpRemoteRestore("x")).toBeNull();
expect(resolveMcpRemoteRestore({})).toBeNull();
});
});
describe("backup mcpRemote live restore round-trip", () => {
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp-"));
tempDirs.push(baseDir);
const startPort = await getFreePort();
const restorePort = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`);
expect(getDebugAllowlist()).toEqual([]);
const payload = buildBackupPayload(input(
{ backupIncludeMcp: true },
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
));
const restore = resolveMcpRemoteRestore(payload.mcpRemote);
expect(restore).not.toBeNull();
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
const status = await restartDebugServer();
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
expect(status.port).toBe(restorePort);
expect(status.host).toBe("0.0.0.0");
expect(status.allowlistCount).toBe(2);
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`);
});
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp2-"));
tempDirs.push(baseDir);
const startPort = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
const restore = resolveMcpRemoteRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100");
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
});
});
describe("debug-server live diagnostics endpoints", () => {
it("serves /providers (live cooldown/runtime snapshot) and /logs/conversion over authenticated HTTP", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-"));
tempDirs.push(baseDir);
const port = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`);
const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`);
expect(provRes.status).toBe(200);
const prov = await provRes.json();
expect(typeof prov.capturedAtMs).toBe("number");
expect(prov.megaDebrid).toBeTruthy();
expect(Array.isArray(prov.megaDebrid.accounts)).toBe(true);
expect(typeof prov.megaDebrid.rotationCursor).toBe("number");
expect(prov.debridLink).toBeTruthy();
expect(Array.isArray(prov.debridLink.keys)).toBe(true);
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
expect(unauth.status).toBe(401);
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`);
expect(convRes.status).toBe(200);
const conv = await convRes.json();
expect(Array.isArray(conv.lines)).toBe(true);
expect(conv).toHaveProperty("available");
});
});

View File

@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { encodeConnectionCode } from "../src/main/connection-code";
import { decodeConnectionCode } from "../tools/rd-diagnostics-mcp/src/code.mjs";
describe("connection-code", () => {
it("round-trips through the bridge decoder", () => {
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
expect(code.startsWith("rddiag:v1:")).toBe(true);
const decoded = decodeConnectionCode(code);
expect(decoded.host).toBe("203.0.113.5");
expect(decoded.port).toBe(9868);
expect(decoded.token).toBe("deadbeef");
expect(decoded.name).toBe("server-1");
expect(decoded.scheme).toBe("http");
});
it("carries https scheme and fingerprint when set", () => {
const code = encodeConnectionCode({
host: "diag.example.com",
port: 8443,
token: "abc",
scheme: "https",
fingerprint: "AA:BB:CC"
});
const decoded = decodeConnectionCode(code);
expect(decoded.scheme).toBe("https");
expect(decoded.fingerprint).toBe("AA:BB:CC");
});
it("omits scheme key for plain http (default)", () => {
const code = encodeConnectionCode({ host: "10.0.0.2", port: 9868, token: "t" });
const json = JSON.parse(Buffer.from(code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
expect(json.s).toBeUndefined();
expect(json).toMatchObject({ v: 1, h: "10.0.0.2", p: 9868, t: "t" });
});
it("rejects invalid input", () => {
expect(() => encodeConnectionCode({ host: "", port: 9868, token: "t" })).toThrow();
expect(() => encodeConnectionCode({ host: "h", port: 0, token: "t" })).toThrow();
expect(() => encodeConnectionCode({ host: "h", port: 9868, token: "" })).toThrow();
});
});

View File

@ -3,7 +3,8 @@ 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, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@ -572,6 +573,78 @@ describe("debrid service", () => {
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready"); 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<Response> => {
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<Response> => {
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 () => { it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
@ -1998,9 +2071,76 @@ describe("debrid service", () => {
expect(calls).toBeGreaterThanOrEqual(1); expect(calls).toBeGreaterThanOrEqual(1);
}, 20000); }, 20000);
it("getProviderRuntimeSnapshot surfaces a live Mega-Debrid account cooldown (until/remaining/reason) for the diagnostics endpoint", () => {
const accId = getMegaDebridAccountId("user");
const key = `${accId}:web`;
expect(getProviderRuntimeSnapshot().megaDebrid.accounts.find((a) => a.key === key)?.cooldown ?? null).toBeNull();
primeMegaDebridRuntimeCooldownForTests(key, 90_000, "Abbruch/Timeout nach 60s");
const snap = getProviderRuntimeSnapshot();
expect(typeof snap.capturedAtMs).toBe("number");
const acc = snap.megaDebrid.accounts.find((a) => a.key === key);
expect(acc).toBeTruthy();
expect(acc!.cooldown).not.toBeNull();
expect(acc!.cooldown!.remainingMs).toBeGreaterThan(0);
expect(acc!.cooldown!.remainingMs).toBeLessThanOrEqual(90_000);
expect(acc!.cooldown!.untilMs).toBeGreaterThan(snap.capturedAtMs);
expect(acc!.cooldown!.message).toContain("Abbruch");
});
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const controller = new AbortController();
let calls = 0;
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
calls += 1;
if (calls === 1) {
controller.abort("simulated-60s-timeout");
return Promise.reject(new Error("aborted"));
}
return Promise.resolve({
fileName: "healthy.rar",
directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar",
fileSize: null,
retriesUsed: 0
});
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e);
expect(err).toBeTruthy();
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
const key = `${getMegaDebridAccountId("user")}:web`;
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
const second = await service.unrestrictLink("https://rapidgator.net/file/healthy.rar.html");
expect(second.provider).toBe("megadebrid");
expect(calls).toBeGreaterThanOrEqual(2);
}, 20000);
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => { it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
const key = `${getMegaDebridAccountId("user1")}:web`; const key = `${getMegaDebridAccountId("user1")}:web`;
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3); expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(10);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1); expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2); expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3); expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
@ -2028,6 +2168,14 @@ describe("debrid service", () => {
expect(genuineEmpty.limitSignal).toBe(true); expect(genuineEmpty.limitSignal).toBe(true);
}); });
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => {
const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"));
expect(result.fatal).toBe(false);
expect(result.cooldownMs).toBe(0);
expect(result.limitSignal).toBeFalsy();
expect(isMegaDebridTransientResolveFailure(result.message)).toBe(true);
});
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => { it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
@ -2117,8 +2265,9 @@ describe("debrid service", () => {
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const key = `${getMegaDebridAccountId("user1")}:web`; const key = `${getMegaDebridAccountId("user1")}:web`;
for (let i = 0; i < MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART - 1; i += 1) {
recordMegaDebridEmptyResponseStreak(key); recordMegaDebridEmptyResponseStreak(key);
recordMegaDebridEmptyResponseStreak(key); }
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false); expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
const megaWeb = vi.fn(async () => null); const megaWeb = vi.fn(async () => null);

View File

@ -0,0 +1,146 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import {
startDebugServer,
stopDebugServer,
restartDebugServer,
writeDebugServerConfig,
getDebugServerRuntimeStatus,
evaluateClientAllowed,
getPeerIp
} from "../src/main/debug-server";
import type { DownloadManager } from "../src/main/download-manager";
const tempDirs: string[] = [];
const TOKEN = "allowlist-secret";
async function getFreePort(): Promise<number> {
const probe = http.createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const address = probe.address();
if (!address || typeof address === "string") {
throw new Error("port probe failed");
}
probe.close();
await once(probe, "close");
return address.port;
}
async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`debug server not ready: ${url}`);
}
async function startWithAllowlist(allowlist: string[], host = "0.0.0.0"): Promise<{ baseUrl: string }> {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-"));
tempDirs.push(baseDir);
const port = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8");
const manager = {} as unknown as DownloadManager;
startDebugServer(manager, baseDir);
const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
return { baseUrl };
}
afterEach(() => {
stopDebugServer();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("debug-server allowlist matcher (pure)", () => {
it("always allows loopback regardless of rules", () => {
expect(evaluateClientAllowed("127.0.0.1", [])).toBe(true);
expect(evaluateClientAllowed("::1", [])).toBe(true);
expect(evaluateClientAllowed("::ffff:127.0.0.1", ["8.8.8.8"])).toBe(true);
});
it("matches an exact allowlisted IP and rejects others", () => {
expect(evaluateClientAllowed("8.8.8.8", ["8.8.8.8"])).toBe(true);
expect(evaluateClientAllowed("9.9.9.9", ["8.8.8.8"])).toBe(false);
});
it("matches inside a CIDR and rejects outside it", () => {
expect(evaluateClientAllowed("10.0.0.42", ["10.0.0.0/24"])).toBe(true);
expect(evaluateClientAllowed("10.0.1.42", ["10.0.0.0/24"])).toBe(false);
});
it("fail-closed: empty rules reject every non-loopback client", () => {
expect(evaluateClientAllowed("203.0.113.7", [])).toBe(false);
expect(evaluateClientAllowed("8.8.8.8", [])).toBe(false);
});
it("derives the client IP from the socket peer, never from X-Forwarded-For", () => {
const forgedLoopback = {
socket: { remoteAddress: "8.8.8.8" },
headers: { "x-forwarded-for": "127.0.0.1" }
} as unknown as http.IncomingMessage;
expect(getPeerIp(forgedLoopback)).toBe("8.8.8.8");
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), [])).toBe(false);
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["9.9.9.9"])).toBe(false);
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["8.8.8.8"])).toBe(true);
const ipv6Mapped = {
socket: { remoteAddress: "::ffff:10.0.0.5" },
headers: {}
} as unknown as http.IncomingMessage;
expect(getPeerIp(ipv6Mapped)).toBe("10.0.0.5");
});
});
describe("debug-server allowlist enforcement (wired)", () => {
it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`);
expect(plain.status).toBe(200);
const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, {
headers: { "X-Forwarded-For": "203.0.113.9" }
});
expect(spoofed.status).toBe(200);
});
it("still enforces the token for loopback clients", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const res = await fetch(`${baseUrl}/health`);
expect(res.status).toBe(401);
});
it("reloads the allowlist live via restartDebugServer", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1);
writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] });
const status = await restartDebugServer();
expect(status.running).toBe(true);
expect(status.allowlistCount).toBe(2);
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200);
});
});

View File

@ -14,7 +14,7 @@ import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { createStoragePaths, emptySession } from "../src/main/storage"; import { createStoragePaths, emptySession } from "../src/main/storage";
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
import { UnrestrictedLink } from "../src/main/realdebrid"; import { UnrestrictedLink } from "../src/main/realdebrid";
@ -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<Response> => {
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 () => { it("requests a fresh direct link after repeated same-link download failures", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
@ -2115,6 +2209,98 @@ describe("download manager", () => {
expect(fs.statSync(item.targetPath).size).toBe(binary.length); expect(fs.statSync(item.targetPath).size).toBe(binary.length);
}); });
it("recovers an HTTP 416 item with a clean fresh restart after the in-budget retries are exhausted", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-"));
tempDirs.push(root);
const binary = Buffer.alloc(160 * 1024, 19);
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
let downloadCalls = 0;
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/unrestrict/link")) {
return new Response(JSON.stringify({ download: "https://dummy/direct-416-recover", filename: "fresh-416.mkv", filesize: binary.length }), { status: 200, headers: { "Content-Type": "application/json" } });
}
throw new Error(`unexpected fetch ${url}`);
};
try {
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 2, autoExtract: false, autoReconnect: false },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
downloadCalls += 1;
if (downloadCalls <= 3) {
throw new Error("HTTP 416");
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, binary);
const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined;
if (item) {
item.downloadedBytes = binary.length;
item.totalBytes = binary.length;
item.progressPercent = 100;
}
return { resumable: true };
};
manager.addPackages([{ name: "fresh-416", links: ["https://dummy/fresh-416"] }]);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 20000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item?.status).toBe("completed");
expect(downloadCalls).toBeGreaterThan(3);
} finally {
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
}
}, 25000);
it("bounds HTTP 416 clean restarts and finally fails instead of looping forever or stalling permanently", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-cap-"));
tempDirs.push(root);
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
let downloadCalls = 0;
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/unrestrict/link")) {
return new Response(JSON.stringify({ download: "https://dummy/direct-416-forever", filename: "always-416.mkv", filesize: 1024 }), { status: 200, headers: { "Content-Type": "application/json" } });
}
throw new Error(`unexpected fetch ${url}`);
};
try {
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 0, autoExtract: false, autoReconnect: false },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
(manager as any).downloadToFile = async () => {
downloadCalls += 1;
throw new Error("direct_link_retry_exhausted:HTTP 416");
};
manager.addPackages([{ name: "always-416", links: ["https://dummy/always-416"] }]);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 20000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item?.status).toBe("failed");
expect(downloadCalls).toBeGreaterThan(4);
expect(downloadCalls).toBeLessThan(30);
expect((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined();
} finally {
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
}
}, 25000);
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => { it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
@ -12150,3 +12336,200 @@ describe("download manager", () => {
} }
}); });
}); });
describe("start conflict guard + selective resume", () => {
function makeItem(id: string, packageId: string, status: string, fileName: string): any {
return {
id, packageId, url: `https://hoster.example/${id}`, provider: "realdebrid",
status, retries: 0, speedBps: 0, downloadedBytes: status === "completed" ? 100 : 0,
totalBytes: status === "completed" ? 100 : null, progressPercent: status === "completed" ? 100 : 0,
fileName, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
createdAt: Date.now(), updatedAt: Date.now()
};
}
it("does not flag a partially-downloaded package whose extract dir holds its own completed output", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-own-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const packageId = "pkg-own-output";
const extractDir = path.join(root, "extract", "OwnOutput");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "episode01.mkv"), Buffer.alloc(64, 7));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId, name: "OwnOutput",
outputDir: path.join(root, "downloads", "OwnOutput"), extractDir,
status: "queued", itemIds: ["own-done", "own-pending"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items["own-done"] = makeItem("own-done", packageId, "completed", "done.rar");
session.items["own-pending"] = makeItem("own-pending", packageId, "queued", "pending.rar");
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session, storagePaths
);
const conflicts = await manager.getStartConflicts();
expect(conflicts.map((c) => c.packageId)).not.toContain(packageId);
});
it("flags a fresh package when its package-specific extract dir already holds files and it has no completed items", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-fresh-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const packageId = "pkg-fresh-conflict";
const extractDir = path.join(root, "extract", "FreshConflict");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "old-from-previous-run.mkv"), Buffer.alloc(64, 9));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId, name: "FreshConflict",
outputDir: path.join(root, "downloads", "FreshConflict"), extractDir,
status: "queued", itemIds: ["fresh-pending"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items["fresh-pending"] = makeItem("fresh-pending", packageId, "queued", "fresh.rar");
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session, storagePaths
);
const conflicts = await manager.getStartConflicts();
expect(conflicts.map((c) => c.packageId)).toContain(packageId);
});
it("start() holds excluded packages out of the run set and runs the rest", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selective-resume-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const runId = "pkg-run";
const holdId = "pkg-hold";
fs.mkdirSync(path.join(root, "downloads", "RunMe"), { recursive: true });
fs.mkdirSync(path.join(root, "downloads", "HoldMe"), { recursive: true });
session.packageOrder = [runId, holdId];
session.packages[runId] = {
id: runId, name: "RunMe",
outputDir: path.join(root, "downloads", "RunMe"), extractDir: path.join(root, "extract", "RunMe"),
status: "queued", itemIds: ["run-item"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.packages[holdId] = {
id: holdId, name: "HoldMe",
outputDir: path.join(root, "downloads", "HoldMe"), extractDir: path.join(root, "extract", "HoldMe"),
status: "queued", itemIds: ["hold-item"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items["run-item"] = makeItem("run-item", runId, "queued", "run.rar");
session.items["hold-item"] = makeItem("hold-item", holdId, "queued", "hold.rar");
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", maxParallel: 2, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session, storagePaths
);
(manager as any).debridService.unrestrictLink = () => new Promise(() => {});
await manager.start({ excludePackageIds: new Set([holdId]) });
expect((manager as any).runPackageIds.has(runId)).toBe(true);
expect((manager as any).runPackageIds.has(holdId)).toBe(false);
expect((manager as any).runItemIds.has("run-item")).toBe(true);
expect((manager as any).runItemIds.has("hold-item")).toBe(false);
expect(session.items["hold-item"].status).toBe("queued");
manager.stop();
});
});
describe("mega-debrid api/web resolution overlap gate", () => {
function megaApiSettings(root: string): any {
return {
...defaultSettings(),
megaLogin: "u", megaPassword: "p", megaCredentials: "u:p",
megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true,
outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract")
};
}
function megaItem(id: string, status: string): any {
return {
id, packageId: "pkg", url: `https://rapidgator.net/file/${id}`, provider: "megadebrid-api",
status, retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, progressPercent: 0,
fileName: `${id}.rar`, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
createdAt: Date.now(), updatedAt: Date.now()
};
}
function addValidating(manager: DownloadManager, session: any, ids: string[]): void {
for (const id of ids) {
session.items[id] = megaItem(id, "validating");
(manager as any).activeTasks.set(id, {
itemId: id, packageId: "pkg", abortController: new AbortController(), abortReason: "none",
resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0
});
}
}
function buildManager(root: string, session: any): DownloadManager {
return new DownloadManager(megaApiSettings(root), session, createStoragePaths(path.join(root, "state")));
}
it("lets the first mega resolve start when nothing is in flight", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-0-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
});
it("serializes a second API resolve while the first is still in its API phase (no concurrent API)", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-1-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
addValidating(manager, session, ["a"]);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
});
it("allows one API resolve to overlap once the first has moved to its web phase", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-2-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
addValidating(manager, session, ["a"]);
primeMegaDebridInFlightForTests("acc:web", 1);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
});
it("caps the overlap at one API plus one web (no third concurrent resolve)", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-3-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
addValidating(manager, session, ["a", "b"]);
primeMegaDebridInFlightForTests("acc:web", 1);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
});
});

Binary file not shown.

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark } from "../src/main/download-manager"; import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } 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", () => {
@ -63,6 +63,31 @@ describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay
}); });
}); });
describe("parseMegaDebridSlowLinkRetry (park only the slow link, never the account)", () => {
it("parses the encoded delay from a slow-link error", () => {
const r = parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:120000:Mega-Debrid (Account 1/1, Su******e3): aborted");
expect(r).not.toBeNull();
expect(r!.delayMs).toBe(120000);
expect(r!.detail).toContain("Mega-Debrid");
});
it("parses it when embedded in the aggregated provider-chain error", () => {
const aggregated = "Provider-Kette: Mega-Debrid Web fehlgeschlagen (Error: mega_debrid_slow_link:90000:Mega-Debrid (Account 1/1): aborted)";
expect(parseMegaDebridSlowLinkRetry(aggregated)!.delayMs).toBe(90000);
});
it("clamps to [1s, 15min]", () => {
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:1:x")!.delayMs).toBe(1000);
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
});
it("does not collide with the account-cooldown or reset-park tokens", () => {
expect(parseMegaDebridSlowLinkRetry("mega_debrid_cooldown:20330:x")).toBeNull();
expect(parseMegaDebridSlowLinkRetry("mega_debrid_reset_park:43200000:x")).toBeNull();
expect(parseMegaDebridCooldownRetry("mega_debrid_slow_link:120000:x")).toBeNull();
});
});
describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => { describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => {
it("parses the encoded until-reset delay from the park token", () => { 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)"); const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)");

View File

@ -0,0 +1,62 @@
# rd-diagnostics-mcp
Standalone **stdio MCP bridge** to the Real-Debrid-Downloader debug-server. It runs on the machine where the
MCP client runs, takes a **connection code** for a downloader server, and exposes that server's
read-only HTTP diagnostics API (`/diagnostics`, `/status`, `/errors`, `/logs/*`, `/accounts`, …) as MCP tools.
One bridge serves all 56 servers; you pass a `code` (or a configured `server` name) per call.
This bridge is **not** bundled into the Electron app and adds **no** dependencies to it.
## Setup
```bash
cd tools/rd-diagnostics-mcp
npm install
```
Register it with your MCP client as a stdio server that launches the bridge:
```bash
node "<repo>/tools/rd-diagnostics-mcp/src/bridge.mjs"
```
Provide servers via environment variables (codes contain a token — treat like passwords):
- `RDDIAG_CODE` — a single default connection code (`rddiag:v1:...`)
- `RDDIAG_SERVERS` — JSON map of name → code, e.g. `{"berlin":"rddiag:v1:...","fra":"rddiag:v1:..."}`
Without env config, every tool simply takes a `code` argument.
## Tools
`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`
(`main|audit|rename|trace|session|conversion|package|item`), `rd_history`, `rd_accounts`, `rd_providers`
(live per-account/key cooldown + in-flight + rotation state), `rd_host`, `rd_self_check`,
`rd_get` (raw escape-hatch, any read-only path).
Each tool accepts `code` or `server` to pick the target.
## Connection code
Format: `rddiag:v1:<base64url(JSON)>` with `{ v:1, h:host, p:port, t:token, n?:name, fp?:certFingerprint, s?:scheme }`.
Generated by the app (Hilfe → Remote-Support → Ferndiagnose (MCP)) or via `node src/gen-code.mjs --host H --port P --token T`.
## Security model (read before exposing a server)
- The debug surface is **read-only** for state/logs; the one control endpoint is `/trace/config` (toggles the
optional, time-bounded support trace). No persistent secrets are written into the logs it serves: passwords are
redacted, debrid API keys/tokens and resolved download URLs are never logged; `/settings` and `/accounts` are redacted.
- Auth is a bearer token (24 random bytes). Over plain HTTP on a public network the token is sniffable, so:
- **Preferred:** keep the server bound to `127.0.0.1` ("Nur lokal") and reach it through a private tunnel
(Tailscale / SSH / Cloudflare Tunnel). The tunnel encrypts and authenticates; no public exposure.
- **Direct network bind (`0.0.0.0`)** requires a non-empty **IP allowlist** (enforced fail-closed: with an empty
allowlist only loopback is accepted). Use only inside a trusted LAN/VPN.
- Revoke instantly from the app ("Token neu" or "Deaktivieren") — the old code stops working immediately.
- `fp` pins a self-signed cert fingerprint and is verified on `secureConnect` (before the token is sent). HTTPS is
not the v1 default; plain HTTP behind a tunnel is the recommended transport.
## Test
```bash
npm test # spins a fake debug-server, runs the bridge as a stdio child, asserts the full protocol path
```

1172
tools/rd-diagnostics-mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
{
"name": "rd-diagnostics-mcp",
"version": "1.0.0",
"private": true,
"description": "Standalone stdio MCP bridge to the Real-Debrid-Downloader debug-server. Connects via connection code, proxies the read-only HTTP diagnostics API as MCP tools.",
"type": "module",
"bin": {
"rd-diagnostics-mcp": "src/bridge.mjs"
},
"scripts": {
"start": "node src/bridge.mjs",
"test": "node test/harness.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.23.8"
}
}

View File

@ -0,0 +1,332 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { decodeConnectionCode } from "./code.mjs";
import { debugGet } from "./http.mjs";
function loadServerMap() {
const map = new Map();
const raw = process.env.RDDIAG_SERVERS;
if (raw) {
try {
const parsed = JSON.parse(raw);
for (const [name, code] of Object.entries(parsed || {})) {
map.set(String(name), String(code));
}
} catch {
process.stderr.write("rd-diagnostics-mcp: RDDIAG_SERVERS ist kein gueltiges JSON, wird ignoriert\n");
}
}
return map;
}
const SERVER_MAP = loadServerMap();
const DEFAULT_CODE = process.env.RDDIAG_CODE ? String(process.env.RDDIAG_CODE) : "";
function listAvailableServers() {
const names = [...SERVER_MAP.keys()];
if (DEFAULT_CODE) names.push("(RDDIAG_CODE-Default)");
return names;
}
function resolveTarget(args) {
let code = "";
if (args && args.code) {
code = String(args.code);
} else if (args && args.server) {
const found = SERVER_MAP.get(String(args.server));
if (!found) {
throw new Error(
`Server "${args.server}" nicht konfiguriert. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
);
}
code = found;
} else if (DEFAULT_CODE) {
code = DEFAULT_CODE;
} else if (SERVER_MAP.size === 1) {
code = [...SERVER_MAP.values()][0];
} else {
throw new Error(
`Kein Verbindungscode. Uebergib "code" oder "server", oder setze RDDIAG_CODE/RDDIAG_SERVERS. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
);
}
return decodeConnectionCode(code);
}
function targetLabel(target) {
return target.name ? `${target.name} (${target.host}:${target.port})` : `${target.host}:${target.port}`;
}
function buildQuery(params) {
const usable = Object.entries(params || {}).filter(
([, v]) => v !== undefined && v !== null && String(v).length > 0
);
if (usable.length === 0) return "";
const sp = new URLSearchParams();
for (const [k, v] of usable) sp.set(k, String(v));
return "?" + sp.toString();
}
function prettyBody(body) {
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch {
return body;
}
}
function connectionHint(err) {
const m = String((err && err.code) || err && err.message || "");
if (/ECONNREFUSED/.test(m)) return "Debug-Server nicht erreichbar — auf dem Server aktiviert? Port/Firewall offen?";
if (/ENOTFOUND|EAI_AGAIN/.test(m)) return "Host nicht aufloesbar — stimmt die Adresse im Verbindungscode?";
if (/ETIMEDOUT|Zeitueberschreitung/.test(m)) return "Zeitueberschreitung — Server/Netz langsam oder Port geblockt.";
if (/ECONNRESET|EPIPE/.test(m)) return "Verbindung abgebrochen — falscher Port/Scheme (http vs https)?";
if (/Fingerprint/.test(m)) return "TLS-Fingerprint passt nicht — Code stammt evtl. von einem anderen Server.";
return "";
}
async function requestTool(args, path, params, opts = {}) {
let target;
try {
target = resolveTarget(args);
} catch (err) {
return { content: [{ type: "text", text: `# Verbindungsfehler\n${err.message}` }], isError: true };
}
const fullPath = path + buildQuery(params);
const label = targetLabel(target);
try {
const res = await debugGet(target, fullPath, { timeoutMs: opts.timeoutMs || 20000 });
const isError = res.status < 200 || res.status >= 300;
let extra = "";
if (res.status === 401) extra = "\n(401 = Token im Verbindungscode ist abgelaufen/rotiert. Neuen Code anfordern.)";
if (res.status === 503) extra = "\n(503 = Download-Manager nicht bereit. App laeuft, aber noch nicht initialisiert?)";
const head = `# ${label} ${fullPath} → HTTP ${res.status}${extra}`;
return { content: [{ type: "text", text: head + "\n" + prettyBody(res.body) }], isError };
} catch (err) {
const hint = connectionHint(err);
const text = `# ${label} ${fullPath} → FEHLER\n${err.message}${hint ? "\n→ " + hint : ""}`;
return { content: [{ type: "text", text }], isError: true };
}
}
const CODE_FIELD = {
code: z.string().optional().describe("Verbindungscode (rddiag:v1:...). Optional, wenn server/RDDIAG_CODE gesetzt ist."),
server: z.string().optional().describe("Name eines via RDDIAG_SERVERS konfigurierten Servers statt eines vollen Codes.")
};
const server = new McpServer({ name: "rd-diagnostics-mcp", version: "1.0.0" });
server.registerTool(
"rd_servers",
{
title: "Konfigurierte Server",
description: "Listet die in dieser Bridge konfigurierten Server (RDDIAG_SERVERS / RDDIAG_CODE). Verbindet sich nicht.",
inputSchema: {}
},
async () => {
const names = [...SERVER_MAP.keys()];
const lines = [];
lines.push(`Konfigurierte Server: ${names.length}`);
for (const n of names) lines.push(`- ${n}`);
lines.push(`Default (RDDIAG_CODE): ${DEFAULT_CODE ? "gesetzt" : "nicht gesetzt"}`);
lines.push("");
lines.push("Tools akzeptieren entweder code:<rddiag:v1:...> oder server:<name>.");
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
server.registerTool(
"rd_ping",
{
title: "Erreichbarkeit pruefen",
description: "Schneller Health-Check (GET /health): App-Version, Uptime, Speicher. Zuerst aufrufen, um Erreichbarkeit + Token zu pruefen.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/health", {}, { timeoutMs: 10000 })
);
server.registerTool(
"rd_diagnostics",
{
title: "Gesamtdiagnose",
description: "Aggregierter Zustand (GET /diagnostics): Meta, Status, Settings, Stats, Accounts, History, Host + die wichtigsten Logs. Der 'alles auf einen Blick'-Endpunkt.",
inputSchema: {
...CODE_FIELD,
lines: z.number().int().positive().optional().describe("Anzahl Log-Zeilen pro Log (Default 150)."),
grep: z.string().optional().describe("Filter fuer Log-Zeilen."),
package: z.string().optional().describe("Optional auf ein Paket fokussieren.")
}
},
async (args) => requestTool(args, "/diagnostics", { lines: args.lines, grep: args.grep, package: args.package }, { timeoutMs: 30000 })
);
server.registerTool(
"rd_status",
{
title: "Live-Status",
description: "Laufzeit-Status (GET /status): aktive Downloads, Queue, Provider-Zustand.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/status", {})
);
server.registerTool(
"rd_items",
{
title: "Download-Items",
description: "Einzelne Download-Items (GET /items), optional gefiltert nach Status/Paket.",
inputSchema: {
...CODE_FIELD,
status: z.string().optional().describe("Status-Filter (z.B. downloading, error, done)."),
package: z.string().optional().describe("Paket-Filter.")
}
},
async (args) => requestTool(args, "/items", { status: args.status, package: args.package })
);
server.registerTool(
"rd_packages",
{
title: "Pakete",
description: "Pakete (GET /packages), optional mit enthaltenen Items.",
inputSchema: {
...CODE_FIELD,
package: z.string().optional().describe("Bestimmtes Paket."),
includeItems: z.boolean().optional().describe("Items mitliefern.")
}
},
async (args) => requestTool(args, "/packages", { package: args.package, includeItems: args.includeItems ? "1" : "" })
);
server.registerTool(
"rd_errors",
{
title: "Letzte Fehler",
description: "Fehler-Ring (GET /errors): die letzten Fehler mit Level/Quelle. 'Was ist schiefgelaufen'.",
inputSchema: {
...CODE_FIELD,
level: z.string().optional().describe("Level-Filter (ERROR, WARN, ...)."),
limit: z.number().int().positive().optional().describe("Anzahl (Default 100).")
}
},
async (args) => requestTool(args, "/errors", { level: args.level, limit: args.limit })
);
const LOG_PATHS = {
main: "/logs/main",
audit: "/logs/audit",
rename: "/logs/rename",
trace: "/logs/trace",
session: "/logs/session",
conversion: "/logs/conversion",
package: "/logs/package",
item: "/logs/item"
};
server.registerTool(
"rd_logs",
{
title: "Log lesen",
description: "Liest das Ende eines Logs (GET /logs/<name>). name: main|audit|rename|trace|session|conversion|package|item. conversion = Pro-Item Link-Aufloesungs-Lebenszyklus (Token, API, Web, Rotation, Abbrueche mit Zeiten). Fuer package/item zusaetzlich package/item angeben.",
inputSchema: {
...CODE_FIELD,
name: z.enum(["main", "audit", "rename", "trace", "session", "conversion", "package", "item"]).describe("Welches Log."),
lines: z.number().int().positive().optional().describe("Anzahl Zeilen vom Ende (Default 100)."),
grep: z.string().optional().describe("Filter."),
package: z.string().optional().describe("Nur fuer name=package."),
item: z.string().optional().describe("Nur fuer name=item.")
}
},
async (args) => {
const path = LOG_PATHS[args.name];
return requestTool(args, path, { lines: args.lines, grep: args.grep, package: args.package, item: args.item });
}
);
server.registerTool(
"rd_history",
{
title: "Verlauf",
description: "Abgeschlossener Verlauf (GET /history), optional nach Status/Suchbegriff.",
inputSchema: {
...CODE_FIELD,
limit: z.number().int().positive().optional().describe("Anzahl (Default 50)."),
status: z.string().optional().describe("Status-Filter."),
grep: z.string().optional().describe("Suchbegriff.")
}
},
async (args) => requestTool(args, "/history", { limit: args.limit, status: args.status, grep: args.grep })
);
server.registerTool(
"rd_accounts",
{
title: "Accounts",
description: "Debrid-Accounts (GET /accounts, Token redigiert): Gueltigkeit, Premium, Cooldown/Rotation.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/accounts", {})
);
server.registerTool(
"rd_providers",
{
title: "Provider-Laufzeitzustand",
description: "Live Provider-Runtime (GET /providers): pro Mega-Account/Debrid-Link-Key der AKTIVE Cooldown (until/remainingMs/Grund/Kategorie), in-flight-Tiefe, Mega-Rotationscursor, Empty-Response-Streaks. Die 'warum kuehlt es JETZT ab'-Ansicht — beantwortet Cooldown-Fragen direkt statt aus Log-Arithmetik.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/providers", {})
);
server.registerTool(
"rd_host",
{
title: "Host-Diagnose",
description: "Windows-Host-Diagnose (GET /host/diagnostics): Laufwerke, Speicher, Pfade.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/host/diagnostics", {})
);
server.registerTool(
"rd_self_check",
{
title: "Self-Check",
description: "Setup/Self-Check (GET /self-check): erkennt Konfigurations-/Pfadprobleme.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/self-check", {})
);
server.registerTool(
"rd_get",
{
title: "Roh-Endpunkt (Escape-Hatch)",
description: "Beliebigen Debug-Server-Pfad lesen (GET <path>), wenn kein spezialisiertes Tool passt. Pfad inkl. fuehrendem / und optionalem Query-String, z.B. /meta oder /stats.",
inputSchema: {
...CODE_FIELD,
path: z.string().describe("Pfad mit fuehrendem /, optional ?query. Nur GET, read-only.")
}
},
async (args) => {
const p = String(args.path || "");
if (!p.startsWith("/")) {
return { content: [{ type: "text", text: "# Fehler\npath muss mit / beginnen" }], isError: true };
}
return requestTool(args, p, {}, { timeoutMs: 30000 });
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
process.stderr.write(
`rd-diagnostics-mcp bereit. Server: ${listAvailableServers().join(", ") || "(keine vorkonfiguriert; code pro Aufruf uebergeben)"}\n`
);
}
main().catch((err) => {
process.stderr.write(`rd-diagnostics-mcp Startfehler: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
});

View File

@ -0,0 +1,20 @@
export interface DecodedConnectionCode {
host: string;
port: number;
token: string;
scheme: string;
name: string;
fingerprint: string;
}
export interface EncodeConnectionCodeInput {
host: string;
port: number;
token: string;
name?: string;
fingerprint?: string;
scheme?: string;
}
export function encodeConnectionCode(input: EncodeConnectionCodeInput): string;
export function decodeConnectionCode(code: string): DecodedConnectionCode;

View File

@ -0,0 +1,56 @@
const PREFIX = "rddiag:v1:";
function base64urlEncode(str) {
return Buffer.from(str, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function base64urlDecode(str) {
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
const b64 = str.replace(/-/g, "+").replace(/_/g, "/") + pad;
return Buffer.from(b64, "base64").toString("utf8");
}
export function encodeConnectionCode({ host, port, token, name, fingerprint, scheme }) {
if (!host || typeof host !== "string") throw new Error("host fehlt");
const p = Number(port);
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("port ungueltig");
if (!token || typeof token !== "string") throw new Error("token fehlt");
const payload = { v: 1, h: host, p, t: token };
if (name) payload.n = String(name);
if (fingerprint) payload.fp = String(fingerprint);
if (scheme && scheme !== "http") payload.s = String(scheme);
return PREFIX + base64urlEncode(JSON.stringify(payload));
}
export function decodeConnectionCode(code) {
const raw = String(code || "").trim();
if (!raw.startsWith(PREFIX)) {
throw new Error(`Verbindungscode muss mit "${PREFIX}" beginnen`);
}
let json;
try {
json = JSON.parse(base64urlDecode(raw.slice(PREFIX.length)));
} catch {
throw new Error("Verbindungscode ist beschaedigt (kein gueltiges base64url/JSON)");
}
if (!json || typeof json !== "object") throw new Error("Verbindungscode-Inhalt ungueltig");
const host = String(json.h || "").trim();
const port = Number(json.p);
const token = String(json.t || "");
if (!host) throw new Error("Verbindungscode ohne Host");
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Verbindungscode mit ungueltigem Port");
if (!token) throw new Error("Verbindungscode ohne Token");
const scheme = json.s === "https" ? "https" : "http";
return {
host,
port,
token,
scheme,
name: json.n ? String(json.n) : "",
fingerprint: json.fp ? String(json.fp) : ""
};
}

View File

@ -0,0 +1,22 @@
#!/usr/bin/env node
import { encodeConnectionCode } from "./code.mjs";
function arg(name, fallback) {
const i = process.argv.indexOf("--" + name);
if (i >= 0 && i + 1 < process.argv.length) return process.argv[i + 1];
return fallback;
}
const host = arg("host");
const port = arg("port");
const token = arg("token");
const name = arg("name");
const scheme = arg("scheme");
const fingerprint = arg("fp");
if (!host || !port || !token) {
process.stderr.write("Usage: node src/gen-code.mjs --host <h> --port <p> --token <t> [--name <n>] [--scheme https] [--fp <sha256>]\n");
process.exit(2);
}
process.stdout.write(encodeConnectionCode({ host, port, token, name, scheme, fingerprint }) + "\n");

View File

@ -0,0 +1,63 @@
import http from "node:http";
import https from "node:https";
function normalizeFp(fp) {
return String(fp || "").replace(/:/g, "").toLowerCase();
}
export function debugGet(target, path, { timeoutMs = 20000 } = {}) {
const scheme = target.scheme === "https" ? "https" : "http";
const lib = scheme === "https" ? https : http;
const rel = path.startsWith("/") ? path : "/" + path;
const url = new URL(rel, `${scheme}://${target.host}:${target.port}`);
const pinning = scheme === "https" && !!target.fingerprint;
return new Promise((resolve, reject) => {
const options = {
method: "GET",
headers: {
Authorization: `Bearer ${target.token}`,
Accept: "application/json"
},
timeout: timeoutMs
};
if (scheme === "https") {
options.rejectUnauthorized = !target.fingerprint;
}
const req = lib.request(url, options, (res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
resolve({ status: res.statusCode || 0, body: data, headers: res.headers });
});
});
req.on("timeout", () => {
req.destroy(new Error(`Zeitueberschreitung nach ${timeoutMs}ms`));
});
req.on("error", (err) => {
reject(err);
});
if (pinning) {
req.on("socket", (socket) => {
socket.on("secureConnect", () => {
const cert = typeof socket.getPeerCertificate === "function" ? socket.getPeerCertificate() : null;
const got = normalizeFp(cert && cert.fingerprint256);
const want = normalizeFp(target.fingerprint);
if (!got || got !== want) {
req.destroy(new Error(`TLS-Fingerprint stimmt nicht (erwartet ${want || "?"}, erhalten ${got || "?"})`));
return;
}
req.end();
});
});
} else {
req.end();
}
});
}

View File

@ -0,0 +1,175 @@
import http from "node:http";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { encodeConnectionCode } from "../src/code.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const BRIDGE = join(__dirname, "..", "src", "bridge.mjs");
const TOKEN = "test-token-abc123";
const failures = [];
function check(name, cond, detail) {
if (cond) {
process.stdout.write(` PASS ${name}\n`);
} else {
failures.push(name);
process.stdout.write(` FAIL ${name}${detail ? " — " + detail : ""}\n`);
}
}
function startFakeServer() {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
const auth = req.headers.authorization || "";
const tokenOk = auth === `Bearer ${TOKEN}` || url.searchParams.get("token") === TOKEN;
if (!tokenOk) {
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
const p = url.pathname;
const q = Object.fromEntries(url.searchParams.entries());
const send = (obj) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(obj));
};
if (p === "/health") return send({ status: "ok", appVersion: "1.7.222", uptime: 42 });
if (p === "/diagnostics") return send({ meta: { appVersion: "1.7.222" }, status: { active: 1 }, query: q });
if (p === "/errors") return send({ errors: [{ level: "ERROR", message: "boom" }], query: q });
if (p === "/logs/main") return send({ lines: ["line1", "line2"], count: 2, query: q });
if (p === "/status") return send({ active: 1, queued: 3 });
if (p === "/items") return send({ items: [], query: q });
if (p === "/accounts") return send({ accounts: [{ name: "acc1", premium: true }] });
if (p === "/meta") return send({ appVersion: "1.7.222", endpoints: ["/health", "/diagnostics"] });
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found", path: p }));
});
server.listen(0, "127.0.0.1", () => resolve(server));
});
}
function startBridge() {
const child = spawn(process.execPath, [BRIDGE], { stdio: ["pipe", "pipe", "pipe"] });
child.stderr.on("data", (d) => process.stderr.write(`[bridge] ${d}`));
const pending = new Map();
let buf = "";
child.stdout.on("data", (chunk) => {
buf += chunk.toString("utf8");
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.id !== undefined && pending.has(msg.id)) {
pending.get(msg.id)(msg);
pending.delete(msg.id);
}
}
});
let nextId = 1;
function rpc(method, params) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, resolve);
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`RPC timeout: ${method}`));
}
}, 15000);
});
}
function notify(method, params) {
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
}
return { child, rpc, notify };
}
function textOf(callResult) {
const c = callResult && callResult.result && callResult.result.content;
if (!Array.isArray(c)) return "";
return c.map((x) => x.text || "").join("\n");
}
async function run() {
const fake = await startFakeServer();
const port = fake.address().port;
const code = encodeConnectionCode({ host: "127.0.0.1", port, token: TOKEN, name: "testserver" });
const badCode = encodeConnectionCode({ host: "127.0.0.1", port, token: "WRONG", name: "testserver" });
const bridge = startBridge();
try {
const init = await bridge.rpc("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "harness", version: "1.0.0" }
});
check("initialize handshake", !!(init.result && init.result.serverInfo), JSON.stringify(init.error || {}));
check("server name reported", init.result && init.result.serverInfo && init.result.serverInfo.name === "rd-diagnostics-mcp");
bridge.notify("notifications/initialized", {});
const tools = await bridge.rpc("tools/list", {});
const names = (tools.result && tools.result.tools || []).map((t) => t.name);
check("tools/list returns tools", names.length >= 10, `got ${names.length}`);
for (const expected of ["rd_ping", "rd_diagnostics", "rd_errors", "rd_logs", "rd_get", "rd_servers"]) {
check(`tool present: ${expected}`, names.includes(expected));
}
const ping = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code } });
const pingText = textOf(ping);
check("rd_ping reaches server", /HTTP 200/.test(pingText) && /"status": "ok"/.test(pingText), pingText.slice(0, 200));
check("rd_ping shows server label", /testserver \(127\.0\.0\.1:/.test(pingText));
const diag = await bridge.rpc("tools/call", { name: "rd_diagnostics", arguments: { code, lines: 50, grep: "err" } });
const diagText = textOf(diag);
check("rd_diagnostics returns aggregate", /"appVersion": "1\.7\.222"/.test(diagText));
check("rd_diagnostics passes query params", /"lines": "50"/.test(diagText) && /"grep": "err"/.test(diagText), diagText.slice(0, 300));
const logs = await bridge.rpc("tools/call", { name: "rd_logs", arguments: { code, name: "main", lines: 5 } });
const logsText = textOf(logs);
check("rd_logs maps name→path + lines", /logs\/main\?lines=5/.test(logsText) && /"count": 2/.test(logsText), logsText.slice(0, 200));
const errs = await bridge.rpc("tools/call", { name: "rd_errors", arguments: { code, level: "ERROR" } });
check("rd_errors returns ring", /"message": "boom"/.test(textOf(errs)));
const raw = await bridge.rpc("tools/call", { name: "rd_get", arguments: { code, path: "/meta" } });
check("rd_get escape hatch hits arbitrary path", /"endpoints"/.test(textOf(raw)));
const unauthorized = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code: badCode } });
check("bad token → HTTP 401 + isError", /HTTP 401/.test(textOf(unauthorized)) && unauthorized.result.isError === true);
const noCode = await bridge.rpc("tools/call", { name: "rd_ping", arguments: {} });
check("missing code → graceful isError", noCode.result && noCode.result.isError === true && /Kein Verbindungscode/.test(textOf(noCode)));
const unreachable = await bridge.rpc("tools/call", {
name: "rd_ping",
arguments: { code: encodeConnectionCode({ host: "127.0.0.1", port: 1, token: TOKEN }) }
});
check("unreachable → isError + hint", unreachable.result.isError === true && /nicht erreichbar|abgebrochen|FEHLER/.test(textOf(unreachable)), textOf(unreachable).slice(0, 160));
} finally {
bridge.child.kill();
fake.close();
}
process.stdout.write("\n");
if (failures.length) {
process.stdout.write(`RESULT: ${failures.length} FAIL\n`);
process.exit(1);
}
process.stdout.write("RESULT: ALL PASS\n");
process.exit(0);
}
run().catch((err) => {
process.stderr.write(`harness error: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
});