Compare commits

..

429 Commits

Author SHA1 Message Date
Sucukdeluxe
77c937888a Release v1.7.190 2026-06-08 23:05:25 +02:00
Sucukdeluxe
fbbc960d9d docs(tasks): Bug-Audit Batch 2 abgeschlossen — 5 Fixes (L/M,H,J/Q,P,B/I) + verifizierte Nicht-Bugs (G,N,D/E,E,O,F) 2026-06-08 23:04:28 +02:00
Sucukdeluxe
dc05b51083 Fix: Settings-only-Backup-Import wischte Live-Queue + Zaehler (B/I)
importBackup wendete die Settings fuer beide Pfade ueber setSettings an, das bei
nicht-"never"-CleanupPolicy applyRetroactiveCleanupPolicy ausloest. Beim reinen
Settings-Restore purgte das die LIVE-Queue (fertige Items), obwohl der Vertrag
"running queue stays untouched" lautet (Dateien blieben auf Platte). Zudem rollte
der Import die laufenden Usage-/Status-Zaehler auf den (aelteren) Backup-Stand
zurueck (anders als updateSettings).

- setSettings bekommt optionales { suppressRetroactiveCleanup }; der Settings-only
  Import setzt es. Die importierte Policy gilt weiter fuer KUENFTIGE Completions
  ueber den normalen Vorwaertspfad (immediate/package_done) — nur der retroaktive
  Sweep wird hier unterdrueckt.
- overlayLiveUsageCounters aus updateSettings extrahiert und im Settings-only Import
  wiederverwendet (inkl. Key-Filter der Debrid-Link-Per-Key-Usage auf existierende
  Keys). Nicht ueber updateSettings geroutet (vermeidet dessen resetHistoryForRetention).
2026-06-08 22:51:16 +02:00
Sucukdeluxe
61a830475b Fix: verschachtelte Entpack-Fortschritte wurden bei jedem Lauf verworfen
Der Resume-Prune validiert Eintraege gegen die Top-Level-Archiv-Kandidaten auf
der Platte. Nested-Archiv-Schluessel (nested:<name>) haben dort kein Gegenstueck,
also wurden sie bei JEDEM extractPackageArchives-Aufruf geloescht — verschachtelte
Archive wurden beim Resume erneut entpackt. nested:-Schluessel werden im Prune
jetzt uebersprungen (sie werden mit dem Rest geleert, wenn das Paket fertig ist).
2026-06-08 22:47:34 +02:00
Sucukdeluxe
3c33b988c3 Fix: Post-Process-Identity-Guard (J) + Remux-Temp nie ins Library sammeln (Q)
J: runPackagePostProcessing loescht im finally die Map-Eintraege fuer das Paket.
Hatte ein Abort den Handle schon entfernt und ein neuer Lauf einen frischen
Task+Controller gesetzt, riss das spaete finall des alten Tasks diesen neuen
Eintrag mit raus -> nicht abbrechbarer Waisen-Task + doppeltes paralleles
Post-Processing. Jetzt nur loeschen wenn Map noch auf DIESEN Task/Controller zeigt.

Q: collectFilesByExtensions filtert jetzt ~rd-Praefix (unsere Remux-Temp/Orphan-
Sidecars) aus, damit eine bei einem Crash mitten im Remux liegengebliebene
Teil-Datei nie in die MKV-Library gesammelt wird.

(dropItemContribution: Kommentar ergaenzt, dass das Nicht-Abziehen der
Session-Totals Absicht ist — kumulative Session-Zaehler, per Test abgesichert.)
2026-06-08 22:46:14 +02:00
Sucukdeluxe
4432fa25e8 Fix: Logger-Flush konnte ungeschriebene Zeilen verlieren (Race mit 1MB-Cap)
flushAsync nahm eine Kopie der pending-Zeilen und entfernte sie nach dem await
per Index-Zaehlung (slice(snapshot.length)). Feuerte waehrend des awaits ein
write() den 1MB-Buffer-Cap, der vorne Zeilen wegshiftet, war die Zaehlung
desynchron und verwarf neu hinzugekommene, noch nicht geschriebene Zeilen.
Jetzt: pending-Zeilen per Move uebernehmen (Buffer auf [] zuruecksetzen) statt
kopieren; await-Zeit-writes laufen in einen frischen Buffer. Bei Schreibfehler
werden die Zeilen wieder vorn eingereiht und der Cap erneut angewandt.
2026-06-08 22:33:11 +02:00
Sucukdeluxe
272a41a4a7 Fix: zu weite Deutsch-Erkennung konnte falsche Tonspur behalten
- isGermanStream: Titel-Fallback nur noch ganze Woerter (german/deutsch); die
  2-3-Buchstaben-Codes ger/deu sind im freien Titel-Text mehrdeutig und konnten
  die falsche Spur als "deutsch" picken (und damit die echte deutsche loeschen).
  Der Sprach-Tag-Check (ger/deu/de) bleibt unveraendert.
- looksLikeGermanRelease: 'dubbed' entfernt — ein nacktes "Dubbed" kann ein
  italienischer/franzoesischer Dub sein und darf den German-first-Fallback nicht
  ausloesen. Explizite german/deutsch-Tokens reichen.
- 2 Negativtests (3-Letter-Titel-Code, nicht-deutscher Dub).
2026-06-08 22:31:34 +02:00
Sucukdeluxe
b71866c3dc Release v1.7.189 2026-06-08 22:23:07 +02:00
Sucukdeluxe
b200b4e5b1 docs(tasks): Bug-Audit Sequenz — B verifiziert demoted (keine Platten-Loeschung), A allein v1.7.189 2026-06-08 22:19:07 +02:00
Sucukdeluxe
189af2242f Fix: Tonspur-Remux konnte bei Windows-Datei-Lock Original UND Remux verlieren
Der atomare Ersetzen-Schritt loeschte das Original bevor der Ersatz bestaetigt
war; schlug das anschliessende Rename fehl (z.B. AV/Indexer-Lock), raeumte der
aeussere catch zusaetzlich die Temp-Datei weg -> null Kopien auf der Platte.

- Atomares Replace-over (MoveFileEx REPLACE_EXISTING / rename(2)) statt
  rm-dann-rename: filePath haelt zu jedem Zeitpunkt entweder das volle Original
  oder den vollen Remux.
- renameWithRetry: transiente Locks (EBUSY/EACCES/EPERM/EEXIST) mit Backoff
  (200/500/1000ms) statt sofort abzubrechen.
- Eindeutiger Temp-Name (~rd<pid><rand>) statt fixem ~rdtmp -> keine Kollision
  zwischen parallelen Paketen/Retries.
- 3 neue Tests (Recovery bei Replace-Fehler, Retry-Pfad EBUSY/EXDEV).
2026-06-08 22:14:13 +02:00
Sucukdeluxe
92890f9649 Release v1.7.188 2026-06-08 14:51:22 +02:00
Sucukdeluxe
2b93f47d3a German-audio step: mislabeled-tag fallback, full logging, shorter temp
- tag mode: when no German-tagged audio track is found but the release name
  says German/Dubbed, fall back to the first track (the dub is mislabeled, e.g.
  German tagged "eng") instead of skipping; non-German names still skip safely
- comprehensive logging: per-package ffmpeg/ffprobe availability, plus per-file
  detected audio languages, decision + reason, remux/rename result and the exact
  error text when a file can't be processed
- shorter same-dir temp name so a long scene path + temp suffix cannot exceed
  Windows MAX_PATH and silently fail the remux
2026-06-08 14:50:34 +02:00
Sucukdeluxe
15edfbeb74 Release v1.7.187 2026-06-08 13:34:26 +02:00
Sucukdeluxe
aa65f56c28 Fix Mega-Web rotation skipping accounts on a timeout abort
When a Mega-Web account's unrestrict aborts because the shared unrestrict
timeout fired while it was running, give that account a 2-min cooldown
(only if it actually ran >=8s, so a quick user-cancel does not cool it
down). The download-manager retry then skips the cooled-down account and
rotates to the next one, instead of hammering the same account every 60s.

- debrid.ts: handle the abort in the rotation catch before classifyAccountFailure
- rotation log event TIMEOUT_COOLDOWN (+ renderer label) replaces the misleading
  red "fataler Fehler" for this case
- RD_MEGA_ABORT_MIN_RUN_MS env override for the run-length threshold
- 2 regression tests (cooldown set -> next call rotates; quick abort -> no cooldown)
2026-06-08 13:33:49 +02:00
Sucukdeluxe
92a36e2e47 Release v1.7.186 2026-06-07 21:18:43 +02:00
Sucukdeluxe
77661389f3 Add "keep only German audio" post-extract step for .DL. files
- New video-processor.ts: ffmpeg/ffprobe remux that keeps only the German
  audio track (by language tag, with safe fallbacks) and strips the ".DL."
  marker from the filename
- Runs after extraction in both the deferred and hybrid post-process paths,
  inside the per-package file-op chain; abortable, disk-space checked,
  mtime-preserving, atomic temp->replace so the original is never lost
- System ffmpeg via PATH / RD_FFMPEG_BIN; toggle + track-mode select in settings
2026-06-07 21:17:26 +02:00
Sucukdeluxe
397e667af2 chore: tidy tasks/todo.md (archive completed work, keep open backlog) 2026-06-07 20:17:26 +02:00
Sucukdeluxe
20c803302d Release v1.7.185 2026-06-07 17:01:11 +02:00
Sucukdeluxe
468df99142 Add extended diagnostics logging
- Electron crash handlers (render-process-gone, child-process-gone,
  unresponsive/responsive, process warnings) with a circuit-breaker
  auto-reload for renderer crashes
- Renderer error capture (window.onerror, unhandledrejection, React
  ErrorBoundary) forwarded to the main log via a one-way IPC channel
- Memory-pressure heartbeat measured against the V8 heap_size_limit
- Gated DEBUG log level (RD_DEBUG) and an in-memory ring of recent
  WARN/ERROR lines, exposed via the /errors endpoint and support bundle
- Disk-error classification (ENOSPC etc.) on download failures and
  integrity-check pass/fail logging
2026-06-07 17:00:06 +02:00
Sucukdeluxe
2ececf699a Release v1.7.184 2026-06-07 04:41:51 +02:00
Sucukdeluxe
d006a60553 Backup: nur Settings als Default + 4 Selektions/Flicker-Bugfixes
Backup:
- Neues Setting backupIncludeDownloads (Default aus) — Backup sichert
  standardmaessig NUR Einstellungen, nicht die Download-Liste/History.
- buildBackupPayload/planBackupImport (testbare backup-payload.ts): Export
  omittet session+history wenn Flag aus (explizites kind-Marker); Import folgt
  dem FILE-Inhalt, nicht dem lokalen Toggle.
- importBackup: settings-only -> frueher Return nach setSettings, KEIN stop/
  Queue-Wipe/Relaunch. Return {restored,relaunch,message}; main.ts gated den
  Auto-Relaunch auf relaunch. Renderer re-seeded settingsDraft bei !relaunch.

Bugfixes:
- Ctrl+A waehlte das ungefilterte Paket-Map -> Loeschen nach Suche traf
  versteckte Pakete. Jetzt visibleOrderIds (sichtbare Zeilen, inkl. Items).
- selectedIds nie geprunt bei Delta-Removal -> aufgeblaehte Counts. Neue pure
  pruneSelection (selection.ts) + Effect.
- link-status-dot conditional -> Dateiname sprang ~14px. Platzhalter-Slot.
- sortPackagesForDisplay sortierte aktive Pakete nach Live-Progress -> Reshuffle
  pro Tick. Jetzt stabile Queue-Reihenfolge je Gruppe (Anti-Flicker).

+17 Tests (backup-payload 9, selection 5, package-order anti-flicker 3).
2026-06-07 04:40:54 +02:00
Sucukdeluxe
3ed3877ac9 chore: remove all source code comments and internal artifacts
Strip every comment from the source (parsed with the TypeScript compiler so
strings, template literals, regex literals and JSX are never touched), and drop
internal/working artifacts that do not belong in the public repository
(design mockups, internal analysis docs, a stray backup file and an old log).
No functional change: build is green, the full test suite passes.
2026-06-06 04:53:54 +02:00
Sucukdeluxe
f3159b9c6e Release v1.7.183 2026-06-06 02:52:37 +02:00
Sucukdeluxe
07b034440b Fix: bereits sauber benannte Folgen werden vom Collect nicht mehr verkrueppelt (Miniserien)
Bei Serien, deren per-Episode-Ordner nur einen Episode-only-Token + Titel tragen
("Show.E01.Titel...-GRP", KEIN S01), benannte der Collect eine vom Auto-Rename bereits
korrekt benannte Datei ("Show.S01E01...-GRP.mkv") neu — und haengte den Staffel/Folgen-
Token HINTER die Scene-Gruppe ("...-GRP.S01E01"). In der Library stand dann der Episoden-
titel + ein angehaengtes S01E01 statt sauber S01E01 (gemeldet fuer "Steven Spielbergs Taken").

decideAutoRenameBaseName behaelt im Guard-B-Zweig "Ziel-Ordner ohne SxxExx" jetzt die
QUELLE, wenn sie ein nicht obfuskierter Scene-Name ist (sie traegt dort den einzigen echten
SxxExx-Token) — statt den Token an den Ordnernamen anzuhaengen. Obfuskierte/rohe Quellen
werden weiter aus dem Ordner sauber benannt. Wirkt in Collect und Auto-Rename.

Adversarial (Workflow) abgesichert: der Diskriminator ist allein "Quelle obfuskiert?" —
die Praefix-Laenge ist KEIN Kriterium, sonst fielen kurze Serien (ER, V, 24, Yu) durch und
zeigten denselben Bug. Regressionstest mit ER.S01E01 gepinnt. 4 Unit- + 1 Integrationstest.
2026-06-06 02:51:46 +02:00
Sucukdeluxe
7b39b33cc7 Release v1.7.182 2026-06-05 17:54:56 +02:00
Sucukdeluxe
339c46bdd2 Fix: Folgen in vollstaendigem Episoden-Ordner OHNE -GROUP-Suffix werden umbenannt
Alte deutsche Dokus/Serien-Ordner ohne Gruppen-Suffix (Ordner endet auf bare Codec
".XviD", kein "-GROUP") wurden vom Auto-Rename als "kein Zielname" verworfen — die
Folge landete dann ROH in der Library (z.B. "safari-fm-s04e08a.avi" statt
"Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD.avi").

buildAutoRenameBaseName akzeptiert jetzt zusaetzlich einen vollstaendigen Episoden-
Ordner: echter SxxExx-Token IM Ordnernamen UND ein Codec-/Aufloesungs-Marker
(SCENE_RESOLUTION_MARKER_RE / SCENE_CODEC_MARKER_RE, inkl. xvid/divx). Der Part-
Buchstabe a/b bleibt erhalten (Ordnername dient unveraendert als Zielname), sodass
Teil 1 und Teil 2 nicht kollidieren. Konservativ: ein nackter "Show.S01E01"-Ordner
ohne Qualitaets-/Codec-Marker wird weiterhin nicht abgeleitet. Greift in Auto-Rename
und Collect. 5 Unit- + 1 Collect-Integrationstest; v1.7.180-Fallback nutzt jetzt
dieselben Module-Konstanten (DRY).
2026-06-05 17:54:02 +02:00
Sucukdeluxe
74aec6f056 Release v1.7.181 2026-06-04 23:08:15 +02:00
Sucukdeluxe
afba79cdfd Fix: Folgen mit Bonus-Wort im Titel bleiben nicht mehr in "Downloader Fertig" liegen
Eine Folge mit gueltigem SxxExx-Token ist eine echte Episode, niemals Bonus/Extras —
auch wenn ihr Titel oder der Episoden-Ordnername ein Bonus-Wort enthaelt
(Interview/Outtakes/Special/Featurette/Making-Of/...). Bisher stufte der Library-
Collect (und Auto-Rename) solche Folgen als Extras ein und verschob sie NIE in die
Bibliothek — extrahiert und korrekt benannt, aber stumm liegengelassen (Skip nur via
logger.info, im Paket-Log unsichtbar). Betraf u.a. Revenge S04E19 "Interview".

Neue isBonusContent()-Guard an beiden Call-Sites: erst SxxExx pruefen (extractEpisodeToken),
nur ohne Token greift der Bonus-Filter (isInsideBonusDir / BONUS_FILENAME_RE). Echte Extras
ohne Token bleiben gefiltert. 2 Integrationstests + 5 Unit-Tests.
2026-06-04 23:05:50 +02:00
Sucukdeluxe
95a951ccc3 Release v1.7.180 2026-06-04 02:33:20 +02:00
Sucukdeluxe
dc271e08ff Renaming: vollstaendigen Episoden-Ordner als Namen nutzen, wenn Quelle keinen SxxExx-Token hat
User-Report (Desktop-Log): "Kreuzfahrt ins Glück" — 25 Folgen "bet_kig_01_hdt.mkv" (obfuskiert,
KEIN SxxExx-Token) landeten roh in der Library, obwohl der Episoden-Ordner
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET" bereits der
saubere Name ist (Episode als "01" statt S01E01).

Ursache (vorbestehend, nicht v1.7.178/179): buildAutoRenameBaseName gibt null zurueck, sobald die
QUELLE keinen SxxExx-Token hat — das "Folge 01"-Nummernformat wurde nie unterstuetzt.

Fix: Fallback in decideAutoRenameBaseName — fehlt der Quell-Episode-Token und kann normal kein
Name abgeleitet werden, aber ein folderCandidate ist ein VOLLSTAENDIGER Scene-Release-Ordner
(Scene-Gruppe UND Aufloesung ODER Codec, kein reiner Season-Ordner), wird dieser Ordnername
direkt verwendet (note "folder-as-is"). Greift NUR ohne Quell-Episode-Token -> Mega-Direct
(mit Quell-Token) bleibt no-target. Aufloesung ODER Codec (nicht nur Aufloesung) deckt
DVDRip/XviD ohne 720p ab (Advisor-Punkt). Bonus/Sample werden vorher gefiltert.

Verifiziert: tsc 6, 682 Tests gruen (+3: Kreuzfahrt real, DVDRip-nur-Codec, Mega-Direct-bleibt-
no-target), Build gruen. Advisor + reproduzierter Diagnose-Test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 02:32:29 +02:00
Sucukdeluxe
e9c6a6bcae lessons: Renaming-Verschlimmbesserung (Unterstrich-Gruppe) dokumentiert 2026-06-03 11:57:29 +02:00
Sucukdeluxe
68f6923e42 Release v1.7.179 2026-06-03 11:56:18 +02:00
Sucukdeluxe
5349554b01 Renaming: Scene-Gruppen mit Unterstrich erkennen (-idTV_iNT) — kein Verschlimmbessern zum Paketnamen
User-Report (aus Desktop-Rename-Log): castle.s08e02.german.dl.720p.web.h264-idtv_int.mkv im
sauberen Episoden-Ordner "Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT" (Paket "scn2-cstl7")
wurde zu "scn2-cstl7.S08E02.mkv" VERSCHLIMMBESSERT (guter Quellname -> obfuskierter Paketname).

Ursache (vorbestehend, nicht durch v1.7.178): hasSceneGroupSuffix erkannte die Scene-Gruppe
"-idTV_iNT" nicht (SCENE_GROUP_SUFFIX_RE + Fallback verbieten Unterstriche). Der saubere
Episoden-Ordner wurde dadurch als Nicht-Scene-Ordner verworfen, und die Namensherleitung fiel
auf den obfuskierten Paket-Ordner "scn2-cstl7" zurueck -> "scn2-cstl7.S08E02".

Fix: hasSceneGroupSuffix nutzt jetzt zusaetzlich extractFlexibleSceneGroupSuffix (existierte
bereits, war aber nicht verdrahtet), das Unterstrich-Gruppen korrekt erkennt (splittet auf "_",
validiert jeden Teil). Der saubere Ordner wird akzeptiert -> idealer Name
"Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT". Mein v1.7.178-Folder-Token-Guard schuetzt
generische Paketordner (Mega-Direct) weiterhin.

Verifiziert: tsc 6, 679 Tests gruen (+1 Charakterisierung fuer den idTV_iNT-Fall), Build gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:55:42 +02:00
Sucukdeluxe
f2e9de8da0 Release v1.7.178 2026-06-03 01:10:00 +02:00
Sucukdeluxe
288a0762a6 Renaming 100%: collect leitet sauberen Namen selbst ab (gemeinsame Entscheidungsfunktion + Wurzel-Schutz)
User-Report (aus dem Desktop-Rename-Log): 17 Dateien landeten ROH in der Library
("tvarchiv...s07e12-720.mkv", "4sf-...s04e01.mkv") — Auto-Rename hatte sie verpasst, der
MKV-Collect schob sie mit dem rohen Scene-Namen weg.

Root Cause 1: Auto-Rename und collectMkvFilesToLibrary sind entkoppelte Scans. Auto-Rename
benennt nur present-and-stable Dateien in extractDir um; eine verpasste Datei (verpasster
Zyklus ODER lag in "Downloader Unfertig" ausserhalb extractDir) wurde von collect roh
weggeschoben (collect behielt blind den Basename).
Root Cause 2: decideAutoRenameBaseName fabrizierte Namen fuer token-lose generische Ordner
("Mega-Direct-Pack" -> "Mega-Direct-Pack.S01E01") wegen eines hasSceneGroupSuffix-Falsch-
Positivs auf "-Pack" — derselbe latente Bug haette Auto-Rename getroffen.

Fix:
- Namens-Entscheidung in EINE pure Funktion extrahiert: decideAutoRenameBaseName (Single
  Source of Truth fuer Auto-Rename UND Collect — koennen nicht mehr divergieren).
- Wurzel-Schutz darin: Rename nur, wenn ein folderCandidate einen echten Season-/Episode-
  Token traegt (kein Fabrizieren aus token-losen Ordnern). Fixt beide Pfade.
- collectMkvFilesToLibrary leitet den sauberen Namen via dieser Funktion ab (gegated auf
  autoRename4sf4sj — respektiert die Umbenenn-Einstellung), inkl. Companion-Untertitel und
  Dedup gegen den sauberen Namen. mkvFiles traegt jetzt sourceRoot fuer die Ordner-Herleitung.
- Auto-Rename-Loop nutzt jetzt die gemeinsame Funktion (behebt nebenbei 2 latente
  use-before-declaration/TDZ-Fehler an resolveRenameItem).
- Latenter Bug: Casing-Zaehler renamedCount -> renamed (war undeklariert -> ReferenceError,
  vom catch verschluckt -> Casing-Korrekturen wurden still verworfen).

Verifiziert: tsc 6 (von 9 — 3 latente Fehler nebenbei behoben), 678 Tests + 9 neue (7
Charakterisierung der Entscheidung + 2 Collect-Integration: raw->clean + Companion/.srt folgt
+ Datei ausserhalb extractDir), Build gruen. Adversarialer Review-Workflow (4 Linsen) + Advisor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:09:21 +02:00
Sucukdeluxe
9a71e01417 Release v1.7.177 2026-06-02 05:20:48 +02:00
Sucukdeluxe
8d03ca124f Update-Neustart: laufende Downloads als queued parken statt als "Gestoppt" haengenzubleiben
Beim Update parkte installUpdate() aktive Downloads via stop() -> deren Abbruch-
Continuation markierte die Items "cancelled"/"Gestoppt". autoResumeOnStart nimmt
nach dem Neustart aber nur "queued"/"reconnect_wait" auf, also liefen die gerade
ladenden Downloads nach dem Update nicht weiter (timing-abhaengig: "manchmal").
Jetzt: stop({parkForRestart:true}) bricht aktive Tasks mit Grund "shutdown" ab,
sodass sie als "queued" re-queued werden (wie bei normalem App-Shutdown). Das
schliesst zugleich den einzigen plausiblen Loesch-Pfad (all-cancelled-Pakete sind
ueber applyRetroactiveCleanupPolicy entfernbar). Stop-Button-Verhalten unveraendert.

Zusaetzliche Robustheit in storage.ts (enge Blast-Radien, nicht die Hauptursache):
- async-Save-Clobber: eine gequeuete, veraltete Payload konnte einen neueren
  Sync-Save (persistNowSync/prepareForShutdown) ueberschreiben; Generation wird
  jetzt zum Snapshot-Zeitpunkt erfasst und durch die Queue getragen.
- loadSession gab leer zurueck (und ignorierte ein gefuelltes .bak), wenn die
  Primaerdatei fehlte; faellt jetzt auf die Backup/Temp-Recovery zurueck.

Regressionstests: tests/update-restart-resume.test.ts (echter Live-Download ->
Park -> Reload = queued, plus Charakterisierung plain stop() -> cancelled) und
tests/session-restart-loss.test.ts (Clobber + Backup-Fallback). Volle Suite gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 05:19:54 +02:00
Sucukdeluxe
53cc6b11eb Release v1.7.176 2026-06-01 13:15:29 +02:00
Sucukdeluxe
251c41ca6c Renaming-Logging: lueckenloses Desktop-Protokoll pro Sitzung + Post-Rename-Verifikation
User-Goal: bei kuenftigen Renaming-Problemen eine vollstaendige, sofort auffindbare Uebersicht —
JEDER Umbenenn-/Verschiebevorgang protokolliert UND danach verifiziert (liegt die Datei wirklich
unter dem Zielnamen? Quelle weg? richtige Schreibweise?).

- NEU desktop-rename-log.ts: pro Sitzung <Desktop>/Downloader-Log/rename-session_<ts>.txt; Ordner
  selbstheilend (mkdir recursive vor jedem Write -> auch nach Loeschung zur Laufzeit sofort wieder
  da). Synchroner Append, Schreibfehler verschluckt (bricht nie einen Download).
- verifyRename (sync) + verifyRenameAsync (Hot-Path): prueft Ziel-Existenz, echten On-Disk-Namen
  (case-genau via readdir), Quell-Abwesenheit; Level INFO/WARN/ERROR. Nutzt denselben \?\-Long-
  Path-Prefix wie der echte Rename (sonst falsche Urteile auf langen Scene-Pfaden).
- download-manager: renamePathWithExdevFallback = verifizierter Wrapper um die unveraenderte
  Raw-Logik (deckt alle Media-Renames ab) + 3 Sync-Sites (startup-Dedup, Deobfuskation, Suffix-Fix)
  via logVerifiedRenameSync; logRenameProcess spiegelt ins Desktop-Log.
- app-controller init/shutdown (getPath("desktop") gegen Startup-Crash abgesichert); support-bundle
  packt das Log mit ein.

Adversarialer Review-Workflow (4 Linsen) fand + behoben: Long-Path-Verify-Bug (falsches OK
maskiert halb-fertigen Move), readdir-Fehler-False-OK, sync-I/O im Hot-Path, getPath-Guard,
Test-Temp-Cleanup. tsc 9 (Baseline), 663 Tests (+7 neue), Build gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:14:55 +02:00
Sucukdeluxe
da72c11772 Release-Script: Asset-Upload mit Retry (transiente Abbrueche bei ~80MB-Assets)
Beim Release v1.7.175 brach der Upload eines ~87MB-Assets transient ab (exit 4). Dank der
draft-first-Logik blieb das Release unsichtbar (kein kaputtes Live-Release), musste aber
manuell per curl nachgeladen + publiziert werden. Der Upload nutzte einen einmaligen
fetch-Stream ohne Retry.

Fix: je Asset bis zu 3 Versuche mit Backoff bei Netzwerk-Abbruch oder 5xx; pro Versuch ein
frischer createReadStream (konsumierter Stream ist nicht erneut sendbar). 4xx (ausser
409/422 = existiert bereits) brechen weiterhin sofort ab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:40:02 +02:00
Sucukdeluxe
f5d435ccd2 Release v1.7.175 2026-05-31 21:09:47 +02:00
Sucukdeluxe
ffcd0817cf Mega-Debrid: Account am Tageslimit bis Neustart parken (Streak-Heuristik) statt endlos neu testen
User-Entscheidung: ein Mega-Debrid-Account am Tageslimit soll bis zum Programm-Neustart
uebersprungen werden, nicht alle 20s/2min neu getestet.

Ground Truth (Support-Bundle gegrept): der limitierte Account liefert im Web-Pfad NIE eine
unterscheidbare Meldung — "Kein Server" = 0 Treffer, "Antwort leer" = 20.861. Tageslimit und
transienter Blip sind auf Message-Ebene nicht trennbar (generate() findet ohne processDebrid-
Code keinen Code -> return null -> "Antwort leer"). Ein Trigger auf "Kein Server" waere toter Code.

Loesung (Verhaltens-Signal statt Wortlaut):
- megaDebridEmptyResponseStreaks zaehlt aufeinanderfolgende "Antwort leer"/"Kein Server"-
  Treffer je Account; ab 3 wird der Account bis Neustart geparkt (until=MAX_SAFE_INTEGER,
  nur In-Memory -> Neustart loescht). Erfolg/anderer Fehler setzt zurueck.
- classifyAccountFailure markiert beide Signale als limitSignal (Symmetrie: ein einzelner
  evtl. transienter Treffer parkt NICHT, behaelt kurzen Cooldown).
- Skip-Branch: "uebersprungen (bis Neustart gesperrt)", traegt nicht zu earliestCooldownUntil
  bei (kein absurder Retry-Timer); Post-Loop wirft klare Endmeldung wenn alle geparkt.
- generate() surfacet "Kein Server" zusaetzlich als Page-Error (falls es doch im HTML steht).
- UI: Rotations-Verlauf zeigt "bis Neustart gesperrt".

Verifiziert: tsc 9 (Baseline), 655 Tests + 5 neue (inkl. Wiring-E2E der eine echte leere
Antwort durch unrestrictWithAccounts->classify->catch->Park treibt), Build gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:08:43 +02:00
Sucukdeluxe
254fce8736 Fix: Release als Draft anlegen, erst nach Asset-Upload veroeffentlichen (Auto-Update-Race)
User-Report: Auto-Update schlug mit "Setup-Asset nicht gefunden" fehl, wenn der Update-Check
waehrend des Asset-Uploads eines neuen Releases feuerte. Ursache: release:gitea pushte Tag +
erstellte das Release (draft:false) VOR dem Asset-Upload → das "latest"-Release war den ganzen
Upload ueber sichtbar, aber ohne latest.yml (Gitea-Assets haben keinen digest → der Updater
holt den Integritaets-Hash aus latest.yml, das zuletzt hochgeladen wird). Fix: Release als
draft:true anlegen → alle Assets hochladen → dann PATCH draft:false. Der Updater ueberspringt
Drafts und sieht das Release erst, wenn es komplett ist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:21:37 +02:00
Sucukdeluxe
2ad08fda05 Release v1.7.174 2026-05-31 20:11:10 +02:00
Sucukdeluxe
99e4b2b885 Fix: Log-Zeitstempel in lokaler Zeit (mit Offset) statt UTC
User-Report: Logs zeigten z.B. "17:29:43" obwohl es lokal 19:29:43 war (CEST/UTC+2), weil
alle Logger `new Date().toISOString()` (UTC "...Z") nutzten. Neuer Helper logTimestamp()
formatiert lokale Zeit mit explizitem Offset (ISO 8601, z.B. "2026-05-31T19:29:43.605+02:00")
— menschlich lokal UND weiterhin eindeutig/Date.parse-bar. Angewandt auf alle Log-Zeilen-
Writer: item-log, logger (rd_downloader.log), audit-log, rename-log, session-log,
package-log, account-rotation-log, trace-log. Interne/API-/Dateinamen-Zeitstempel
(debug-server, support-bundle, trace autoDisableAt-Config) bleiben absichtlich UTC.

Test: tests/log-timestamp.test.ts (Format + Round-Trip zum selben Instant + lokale Stunde,
TZ-unabhaengig). 650 Tests gruen, tsc 9, Build sauber.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:10:18 +02:00
Sucukdeluxe
c4c0110f84 Release v1.7.173 2026-05-31 20:00:23 +02:00
Sucukdeluxe
0be5248a36 Fix: Mega-Debrid Web-Rotation nutzt jetzt die Per-Account-Credentials (echter Rotations-Bug)
Root-Cause (verifiziert via Support-Bundle): Der Web-Unrestrict lief fuer JEDEN rotierten
Account mit den Creds des ersten/Legacy-Accounts (settings.megaLogin), weil MegaWebFallback
EINE geteilte Cookie-Session + festes getCredentials() nutzte UND megaWebUnrestrict ohne
Account-Bezug aufgerufen wurde. Item-Log-Beweis: "Account 2/2 (FabelDavid): Mega-Web Antwort
leer", obwohl FabelDavid real funktioniert — die Rotation nutzte FabelDavid nie wirklich,
sondern immer Account 1 (am Limit). Alle bisherigen Fixes (v1.7.169-172) lagen downstream
dieses Punkts und konnten den Bug nicht beheben.

Fix:
- MegaWebUnrestrictor bekommt optionalen `account`-Parameter; MegaDebridClient.unrestrictViaWeb
  reicht this.login/this.password (den rotierten Account) durch; app-controller leitet ihn weiter.
- MegaWebFallback: Per-Login Session-Cache (Map<login,{cookie,setAt}>) statt einem geteilten
  Cookie; login() gibt das Cookie zurueck, generate() bekommt es als Param. Jeder Account nutzt
  seine eigene Session — kein Re-Login-Thrash unter Parallel-Last (maxParallel=8).

Tests: mega-web-fallback (Login-POST traegt den uebergebenen Account-Login, nicht den Default)
+ debrid-Rotation (jeder Account erhaelt SEINE Creds; Account 2 loest auf). 647 Tests gruen,
tsc 9, Build sauber.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:59:37 +02:00
Sucukdeluxe
fd2cb724a3 Release v1.7.172 2026-05-31 19:28:55 +02:00
Sucukdeluxe
9d8351c017 Fix: Mega-Debrid "Kein Server fuer diesen Hoster" (Tageslimit) -> schnell scheitern + rotieren
User-Report: Account 1 am Tageslimit liefert "Kein Server fuer diesen Hoster verfuegbar".
Bisher lief das durch die volle Web-Retry-Maschine (generate->null -> re-Login -> 3x
REQUEST_RETRIES) und fraß ~40s des GETEILTEN 60s-Unrestrict-Budgets -> der funktionierende
naechste Account (FabelDavid) lief in den Timeout (aborted:debrid -> als fatal klassifiziert,
"abgebrochen (fataler Fehler)" im Rotations-Verlauf), obwohl er gehen wuerde.

Fix (3 Teile, gemeinsame MEGA_DEBRID_NO_SERVER_RE):
1. mega-web-fallback generate(): die "Kein Server"-Meldung wird surfacet (throw) statt
   null zurueckzugeben -> kein re-Login + erneutes Pollen.
2. unrestrictViaWeb: bricht bei der Meldung ab (kein 3x-REQUEST_RETRIES) -> sofortige
   Retries sind zwecklos (Limit bleibt) und verbrennen das geteilte Rotations-Budget.
3. classifyAccountFailure: erkennt die Meldung -> quota-Cooldown (2 min) -> naechster
   Account, mit echter Meldung im Log statt generischem "Antwort leer".

So scheitert der limitierte Account schnell (1 Versuch) und der naechste Account bekommt
das volle Budget zum Aufloesen.

Tests: mega-web-fallback (throw + ajaxCalls=1) + debrid-Rotation (acc1 Limit -> acc2,
calls=2). 645 Tests gruen, tsc 9, Build sauber.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:23:49 +02:00
Sucukdeluxe
1e5cd3012b Release v1.7.171 2026-05-31 14:29:53 +02:00
Sucukdeluxe
c5a4cb3488 Fix: Account-Status ueberlebt Settings-Save (Badge bleibt nach Dialog-Speichern)
Folge-Fix zum Sofort-Check (efb5696): updateSettings uebernahm debridAccountStatuses
aus dem (evtl. veralteten) Renderer-Settings-Patch statt aus dem Manager. Speichern
direkt nach Hinzufuegen+Pruefen eines Accounts konnte so den frisch geprueften Status
ueberschreiben -> Badge sprang zurueck auf "Noch nicht geprueft". debridAccountStatuses
ist main-owned Runtime-State (nur via applyDebridAccountStatuses gesetzt) -> wird in
updateSettings jetzt aus dem Live-Manager bewahrt (wie die Usage-Counter). Schuetzt auch
den "Alle pruefen"+Settings-Save-Pfad. 643 Tests gruen, tsc 9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:29:17 +02:00
Sucukdeluxe
efb5696c13 Feature: Mega-Debrid-Account beim Hinzufuegen sofort pruefen (Gueltigkeit + Premium)
Beim "Hinzufuegen" eines Mega-Debrid-Accounts im Bearbeiten-Dialog wird der Account
jetzt sofort einzeln geprueft (connectUser) — Login-Gueltigkeit + Premium-Restlaufzeit
erscheinen direkt als Badge, ohne den Tab schliessen und "Alle pruefen" klicken zu
muessen. Waehrend der Pruefung zeigt das Badge "Pruefe…".

Neue Einzel-Check-IPC (Spiegel von checkDebridAccounts): CHECK_MEGA_DEBRID_ACCOUNT
-> app-controller.checkSingleMegaDebridAccount(login, password) baut den Account-Entry
(id via getMegaDebridAccountId), ruft die bestehende checkMegaDebridAccount() und merged
das Ergebnis via applyDebridAccountStatuses -> Snapshot -> Badge (gleicher Pfad wie
"Alle pruefen"). Funktioniert fuer den noch nicht gespeicherten Draft-Account, weil
applyDebridAccountStatuses merged (kein Pruning) + emitState.

Kern-Check-Logik unveraendert + weiterhin durch account-check.test.ts gedeckt.
643 Tests gruen, tsc 9 (unveraendert), Build sauber. GUI compile-/build-verifiziert,
im laufenden Electron noch nicht click-getestet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:20:21 +02:00
Sucukdeluxe
4a883fb93f Release v1.7.170 2026-05-31 13:12:06 +02:00
Sucukdeluxe
66878174e6 Feature: Mega-Debrid-Accounts einzeln (temporaer) deaktivieren — UI-Toggle
Backend war bereits vorhanden (megaDebridDisabledAccountIds + Rotation-Skip +
Storage-Normalisierung); es fehlte nur das UI. Spiegelt das Debrid-Link-Muster:
im Account-Bearbeiten-Dialog bekommt jeder Mega-Account einen Aktivieren/
Deaktivieren-Toggle (+ "Deaktiviert"-Badge). Der Disabled-Zustand wird im Dialog-
Draft gehalten (megaDisabledIds) und beim Speichern via applyAccountDialogToSettings
in megaDebridDisabledAccountIds uebernommen (gefiltert auf vorhandene Accounts).
Kein Live-Persist mitten im Dialog -> kohaerent mit dem draft-then-Save-Modell.

Wirkt OHNE Neustart: DebridService.unrestrictLink liest this.settings live
(setSettings propagiert die Liste), unrestrictWithAccounts ueberspringt deaktivierte
Accounts (gleicher Mechanismus wie Daily-Limit/Cooldown-Skip).

Test: "skips a manually disabled Mega-Debrid account" — acc1 disabled -> acc2 loest
auf (beweist den ID-Seam getMegaDebridAccountId). 643 Tests gruen, tsc 9, Build sauber.
GUI-Toggle compile-/build-verifiziert, im laufenden Electron noch nicht click-getestet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:08:09 +02:00
Sucukdeluxe
661b1e8c21 Test: Mega-Debrid Multi-Account-Rotation bei Tageslimit-Fehler (Coverage-Luecke)
Es gab Rotations-Tests fuer Debrid-Link, aber KEINEN fuer Mega-Debrid. Beweist die
vom User geforderte Rotationstatsache: liefert ein Account den Tageslimit-Fehler,
rotiert unrestrictWithAccounts zum naechsten Account (acc1 Limit-Fehler -> acc2 loest
den Link auf). Fehler-basiert (NICHT timeout-basiert — der reverted v1.7.168-Ansatz).
64 Tests in debrid.test.ts gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:19:31 +02:00
Sucukdeluxe
613ebfd50a Docs: lessons.md — Fix-Diagnose empirisch bestaetigen (Timeout != Account-Haenger)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:07:23 +02:00
Sucukdeluxe
f098f52498 Release v1.7.169 2026-05-31 00:05:17 +02:00
Sucukdeluxe
13885b830c Revert: Per-Account-Timeout (v1.7.168) war Fehldiagnose — Mega-Web pollt legitim bis 180s
v1.7.168 fuehrte einen 25s-Per-Account-Timeout in die Rotation ein, Annahme: ein
"haengender" Account solle uebersprungen werden. Falsch: der Mega-Debrid-WEB-Unrestrict
ist eine Polling-Schleife (mega-web-fallback.ts: bis 60 Durchlaeufe, intern 180s-Ceiling)
— Mega-Debrid laedt die Datei erst auf den eigenen Server, das dauert legitim 30-180s.
Der 25s-Cap schnitt JEDEN Account mitten im Polling ab ("Account-Timeout nach 25s" in
Dauerschleife), die Datei wurde nie aufgeloest. Ein Timeout ist bei einem langsam-
pollenden Provider KEIN Account-Fehler und darf keine Rotation ausloesen.

Revert auf den Stand vor c4a49d9: Rotation nur noch bei echten Account-Fehlern
(Quota/Ban/ungueltig -> Cooldown -> naechster). debrid.ts + debrid.test.ts (inkl. des
dedizierten Per-Account-Timeout-Tests) zurueckgesetzt. 641 Tests gruen, tsc 9 (unveraendert).

WICHTIG: Behebt nur die von mir verursachte Regression — macht den Download NICHT von
selbst funktionsfaehig. Offene Frage (Mega-Web langsam-aber-funktioniert vs. Server-IP
geblockt) ist erst zu klaeren, bevor am eigentlichen Unrestrict-Timeout gedreht wird.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:04:34 +02:00
Sucukdeluxe
4bded129ce Docs: lessons.md — Hung-Chat fortsetzen via reflog-Recovery
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:45:41 +02:00
Sucukdeluxe
664e34fc53 Test: dedizierter Per-Account-Timeout-Rotationstest (acc1 haengt -> acc2 versucht)
Schliesst die Coverage-Luecke zum v1.7.168-Fix (c4a49d9): der korrigierte Abort-Test
prueft nur Signal-Propagation, nicht die Kernlogik des Fix. Dieser Test (Fake-Timer)
faehrt den echten Rotationspfad: acc1 haengt bis sein Per-Account-Timeout feuert ->
30s-Cooldown -> Loop probiert acc2 -> Erfolg. Ohne den Fix (keine Abort-Quelle bei
fehlendem globalem Signal) wuerde acc1 ewig haengen -> Test-Timeout. 642 Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:44:19 +02:00
Sucukdeluxe
34a1a59a2a Release v1.7.168 2026-05-30 23:38:08 +02:00
Sucukdeluxe
c4a49d99ed Fix: Per-Account-Timeout in Account-Rotation (Mega-Debrid + Debrid-Link)
Kernbug (User-Log, v1.7.168): "Unrestrict Timeout nach 60s" — Account 1 hing die
volle Zeit, acc2/acc3 wurden NIE versucht. Ursache: die gesamte Account-Rotation
lief unter EINEM geteilten ~60s-Signal (download-manager wickelt den ganzen
unrestrictLink in getUnrestrictTimeoutMs()); haengt acc1 bis es feuert, bricht die
ganze Rotation ab.

Fix (debrid.ts): jeder Account/Key bekommt im Rotations-Loop sein EIGENES Timeout
(PER_ACCOUNT_ATTEMPT_TIMEOUT_MS=25s, env RD_PER_ACCOUNT_TIMEOUT_MS, clamp 8-45s) via
AbortController + AbortSignal.any([global, attempt]). Catch: globaler signal.aborted
-> throw (Rotation stoppen); nur attemptController.signal.aborted -> 30s-Cooldown +
naechster Account. Ueber die Retry-Zyklen werden mit den Cooldowns alle Accounts erreicht.

Test: "aborts Mega web unrestrict when caller signal is cancelled" pruefte vorher
Objekt-Identitaet (.toBe(controller.signal)); der Per-Account-Timeout wrappt das Signal
aber zwingend (AbortSignal.any), die gereichte Instanz ist daher absichtlich nicht mehr
identisch. Umgestellt auf VERHALTEN: gereichtes Signal ist eine AbortSignal-Instanz und
propagiert das Caller-Cancel (aborted=true).

Recovered aus dem reset-weggesetzten Commit ae3ee1f (der andere Chat committete den Fix,
der Test brach, er resettete + hing). 641 Tests gruen, tsc unveraendert (9 pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:37:16 +02:00
Sucukdeluxe
2448ae5c7a Docs: lessons.md — Release-Verifikation + Gitea-UNIQUE-Recovery + curl-F-Leerzeichen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:54:50 +02:00
Sucukdeluxe
211e7e16cf Release v1.7.167 2026-05-30 22:51:18 +02:00
Sucukdeluxe
dd31bee8b1 Rotation: jeden Account-Versuch ins ITEM-Log schreiben (Sichtbarkeit)
User sah im Item-Log nur "Link-Umwandlung gestartet" -> "Unrestrict Timeout
60s" -> "erneut Versuch 1/inf", aber nie welcher Account/Key wann probiert
wurde. Die Rotation lief nur in account-rotation.log + Panel.

Jetzt: AsyncLocalStorage-Item-Sink (parallel-sicher bei 8 gleichzeitigen
Unrestricts) leitet JEDEN Rotations-Event in das Log des betroffenen Items:
"Account-Rotation: Mega-Debrid Web - Account 1 (xy) wird versucht / fehl-
geschlagen (Timeout) -> Account 2". Damit ist im Item-Log direkt sichtbar,
ob acc2/acc3 ueberhaupt erreicht werden -> dient auch als Diagnose fuer den
vermuteten Timeout-Bug (kommt separat, falls das Log Stillstand zeigt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:50:49 +02:00
Sucukdeluxe
b414ab1773 Release v1.7.166 2026-05-30 22:37:50 +02:00
Sucukdeluxe
5fc80b7b7f Release v1.7.165 2026-05-30 22:35:33 +02:00
Sucukdeluxe
4b1625c5ee Release v1.7.164 2026-05-30 21:20:03 +02:00
Sucukdeluxe
3977184fd4 Account-Rotation: Login/Premium-Badges + Live-Rotations-Panel + "Alle pruefen"
- Pro Mega-Debrid-Account UND Debrid-Link-Key im Bearbeiten-Dialog: Badge mit
  Login-Gueltigkeit + Premium-Restlaufzeit (connectUser vip_end / account/infos premiumLeft)
- "Alle pruefen"-Button oben rechts; prueft alle Accounts (Concurrency-Cap 4),
  Ergebnis persistiert (debridAccountStatuses), ueberlebt Neustart
- Rotations-Verlauf-Panel: zeigt live welcher Account/Key versucht wurde + warum
  gewechselt (Ring-Buffer -> Snapshot -> UI), statt nur "Link-Umwandlung erneut"
- Bug A: Mega-Debrid Per-Account-Verbrauch wurde nie erfasst (Heute/Insgesamt immer 0)
- Bug B: isProviderConfigured erkannte reine megaCredentials-Multi-Config nicht
- Neu: account-check.ts (standalone), CHECK_DEBRID_ACCOUNTS IPC, 13 Tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:19:23 +02:00
Sucukdeluxe
748c07a531 Release v1.7.163 2026-05-28 22:29:35 +02:00
Sucukdeluxe
d923d6dabb Docs: tasks/lessons.md — Befund-gegen-Realitaet gaten + Crash-Debris stashen
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:25:16 +02:00
Sucukdeluxe
5495f5f24f Test: e2e Wiring-Lock fuer Deferred-Pass-Rename (treatFilesAsStable)
Ergaenzt den Mechanism-Test um einen End-to-End-Test, der den echten
Produktionspfad runDeferredPostExtraction -> Rename -> Collect faehrt. Sperrt
die Verdrahtung: wuerde jemand das `true` an der Rename-Call-Site (12130)
entfernen, faellt dieser Test (frische Datei landet wieder unbenannt) — der
reine Mechanism-Test wuerde das nicht bemerken. Negativ-Gate verifiziert
(ohne `true` -> FAIL). 624 Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:24:20 +02:00
Sucukdeluxe
35622445da Fix: Deferred-Final-Pass benennt frische Dateien vor dem Collect um
Folge-Fund zu 18eada9 (Opus-Verifikation des deferFreshFiles-Konzepts):
18eada9 schloss den "frische Datei landet mit Original-Scene-Namen in der
Library"-Bug nur fuer den Hybrid-Pfad (deferFreshFiles=true + Mehrfach-Paesse).
Der finale Deferred-Pass blieb betroffen.

Root Cause (verifiziert via failing Test gegen HEAD):
- runDeferredPostExtraction macht Rename -> Collect (deferFreshFiles=false). Ist
  eine Datei beim Deferred-Rename noch "frisch" (juenger als fileStabilizeMinAgeMs,
  prod=2000ms) -- v.a. eine eben per Nested-Extraction geschriebene Datei --
  ueberspringt der Frische-Gate sie, und der Collect moved sie mit Original-
  Scene-Namen in die Library. collectMkvFilesToLibrary benennt selbst nicht um
  (buildUniqueFlattenTargetPath, nur Flatten).
- Im Deferred-FINAL-Pass gibt es keinen concurrent Extractor-Write mehr
  (Extraktion inkl. Nested ist awaited) -- der Frische-Gate ist dort ein False
  Positive. Pre-existierender Gap (Frische-Skip aelter als 18eada9), auch
  v1.7.162 betroffen.

Fix (minimal): treatFilesAsStable-Param durch autoRenameExtractedVideoFiles(Impl).
Der Deferred-Final-Pass ruft mit treatFilesAsStable=true -> Frische-Gate umgangen
-> alle Dateien werden umbenannt, bevor der Collect sie sammelt. Hybrid-Pfad
unangetastet (nutzt ...Impl mit Default false -> Frische-Skip bleibt aktiv).

Regressionstest: frische Datei im Deferred-Pass landet UMBENANNT in der Library.
623 Tests gruen, tsc unveraendert (9 pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:16:01 +02:00
Sucukdeluxe
30dbbbae9e Release v1.7.162 2026-05-28 17:53:51 +02:00
Sucukdeluxe
98dc36648c Support-Bundle verschlankt: Item-/Package-Logs nur noch letzte 8h
Das Bundle packte bisher alle logs-Unterordner rekursiv (addDirectoryIfExists)
→ tausende Per-Item-Logs → 200+ MB (User-Bundle: 4273 Item-Logs, 214 MB).
Zum Verschicken/Analysieren unhandlich.

Neue addRecentDirectoryFiles(): packt nur Dateien mit mtime in den letzten
8h. Package-Logs und Item-Logs nutzen das 8h-Fenster; Session-Logs (wenige
Dateien) weiterhin komplett. Haupt-Logs (rd_downloader.log, rename.log,
audit.log, session.log, trace.log) waren schon per addFileIfExists einzeln
gepackt und bleiben unveraendert. Live-Logs der aktiven Queue (laufende
Session) ebenfalls komplett.

Ergebnis: Bundle enthaelt alles fuer aktuelle Fehler + Rename-Probleme,
aber kein Bloat durch tausende alte Item-Logs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:53:09 +02:00
Sucukdeluxe
8870a3aeca Release v1.7.161 2026-05-28 17:47:02 +02:00
Sucukdeluxe
18eada963f Fix: Hybrid-Rename-Race — 1-2 Dateien pro Staffel blieben unbenannt
User-Report (verifiziert via Support-Bundle): pl3x-24hours.s01e07,
tmsf-burnnotice-s05e11-repack, -s05e15 landeten mit Original-Scene-Namen in der
Library statt umbenannt. Andere Episoden derselben Pakete (formatidentisch)
wurden korrekt umbenannt → kein Format-Problem, sondern Timing-Race.

Root Cause (aus Log-Timeline):
1. autoRenameExtractedVideoFilesImpl erfasste `now` EINMAL am Scan-Start. Bei
   Hybrid-Extraktion werden weitere Dateien WÄHREND des Scans geschrieben →
   deren mtime > now → negatives ageMs → der "Clock-Skew = stabil"-Zweig wertete
   sie faelschlich als stabil → Rename mitten im Extractor-Write → EBUSY → 200ms-
   Retry deferred.
2. Der MKV-Collect hatte KEINEN Frische-Skip und moved die Datei im Retry-Fenster
   mit Original-Namen, bevor der Rename-Retry feuerte.
3. Rename + Collect liefen als zwei separate chainPackageFileOp-Ketten →
   ueberlappende Hybrid-Runden konnten einen Collect zwischen Rename und Collect
   einer anderen Runde einschieben.

Fix (3 Teile, scoped auf extractDir des Pakets — kein Shared-Library-Scan, nicht
das v1.7.107-Antipattern):
1. `now` wird PRO DATEI erfasst → frisch-geschriebene Dateien korrekt als "frisch"
   erkannt und deferred (statt EBUSY-Rename mitten im Write).
2. collectMkvFilesToLibrary bekommt deferFreshFiles-Param: im Hybrid-Pfad werden
   frische Dateien (juenger als fileStabilizeMinAgeMs) uebersprungen statt unbenannt
   gemoved. Der finale Deferred-Pass (deferFreshFiles=false) sammelt sie nach
   Stabilisierung ein (Safety-Net).
3. Hybrid-Pfad: Rename (Impl-Variante, kein Self-Chain) + Collect in EINER
   chainPackageFileOp-Kette → atomar, kein Interleaving ueberlappender Runden.

Deferred-Pfad unangetastet (dort keine concurrent Extraktion). Regressionstest:
frische Datei wird im Hybrid-Collect deferred, vom finalen Pass gesammelt.
622 Tests gruen, tsc-Fehlerzahl unveraendert (9 pre-existing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:44:19 +02:00
Sucukdeluxe
e061997ed2 Release v1.7.160 2026-05-24 19:08:04 +02:00
Sucukdeluxe
08372f99cb Fix: neu hinzugefügtes Archiv-Passwort greift jetzt ohne App-Neustart
User-Report: ZIP scheitert mit wrong_password -> Passwort zur Liste hinzufügen
+ Settings speichern -> "Jetzt entpacken" scheitert WIEDER -> erst nach
App-Neustart klappt "Jetzt entpacken".

Analyse (eigenständig + zweiter Analyse-Agent, gesamte TS+Java-Kette):
Renderer-Save -> updateSettings (fingerprint enthält archivePasswordList) ->
setSettings (this.settings aktualisiert) -> extractNow -> runPackagePostProcessing
-> extractPackageArchives (passwordList: this.settings.archivePasswordList) ->
archivePasswordCandidates (enthält neues PW) -> Daemon bekommt Passwörter PRO
REQUEST. Java-Daemon probiert alle PW frisch, kein Per-Archiv-Cache; zip4j/
sevenzipjbinding ohne relevanten static State. Alle Pfade propagieren die neue
Liste korrekt — statisch ist KEIN Bug auffindbar.

Verifikation per Ausschluss: die EINZIGE zustandsbehaftete Komponente, die ein
App-Neustart zurücksetzt und ein Settings-Save NICHT, ist der langlebige
JVM-Daemon-Prozess (+ der In-Memory Learned-Password-Cache). Der User bestätigt
empirisch, dass ein Neustart (= frischer Daemon) es fixt.

Fix: neue resetExtractorCachesForPasswordChange() repliziert den Neustart-Effekt
am Extractor-Subsystem — bei Änderung der Passwortliste in setSettings wird der
Learned-Password-Cache geleert und der idle JVM-Daemon heruntergefahren, sodass
die nächste Extraktion frisch mit der neuen Liste startet. Beschäftigter Daemon
wird nicht abgebrochen (laufende Extraktion bleibt unangetastet).

Diagnostik: setSettings loggt jetzt PW-Anzahl + Reset-Resultat. Zusammen mit den
bestehenden "Archiv-Passwortliste: passwordCount=N"-Logs lässt sich bei erneutem
Auftreten in 30s unterscheiden, ob das PW beim Extractor ankommt (H2, Fix greift)
oder nicht (H1, dann TS-upstream). Best-bet per Ausschluss + Logs zur finalen
Bestätigung beim nächsten Repro.

621 Tests grün, tsc-Fehlerzahl unverändert (9 pre-existing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:07:22 +02:00
Sucukdeluxe
4398afa271 Release v1.7.159 2026-05-23 17:36:33 +02:00
Sucukdeluxe
682bd1d759 M2 sauber gelöst: Auto-Relaunch nach Backup-Import
Problem: nach importBackup hielt der Manager weiter die STALE In-Memory-Session
(Import schrieb nur auf Disk). blockAllPersistence wurde gesetzt um Überschreiben
zu verhindern, aber nie zurückgesetzt → ignorierte der User die manuelle
"Bitte neustarten"-Aufforderung und arbeitete weiter, ging bei hartem Crash
alles verloren (stille Persistenz-Blockade).

In-Memory-Reload verworfen: aborted activeTasks settlen ASYNC und greifen in
ihren finally-Blöcken auf this.session.items[id] zu — ein Session-Swap würde
dagegen racen. Sicherer Reload bräuchte async-Refactor (alle Tasks awaiten).

Lösung: Auto-Relaunch. Nach restored===true startet main.ts die App automatisch
neu (1.5s Delay für Toast). Der frische Prozess lädt die restored Session sauber
über den bewährten Startup-Pfad — null Stale-State-Risiko. Main-getrieben (nicht
Renderer), damit ein Renderer-Fehler den Restart nicht verhindern kann.
skipShutdownPersist/blockAllPersistence schützen weiterhin das kurze Fenster +
den Quit (prepareForShutdown:5680 überspringt Persistenz sauber, fasst die stale
Session nicht an). Nach Relaunch: frischer Prozess, Flags zurückgesetzt — Footgun weg.

621 Tests grün, tsc-Fehlerzahl unverändert (9 pre-existing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 17:35:51 +02:00
Sucukdeluxe
6dc32303a0 Release v1.7.158 2026-05-23 16:42:31 +02:00
Sucukdeluxe
7d52d5a495 Deferred-Post-Processing Lifecycle härten (H1/H2/M1) + 0-Byte-Fix (H3) + Dead Code (N1)
Aus der Bug-Analyse (3 Subagents): die Deferred-Post-Processing-Pipeline war
nur halb ins Abbruch-/Lifecycle-Management integriert — gleiche Ecke wie der
v1.7.156-Datenverlust.

H1: abortPostProcessing (globaler Stop/Shutdown/clearAll/external) bricht jetzt
    auch packageDeferredPostProcessAbortControllers + die neue Hybrid-Map ab.
    Vorher rasten MKV-Move/Cleanup/Rename gegen den synchronen Shutdown-Save.

H2: Hybrid-Post-Extract (Rename+MKV-Collect) lief als komplett ungetracktes
    detached Promise. Jetzt in packageHybridPostProcessControllers (Set/Package)
    registriert — SYNCHRON vor dem Promise, mit shouldAbort an beide Aufrufe.
    Bewusst SEPARAT von der Deferred-Map, sonst würde runDeferredPostExtraction's
    replace-Logik die laufende Hybrid-Arbeit selbst killen (Advisor-Fund).
    Cancel/Reset/Stop stoppt jetzt laufende Hybrid-Verschiebungen.

M1: hasAnyDeferredPostProcessPending() — Scheduler-Abschluss + finishRun-Clear
    gaten darauf. Run endet/Summary feuert nicht mehr während im Hintergrund
    noch Dateien verschoben werden; Run-State wird nicht mehr mittendrin geleert.

H3: validateDownloadedFileCompletion akzeptierte 0-Byte bei source=stream-end
    (kein Content-Length, keine Provider-Größe) als "fertig". Jetzt ok:false
    -> bestehender download_underflow-Retry-Pfad. Verhindert leere Datei = komplett.

N1: toter (unerreichbarer) Disk-Fallback-Block in findReadyArchiveSets +
    verwaiste pendingItemStatus-Map entfernt (verhaltensneutral).

Bewusst übersprungen: M2 (blockAllPersistence — vorgeschlagener Reset wäre
unsicher, In-Memory-Session ist nach Import stale) und M3 (cancelPendingAsyncSaves
— Generation-Guard schützt Korrektheit bereits). Siehe tasks/todo.md.

8 neue Tests (tests/download-completion.test.ts) inkl. H3-Regression. 621 Tests grün.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:39:34 +02:00
Sucukdeluxe
d1274d23dc Release v1.7.157 2026-05-23 16:19:26 +02:00
Sucukdeluxe
96bcbd13d7 Ember-Theme: warmer Amber-Akzent auf kühlem Navy
Redesign-Entscheid des Users (siehe design-mockups/): Aurora-Flavor "Ember".
Re-Skin der Dark+Light-Theme-Variablen + hartcodierte Cyan-Stellen:

- --accent Cyan #38bdf8 -> Amber #f2942d, neue --accent-2 Koral #ff7a5c
- Surfaces/Text/Muted leicht ins Warme verschoben (Warm-Kalt-Spannung:
  Amber-Signal auf kühlem Navy bleibt erhalten)
- Download-Progress Cyan-Gradient -> Amber->Koral (3 Stellen)
- Accent-Button + Border -> Amber
- Speed-Chart (App.tsx Canvas) Akzent -> Amber
- Grün (Entpacken/Online/Erfolg) bleibt semantisch unverändert

Bewusst KEINE Glas-/Glow-/Gradient-Mesh-Effekte (User lehnt KI-Look ab) —
nur die warme Farbwelt auf der bestehenden flachen Struktur.

design-mockups/: 4 Erst-Richtungen + Aurora-Flavor-Switcher + Forge (Referenz).
tasks/todo.md: Bug-/Feature-Analyse (3 parallele Subagents).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:17:47 +02:00
Sucukdeluxe
d54a9d603b Release v1.7.156 2026-05-23 15:12:40 +02:00
Sucukdeluxe
ceda9817f8 v1.7.156 HOTFIX: MKV-Collection loescht keine pending Archive im outputDir mehr
KRITISCHER Datenverlust-Fix (Regression aus v1.7.154):

collectMkvFilesToLibrary lief seit v1.7.154 mit einem Cleanup-Loop ueber
BEIDE Source-Dirs (extractDir + outputDir). cleanupNonMkvResidualFiles
loescht alle Nicht-Video-Dateien — auf dem outputDir traf das auch die
RAR-Archive. Bei Multi-Archive-Set-Paketen (z.B. S01 + S02 RARs im selben
outputDir) wurde nach dem Extrahieren von S01 die MKV-Collection getriggert,
die dann die noch nicht entpackten S02-RAR-Parts als "Restdateien" loeschte.
Folge: S02 ging verloren (missing_file beim spaeteren Extract).

Fix: Destruktiver Cleanup (Restdateien + leere Ordner) laeuft jetzt NUR
noch auf dem cleanupDir:
- autoExtract=true  -> extractDir (entpackter Inhalt, fertig verarbeitet)
- autoExtract=false -> outputDir (kein Extract, finaler Inhalt)
Der outputDir wird bei autoExtract=true nie hier aufgeraeumt — das macht
die separate Archive-Cleanup-Pipeline mit Extraktions-Guards.

Das MKV-Scannen beider Dirs (v1.7.154 Mega-Direct-.mkv) bleibt erhalten,
nur der Cleanup ist eingegrenzt.

Regressionstest verifiziert: 2 RAR-Sets im outputDir, S01-MKVs in
extractDir -> collectMkvFilesToLibrary darf S02-RARs nicht loeschen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 15:12:00 +02:00
Sucukdeluxe
dfab5e0cb4 Release v1.7.155 2026-05-23 01:00:56 +02:00
Sucukdeluxe
6a90eb500e v1.7.155 Mega.nz Filename-Pre-Resolve via Public API
UX: Beim Hinzufuegen von mega.nz Links wurden bisher nur die opaken
URL-Fragmente angezeigt ("pZl1wBRQ" etc.). Echte Filenames kamen erst
beim Mega-Debrid Unrestrict-Call, d.h. unmittelbar vor Download-Start.

Fix: Neuer src/main/mega-public-api.ts holt Filename + Groesse direkt
von Mega's Public API (g.api.mega.co.nz/cs) ohne Mega-Debrid-Quota
anzufassen. AES-128-CBC Decryption der Attribute mit dem Key aus
dem URL-Fragment.

resolveFilenames (debrid.ts) ruft den neuen Resolver fuer alle
erkannten mega.nz Links auf (concurrency 4). Auf Fehler/Rate-Limit
fallback auf den bestehenden Unrestrict-Pfad.

19 neue Tests fuer URL-Parser, AES-Decryption, Mocked-Fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 01:00:08 +02:00
Sucukdeluxe
342b4180a1 Release v1.7.154 2026-05-23 00:44:46 +02:00
Sucukdeluxe
7ba8dd07b9 v1.7.154 collectMkvFilesToLibrary scannt jetzt extractDir UND outputDir
Bug: Direct .mkv Downloads (z.B. von Mega-Debrid bei mega.nz, die
KEIN Archiv liefern) blieben mit autoExtract=true im outputDir liegen
und kamen nie in die MKV-Library. collectMkvFilesToLibrary scannte
binary nur extractDir wenn autoExtract aktiv war.

Fix: Beide Source-Dirs scannen, dedupe by basename (extractDir wins),
Safety-Check + Existenz-Check pro Dir. Cleanup-Loop läuft auch pro Dir.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 00:43:43 +02:00
Sucukdeluxe
8ec5d17e09 Release v1.7.153 2026-04-22 17:22:50 +02:00
Sucukdeluxe
6fcad8bc6c v1.7.153 zwei Production-Regressionen aus v1.7.151 gefixt
Aus dem live Rename-Log + Library-Screenshot:

(1) .nfo Files landeten in der MKV-Library
   moveCompanionFiles() hatte ".nfo" in der Extensions-Liste — sollte
   nur fuer Subtitles gemoved werden. .nfo gehoert nicht in die
   Library. Aus dem Set entfernt; renameCompanionFiles laesst .nfo
   weiterhin mit-umbenennen (im Extract-Dir, harmlos), aber MKV-Move
   bringt sie nicht mehr in die Library.

(2) Vollstaendige Scene-Namen wurden auf "Show.SxxExx.mkv" gekuerzt
   buildSafeAutoRenameTargetPath hatte ein 247-Zeichen Total-Path-Cap,
   das vollstaendige Scene-Releases wie
     "Dr.House.S04E02.Der.Stoff.aus.dem.die.Heldin.ist.GERMAN.5.1.DL.AC3.720p.BDRiP.x264-TvR.mkv"
   abgelehnt hat → Fallback aktiviert → "Dr.House.S04E02.mkv".
   Die ABER renamePathWithExdevFallback wraps eh ueber
   toWindowsLongPathIfNeeded (\?\ Prefix), und der Endpfad nach
   MKV-Move ist viel kuerzer (Library-Dir-Prefix). Cap war
   ueberhaupt nicht noetig und hat aktiv schoene Namen abgesaegt.
   Cap entfernt; nur das 255-char NTFS Filename-Limit bleibt.

Test: neuer Test "mkv-move moves SUBTITLES to library but NOT .nfo
metadata files". 592/592 Tests gruen.

Im naechsten Schritt: Fix-Skript fuer den User damit existierende
Library-Files (mit .nfo + zu kurzen Namen) korrigiert werden ohne
Re-Download.
2026-04-22 17:22:14 +02:00
Sucukdeluxe
80b4b379f7 Release v1.7.152 2026-04-22 02:25:34 +02:00
Sucukdeluxe
7f7bcf8ab2 v1.7.151 Review-Findings nachgepflegt: 3 Edge-Cases entschaerft
Independent code review fand drei echte Probleme an v1.7.151:

(a) File-stability check bei Clock-Skew rueckwaerts:
   negative ageMs (mtime in der Zukunft, z.B. NTP-Korrektur, VM-Resume)
   wurde von "ageMs < 2000" als "frisch" interpretiert → Datei stuck
   bis Clock aufschliesst. Fix: ageMs >= 0 zusaetzlich pruefen — negativ
   = "definitiv stabil".

(c) Suffix-Loop koennte Source-File als Resolved-Target waehlen:
   wenn Source schon "<base>.2.mkv" heisst und das Original "<base>.mkv"
   anderswo existiert, koennte die .2/.3-Loop sich selbst auswaehlen.
   Fix: pathKey-Vergleich gegen sourcePath im Loop, springt weiter.

(f) xX-Format matched x264/x265/x266 Codec-Tokens:
   "5x265.x265.mkv" wurde als S05E265 interpretiert.
   "Movie.x264-GROUP.mkv" konnte phantome Episode triggern.
   Fix: zweite Number-Group auf \d{1,2} (max 99) gecapped + negativer
   Lookahead [\dx] dahinter. 3-stellige xX-Episoden (sehr selten) gehen
   verloren — moderne SxxEnnn deckt das ab. Schutz gegen alle gaengigen
   Codecs (x264/265/266, h264/265) und Aspect-Ratios (1920x1080).

Tests: neue assertions fuer x264/x265/aspect-ratio + 10x99 vs 10x100.
591/591 gruen.
2026-04-22 02:25:10 +02:00
Sucukdeluxe
c9d4e69bea Release v1.7.151 2026-04-22 02:17:37 +02:00
Sucukdeluxe
709a93b405 Auto-Rename Hardening: 9 weitere Bugs aus 10-Agent-Audit gefixt
B) Symlink-Following + Library-Cross-Risk verhindert
   - collectFilesByExtensions skippt jetzt Symbolic Links / Junctions
     (entry.isSymbolicLink) — der v1.7.107-Korruptions-Vektor kann nicht
     mehr ueber Reparse-Points zurueckkehren
   - autoRenameExtractedVideoFiles bricht ab wenn extractDir mit
     mkvLibraryDir ueberlappt (in beide Richtungen) → keine Cross-
     Package-Korruption durch fehlerhafte User-Konfig
   - collectMkvFilesToLibrary mit gleichem Schutz fuer sourceDir<->targetDir

C) Long-Path Silent Skip behoben
   - buildSafeAutoRenameTargetPath prueft jetzt zusaetzlich Gesamtpfad-
     Laenge (247 chars conservative Windows-Limit), nicht nur Datei-
     Namen-Laenge. Fallback zu kuerzerem Pfad greift jetzt zuverlaessig

D) Hybrid-Extract Partial-Write Race entschaerft
   - Files mit mtime juenger als 2s werden uebersprungen (im naechsten
     Scan re-evaluiert). Verhindert Rename auf gerade-noch-gschriebene
     MKVs waehrend Hybrid-Extract parallel arbeitet
   - Konfigurierbar via fileStabilizeMinAgeMs (Tests: VITEST=true => 0)

E) Retry-Logik fuer transiente Rename-Fehler
   - renamePathWithExdevFallback retried jetzt EBUSY/EACCES/EPERM/EEXIST
     mit 200/500/1000ms Backoff. Antivirus, Indexer, OneDrive, offene
     Player-Locks → automatisch geheilt statt permanent geskippt

F) Subtitle/.nfo Companion-Files werden mit-umbenannt UND mit-verschoben
   - Neue Helper renameCompanionFiles + moveCompanionFiles erkennen Subs
     (.srt/.ass/.ssa/.sub/.idx/.vtt/.smi) und Metadaten (.nfo) am Basis-
     Namen-Match. Auch Sprach-Tags wie .de.srt bleiben erhalten
   - Mediaplayer kann Subs nach Library-Move wieder automatisch laden

G) Sample-Token False-Positive entschaerft
   - Dateien die sampleTokenRe matchen bekommen Size-Check: nur als Sample
     behandelt wenn ≤150 MB. Series mit "Sample" im Titel (z.B.
     "Sample.Squad.S01E01.mkv") werden jetzt korrekt umbenannt
   - Sample-Subfolder-Detection bleibt unveraendert (eindeutig)

H) UNC + Casing-only Rename: jetzt via renamePathWithExdevFallback
   - Casing-Rename benutzt jetzt den gleichen Helper, bekommt automatisch
     toWindowsLongPathIfNeeded und Retry-Logik

I) Multi-MKV in selbem Folder: numerischer Suffix statt Skip
   - Wenn Ziel existiert: probiert .2, .3, ... bis .99 bevor aufgegeben.
     A/B-Parts oder alternate-Audio-Files in selbem Folder werden jetzt
     korrekt mit Suffix differenziert statt 2./3. File silent zu droppen

J) Episode-Token Coverage: xX-Format hinzugefuegt
   - Neuer SCENE_EPISODE_X_RE erkennt 1x01, 10x100, etc. (aeltere
     Scene-Releases). Quality-Tokens wie 1080p werden NICHT falsch
     als 1080xX matched (kein zweiter Number-Group)

Tests:
- Symlink-Guard: extractDir==mkvLibraryDir → 0 renamed, File unangetastet
- Companion: .srt/.de.srt/.nfo bei Rename mitbenannt
- Multi-MKV-Collision: 2 Files → suffix .2 statt skip
- Episode-Token: 1x01/10x100 erkannt, 1080p nicht falsch matched

589/589 Tests gruen.
2026-04-22 02:17:11 +02:00
Sucukdeluxe
5369ec0958 Release v1.7.150 2026-04-22 01:55:04 +02:00
Sucukdeluxe
36ff1c5a86 Cross-Pipe Race-Fix: Rename + MKV-Move teilen jetzt einen per-Package Lock
v1.7.149 hat den Race ZWISCHEN parallelen Auto-Rename-Scans gefixt
(autoRenameInFlight). Aber es gab noch einen anderen Race:

- Hybrid-Pfad (Z.10952-66) feuert "fire-and-forget" rename->mkvMove
- Deferred-Post-Process-Pfad (Z.11672/11748) feuert "awaited" rename + mkvMove

Beide Pipes koennen GLEICHZEITIG fuer dasselbe Package laufen. Innerhalb
einer Pipe ist rename->mkvMove sequentiell, aber Pipe A's mkvMove kann
WAEHREND Pipe B's rename starten (nachdem die Rename-Serialisierung von
v1.7.149 Pipe B entsperrt hat). Resultat: Pipe A bewegt File X aus
extractDir, Pipe B's rename versucht File X umzubenennen → ENOENT, oder
File landet mit altem Hoster-Namen in der Library.

Fix: autoRenameInFlight wird zu packageFileOpChain generalisiert. Helper
chainPackageFileOp(pkgId, fn) chained beliebige file-mutierende Ops auf
das vorherige Promise. autoRenameExtractedVideoFiles benutzt es intern,
und beide collectMkvFilesToLibrary-Aufrufstellen werden jetzt explizit
durch denselben Chain geroutet.

Effekt: pro Package laeuft maximal eine post-process Operation (rename
ODER mkvMove) zu jeder Zeit, egal welche Pipe sie triggert.

Tests:
- "serializes rename and mkvMove across hybrid + deferred pipes":
  4 chainPackageFileOp-Calls fuer dasselbe Package, max-concurrent == 1,
  Reihenfolge erhalten, Slot nach letztem Op geleert.
- "chainPackageFileOp recovers from a failed op": Fehler im ersten Op
  bricht die Chain nicht — nachfolgende Ops laufen normal weiter.

584/584 Tests gruen.
2026-04-22 01:54:35 +02:00
Sucukdeluxe
84d02c5f98 Release v1.7.149 2026-04-22 01:38:54 +02:00
Sucukdeluxe
c417ebb57f Auto-Rename Race-Fix: parallele Scans pro Package serialisieren
Im Production-Log:
  21:30:33.957Z Auto-Rename Scan gestartet | videoFiles=25
  21:30:33.992Z Auto-Rename Scan gestartet | videoFiles=25  (35ms spaeter, gleiches pkg!)
  21:30:33.994Z Auto-Rename durchgefuehrt | E24.B          (Scan 1)
  21:30:34.009Z Auto-Rename uebersprungen: Ziel existiert  (Scan 2 sieht renamed file)
  21:30:34.029Z Auto-Rename durchgefuehrt | E24.A          (Scan 1)
  21:30:34.056Z Auto-Rename fehlgeschlagen | ENOENT        (Scan 2 versucht renamed file)

Ursache: hybrid-extract feuert nach JEDEM erfolgreichen Archive einen
fire-and-forget autoRename (Z.10915), und der deferred Post-Process-Pfad
ruft am Ende nochmal autoRename auf (Z.11630). Bei einem Multi-Archive-
Package (25 Episoden) ueberlappen sich 2+ Scans auf demselben Fileset.

Ergebnis: "Ziel existiert"-Warnungen + ENOENT-Fehler beim Rename.
Manchmal blieben einzelne Files unbenannt durchrutschen (Scan 2 sieht
File X, will renamen, aber Scan 1 hat es schon weg-renamed).

Fix: pro Package via Promise-Chaining serialisieren. Neue Map
autoRenameInFlight haelt das laufende Scan-Promise pro packageId. Der
neue Wrapper kettet jeden weiteren Aufruf an das vorherige Promise an
— so laeuft maximal ein Scan zur Zeit pro Package, der naechste startet
erst wenn der vorherige fertig ist (und sieht damit den korrekten
Disk-State).

Test: zwei parallele autoRenameExtractedVideoFiles-Aufrufe fuer dasselbe
Package mit 3 obfuskierten Files. Beide resolven sauber, Summe der
Renames == 3, alle 3 Folders enthalten am Ende den korrekten Folder-
Namen statt Hoster-Obfuskation. 582/582 Tests gruen.
2026-04-22 01:38:26 +02:00
Sucukdeluxe
7ab508617a Release v1.7.148 2026-04-22 01:30:02 +02:00
Sucukdeluxe
834da04b45 Auto-Rename Folder-Override: nur bei OBFUSKIERTEM Source-Filename
Regression in v1.7.147: der Folder-Override (parentEpisodeToken
ueberschreibt sourceEpisodeToken bei Mismatch) ist zu aggressiv. Bei
sauberen Scene-Releases die zufaellig im falschen Folder liegen wuerde
das den Episodennamen FALSCH umschreiben.

Beispiel aus Production-Log:
  Folder: The.Royals.S01E08.Der.Grosse.stuerzt.German.DL.720p.BluRay.x264-J4F
  File:   the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv

Beide haben sauberes Scene-Format. Datei sagt klar S01E09 — Folder ist
falsch beschriftet (Hoster-Fehler). Die Datei zu S01E08 umbenennen
waere Daten-Korruption.

Fix: neue Helper-Funktion looksLikeObfuscatedSceneFileName() prueft ob
ein Filename Scene-Marker hat (720p/1080p, german/english, bluray/web/
hdtv, x264/x265, ac3/aac/dts) ODER 5+ Punkte als Scene-Struktur. Wenn
2+ Marker oder 5+ Punkte → KEIN Override (Source ist authoritativ).
Wenn weniger → Source ist obfuskiert, Folder gewinnt.

Beispiele aus Production:
- "awa-diethundermans02e16hd.mkv" (0 Marker, 0 Punkte) → obfuskiert,
  Folder Die.Thundermans.S02E01... gewinnt → korrekt umbenannt
- "the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv"
  (5 Marker) → sauber, Source bleibt → Skip statt Falsch-Rename
- "Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264.mkv"
  (5+ Marker) → sauber, kein Override

4 neue Unit-Tests fuer looksLikeObfuscatedSceneFileName, 581/581 gruen.
2026-04-22 01:29:32 +02:00
Sucukdeluxe
b1291d2e3c Release v1.7.147 2026-04-22 00:46:25 +02:00
Sucukdeluxe
19c31caab5 Auto-Rename: zwei Mismatch-Bugs gefixt (obfuskierter File-Token + weak Folder)
Bug 1 - Obfuskierter Datei-Token vs Folder:
Hoster verteilen Episoden in EPISODEN-Ordner ABER scrambeln den
Datei-Token zur Anti-Piracy:
  Folder: Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN...
  File:   awa-diethundermans02e16hd.mkv  (Datei sagt E16, Folder E01)

Bisher hat der Episode-Token-Mismatch-Check uebersprungen → Datei
behaelt obfuskierten Namen → MKV-Move kopiert Garbage-Namen ins
Library-Verzeichnis.

Fix: wenn der unmittelbare Eltern-Ordner explizit denselben SxxExx-
Token wie unser computed targetBaseName traegt (also ein Per-Episode
Scene-Folder ist, NICHT der Extract-Root), wird der Folder-Token als
authoritativ behandelt — Scene-Releases benennen Episoden-Folder
deterministisch korrekt, der Datei-Name ist die Obfuskation.

Bug 2 - Weak Folder ueberschreibt perfekten Source-Filename:
Source: Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264
Folder-Kette: ["S01 Complete", "Desperate.Housewives.S01.Synced..."]

Auto-Rename hat den unmittelbaren Parent "S01 Complete" gewaehlt und
daraus "S01E01 Complete" gebaut — kompletter Verlust des Series-Namens.

Fix: neue Helper-Funktion hasMeaningfulSeriesPrefix() prueft, ob ein
Name mindestens 3 alphabetische Zeichen VOR dem Season-Token hat.
Wenn (a) Source einen Episode-Token hat, (b) Source einen Series-Prefix
hat, (c) Computed Target KEINEN Series-Prefix hat und (d) Target weniger
als die Haelfte der Source-Laenge ist → Source behalten, Rename
ueberspringen. Renaming wuerde Information ZERSTOEREN.

Tests: 3 neue Unit-Tests fuer hasMeaningfulSeriesPrefix decken die
relevanten Faelle ab (echte Series-Namen vs generische Season-Labels
vs. Folder ohne Season-Token). 577/577 Tests gruen.
2026-04-22 00:45:49 +02:00
Sucukdeluxe
9e165b72a3 Release v1.7.146 2026-04-21 21:36:47 +02:00
Sucukdeluxe
75036edbd1 DLC-Import Hang gefixt: kein sync-FS Log-I/O mehr pro Link
Symptom: Nutzer zieht DLC mit vielen Paketen rein, App haengt 1-2 min.

Ursache: addPackages() rief logPackageForItem() pro Link auf. Jede
dieser Calls triggerte ~10 synchrone FS-Operationen:
  - ensurePackageLog: mkdirSync + existsSync
  - ensureItemLog: mkdirSync + existsSync + writeFileSync (first-time)
    + 2× appendFileSync (first-time header)
  - logPackage + writeItemLogEvent (appendFileSync, batched)

Bei einer DLC mit 60 Paketen × 25 Links = 1500 Items → ~9.000-15.000
sync FS-Calls. Auf langsamen Disks / Netzwerk-Shares: 60-120 Sekunden
Event-Loop-Blockade. UI eingefroren.

Fix: per-Item-Logs waehrend Bulk-Add nicht mehr initialisieren. Sie
werden lazy beim ersten echten Lifecycle-Event (Download-Start, Fehler)
angelegt. Stattdessen EINE zusammengefasste "Links registriert (N)"
Zeile ins Package-Log pro Paket — bei >50 Links mit gekuerzter
Vorschau (erste 20 + "+N more") damit die Log-Zeile nicht riesig wird.

Neuer Test "bulk-adds large DLC containers without initializing per-item
logs" verifiziert: 1500 Items werden in <5s hinzugefuegt (lokal unter
300ms), keine Item-Log-Dateien entstehen, pro Paket existiert genau ein
Package-Log.
2026-04-21 21:36:19 +02:00
Sucukdeluxe
56656dfef9 Release v1.7.145 2026-04-20 20:21:07 +02:00
Sucukdeluxe
90f347dc2b Startup Health-Check: proaktive Warnungen bei Problem-Zustaenden
Laeuft einmal beim App-Start und warnt klar im Log, wenn etwas auffaellt
— BEVOR der Nutzer mitten im Download stolpert. Blockiert den Start
nicht, schreibt nur in rd_downloader.log + audit-log.

Pruefungen:
- Download-Ziel-Ordner fehlt / nicht beschreibbar / nicht konfiguriert
- Runtime-Ordner (%APPDATA%/runtime) fehlt oder nicht beschreibbar
- Wenig Festplattenplatz im Download-Ordner (< 5 GB)
- Kein einziger Debrid-Provider konfiguriert → Downloads koennen nicht
  funktionieren
- State-Datei > 50 MB (alte abgeschlossene Pakete sollten geprunt werden)

Listet zudem als INFO alle aktiv konfigurierten Provider auf, damit aus
dem Startup-Log klar ist was aktiv ist (Mega-Debrid X Accounts,
Debrid-Link X Keys, etc.).

Reine Funktion runStartupHealthCheck() → HealthCheckReport, 6 Unit-Tests
decken die wichtigsten Pfade ab. Wiring in AppController-Constructor ist
in try/catch — falls der Check selbst abstuerzt, stoert das den Start
nicht.
2026-04-20 20:20:25 +02:00
Sucukdeluxe
a1697e652e Release v1.7.144 2026-04-20 16:52:50 +02:00
Sucukdeluxe
5ecb636d95 Debrid-Link skip-Errors: Key bleibt "ready" statt "error"
fileNotAvailable, disabledServerHost, notFreeHost, serverNotAllowed,
freeServerOverload, maintenanceHost, noServerHost sind LINK- oder
HOST-level Fehler, nicht Key-level. Der Key antwortet ganz normal und
sagt nur "diesen Link kann ich aktuell nicht verarbeiten".

Vorher wurde trotzdem der Runtime-Status auf "error" gesetzt — sah in
der UI aus als waere der Key kaputt und hat die Rotations-Heuristiken
irritiert.

Fix: bei failure.category === "skip" den Runtime-Status in Ruhe lassen.
Der Key bleibt "ready" (bzw. was er vorher war). Invalid bleibt
"invalid", alle anderen fehlerhaften Antworten bleiben "error".

Test: Key 1 gibt fileNotAvailable zurueck → Key 2 erfolgreich. Key 1
darf danach NICHT "error" sein (per neuem Test-Helper
getDebridLinkKeyRuntimeStateForTests).
2026-04-20 16:52:18 +02:00
Sucukdeluxe
8e1159565b Release v1.7.143 2026-04-19 23:42:09 +02:00
Sucukdeluxe
d62fa548cb Debrid-Link: per-(key, host) cooldown for maxLinkHost / maxDataHost
Previously, when a Debrid-Link key returned maxDataHost or maxLinkHost
("you've used up YOUR per-host quota for this hoster on this key"), the
WHOLE key got a 2-min key-wide cooldown — blocking it for all hosters
even though it was only exhausted for that one host.

Now those errors apply a per-(key, host) cooldown instead:
- Key 1 hits maxDataHost on rapidgator → only (Key 1, rapidgator) is
  blocked. Key 1 stays usable for uploaded.net etc. in the same rotation
- Same key + same host on subsequent attempts: skipped with explicit
  "Host-Cooldown rapidgator bis HH:MM:SS" log line
- Key-wide quotas (maxLink, maxData) still apply key-wide as before

Implementation:
- DEBRID_LINK_QUOTA_ERRORS split into key-wide vs host-only sets
- New debridLinkKeyHostCooldowns map keyed by `${keyId}|${hoster}`
- setDebridLinkKeyHostCooldownState mirrors max-wins / strong-category
  semantics of the per-key version, falls back to key-wide cooldown when
  the hoster can't be parsed (safer than thrashing)
- Key runtime status stays "ready" on host-only failures — only this
  (key, host) is blocked, the key is still healthy for other hosters
- Reset/prune helpers (resetDebridLinkRuntimeStateForTests,
  pruneDebridLinkRuntimeStateForKeys, pruneExpiredDebridLinkRuntimeState)
  clear the new map too
- New rotation log event SKIP_HOST_COOLDOWN

Test: 2 keys, key1 hits maxDataHost on rapidgator → key2 succeeds.
Second rapidgator request: key1 SKIPPED via host-cooldown.
Third request to uploaded.net: key1 tried again and succeeds.
2026-04-19 23:41:07 +02:00
Sucukdeluxe
3a8be961b0 Release v1.7.142 2026-04-19 23:04:01 +02:00
Sucukdeluxe
25aa48fe99 Account-rotation logging + transient cooldown fixes
- New dedicated account-rotation.log (audit-style) so multi-account/key
  rotation flow is visible without rd_downloader.log noise
- Mega-Debrid: always show "Account X/Y (masked@login)" label even with
  one account, and log a clear "TESTE Account fuer Link-Generierung..."
  line BEFORE every network call so the user sees which account is in
  play even if the call hangs
- Mega-Debrid: on FAILED, log which account will be tried next
  (skipping disabled/limited/cooldown ones), so the rotation is auditable
- Mega-Web "Antwort leer" / empty body now uses 20s cooldown instead of
  120s — empty responses are typically transient server hiccups, not
  real failures (caused healthy accounts to be unfairly blocked)
- Generic transport/unknown errors: 30s cooldown instead of 120s
- Global stall watchdog no longer counts items in "validating" status —
  per-item validating watchdog already handles them and gives the
  multi-account rotation enough time. Without this fix the global
  watchdog could abort the unrestrict mid-rotation (e.g. account 3 of 3
  still being tested) just because no download bytes had arrived
- Same logging treatment applied to Debrid-Link key rotation
2026-04-19 23:03:22 +02:00
Sucukdeluxe
4d2f11a96d Release v1.7.141 2026-04-19 22:12:06 +02:00
Sucukdeluxe
cdec0029fe Debrid-Link stability: stop double-blocking, shorter transport cooldown, max-wins
User reported Debrid-Link "often jumps into provider-cooldown" and feels
unstable. Root cause was four cooperating bugs that turned isolated key-level
failures into provider-wide multi-minute outages.

Fix 1: Skip provider-wide circuit breaker for ALL Debrid-Link errors
  (download-manager.ts ~8689)
  Previously only the explicit `debrid_link_cooldown:` sentinel was bypassed;
  every other Debrid-Link error (terminal failures, timeouts, parse errors)
  still went through recordProviderFailure() + applyProviderBusyBackoff(),
  applying a provider-wide cooldown ON TOP of the per-key cooldown debrid.ts
  already managed. Now any error message containing "debrid-link" or where
  the failure key is "debridlink" skips the provider-level circuit breaker
  entirely. Per-key cooldowns alone are the right granularity.

Fix 2: Transport errors get a short 15s cooldown, not 2 min
  (debrid.ts ~2684)
  A single network timeout / ECONNRESET was parking the key for 2 full
  minutes. With 9 keys all of which might experience the same transient
  issue at different moments, this could cascade into all keys cooling
  down for 2 min each. Now isolated transport hiccups get 15s while real
  API/server problems still get the full 2 min.

Fix 3: HTTP 200 with success:false (no error code) is now temporary, not fatal
  (debrid.ts ~2691)
  Previously these went through to the fallthrough "fatal: true" which
  permanently failed the item. Now they get a 30s temporary cooldown and
  the item retries on the next key.

Fix 4: setDebridLinkKeyCooldownState is max-wins under concurrent calls
  (debrid.ts ~135)
  When 8 parallel items all hit the same key with floodDetected, each
  computes its own cooldown duration and calls setDebrid LinkKeyCooldown
  State. Without max-wins, the LAST setter could shorten the cooldown
  (e.g. one item read a 1h Retry-After header, another defaulted to 2 min;
  the 2 min would then overwrite the 1h). Now the longer cooldown wins,
  with rate_limit/quota/invalid categories also winning over plain
  temporary regardless of duration.

Tests: 201/201 (debrid + download-manager) green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 22:11:35 +02:00
Sucukdeluxe
7be2d2e148 Release v1.7.140 2026-04-19 21:59:59 +02:00
Sucukdeluxe
c3590f08fc Fix stale-state when account credentials change at runtime
Reported user bug: "When I add an account, remove it, add a new one, the
new account is only really used after restart." Multiple cache layers were
not invalidated when settings changed, causing the system to keep using
stale state until the app was restarted.

Three layers of caching needed invalidation:

1. DebridService cached client instances (debrid.ts ~3067)
   The cached DebridLinkClient / LinkSnappyClient / DdownloadClient hold
   internal state (session cookies, auth tokens, parsed key lists). Without
   explicit invalidation when credentials change, the OLD client instance
   keeps serving requests until apiKeysRaw / login+password no longer match
   the cache key - which doesn't always trigger because the cache key may
   incidentally match (e.g. user removes and re-adds the same key).
   Fix: setSettings() now compares previous vs next credentials per provider
   and explicitly clears the cache when they differ.

2. MegaDebridClient.cachedApiTokens (debrid.ts ~1533)
   Static module-level token cache keyed by login (lowercase). When a user
   changes the password for an existing login (same login, new password),
   the cached token was kept for up to 20 minutes and would only get cleared
   after the API returned 401/403.
   Fix: Two new static methods - pruneCachedTokensNotIn() removes entries
   whose login is no longer in the active list, and clearCachedApiToken()
   force-clears a specific login. Both called from setSettings() based on
   diff between previous and next account list.

3. Module-level Debrid-Link cooldown maps (debrid.ts ~41-51)
   debridLinkKeyCooldowns / debridLinkKeyCooldownDetails / runtime statuses
   are keyed by API key ID (FNV-1a hash of the key). When a key was put
   into cooldown then removed from settings then re-added later, the old
   cooldown entry would still block it.
   Fix: New pruneDebridLinkRuntimeStateForKeys() function called from
   setSettings() removes cooldown entries for keys no longer in the active
   set.

4. providerFailures circuit-breaker map (download-manager.ts ~1990)
   Per-provider failure tracking with cooldownUntil. When a user removes a
   failing account and adds a new one of the same provider, the cooldown
   would carry over. Now setSettings() compares per-provider credentials
   and clears matching providerFailures entries when they change.

Reproduction (now fixed):
  1. Add Debrid-Link key A
  2. Trigger a failure (e.g. invalid link) so A goes into cooldown
  3. Remove A, add B in settings
  4. Try a download immediately - previously the cooldown for A or the
     cached client with A's state could still prevent B from being used.
     After this fix B is used immediately.

Tests: 201/201 (debrid + download-manager) green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 21:59:28 +02:00
Sucukdeluxe
04413599d8 Release v1.7.139 2026-04-19 14:11:16 +02:00
Sucukdeluxe
4d1f3c3fdc Performance: hash-based IPC state diffing (the big one)
Implements per-item / per-package hash-based diffing for the IPC state-update
channel. This is the architecturally biggest performance win — for queues
with thousands of items where most are idle between emits, this can cut
IPC payload size by 80-95%.

How it works:
1. New `getSnapshotForEmit()` method computes a compact hash per item and
   per package covering the visible/mutable fields. On each emit it includes
   only items/packages whose hash changed since the last emit, plus a list
   of removed IDs. Every 30 seconds a full resync is sent for safety.

2. A new `payloadKind: "full" | "delta"` field on UiSnapshot signals the
   format. `removedItemIds` and `removedPackageIds` lists carry deletions.

3. The renderer maintains a `masterSnapshotRef` and merges incoming deltas:
   spreads new items over master items, deletes the removed-IDs, then sets
   the merged snapshot as React state. Full payloads replace the master
   entirely (initial sync + 30s resync).

4. The existing direct `getSnapshot()` API used by app-controller, debug-server,
   and link-export is unchanged — they still get a full snapshot. Only the
   "state" emit channel uses delta encoding.

Trade-offs accepted:
- Hash computation cost: ~13 string concats per item per emit. With 5000
  items at 700ms intervals that's ~7100 hash ops/sec — well under 1ms total.
- The 30s full resync ensures any drift bug self-heals within 30s without
  user-visible glitch.
- Server keeps two extra Maps (item/package hash tracking).

Items / packages that are completely idle between emits add ZERO bytes to
the IPC payload now, instead of ~450 bytes per item. For a normal queue of
5000 items where ~30 are actively downloading, payload drops from ~3.6 MB
to ~30 KB per emit — a 100x reduction.

Tests: 140/140 download-manager + 133/133 storage+auto-rename green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 14:10:48 +02:00
Sucukdeluxe
ca47773317 Release v1.7.138 2026-04-19 13:57:42 +02:00
Sucukdeluxe
67fe689e28 Performance: cache cloneSettings() between snapshots (400ms TTL)
cloneSettings() copies 85+ fields including 6 nested usage Maps and the
bandwidth schedule array. With ~700ms emit interval most snapshot ticks
clone identical settings. Add a time-based cache (400ms TTL) so most
snapshots reuse the previous clone.

Settings are mutated in-place by hot paths (provider usage tracking, debrid
key counters), so a reference check wouldn't catch changes — but the 400ms
TTL window is short enough that user-visible setting changes still appear
within one render cycle. replaceSettings() explicitly invalidates the
cache for immediate visibility on user setting changes.

The 4 architecturally-invasive items I considered for this round —
recordSpeed batching (already 120ms-bucketed, false alarm), full IPC state
diffing (392 mutation sites, too risky), list virtualization (variable
package heights make it complex), and per-channel settings/stats split
(invasive type changes) — are deferred. This caching change captures most
of the practical settings-clone savings without the architectural risk.

All 140 download-manager tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 13:57:15 +02:00
Sucukdeluxe
ce33617aa6 Release v1.7.137 2026-04-19 13:47:24 +02:00
Sucukdeluxe
d71dd3af0b Performance: visiblePackages dep fix + ItemRow date memo + small Object.keys cuts
Five more low-risk hot-path optimizations:

1. visiblePackages no longer re-runs the sort callback on every item update.
   The sort is only meaningful when running && autoSort && >1 packages, so we
   pass null as items dep otherwise. Previously fired the full O(N) sort
   pass on every progress tick even when it would have returned the input
   array unchanged.

2. ItemRow memoizes formattedCreatedAt + displayStatus + statusTitle so a
   row that re-renders because of progress/speed changes no longer pays for
   formatDateTime() and computeDisplayedItemStatus() twice (title+body).

3. resetSessionTotalsIfQueueEmpty: removed redundant Object.keys() check.
   itemCount + packageOrder.length cover the same condition without
   allocating two intermediate arrays.

4. markQueuedAsReconnectWait: replaced Object.keys() array allocation with
   for-in iteration when runItemIds is empty. Saves a 5000-element string
   array allocation every 900ms during reconnect.

5. columnOrderJson useEffect dep: replaced JSON.stringify with cached
   join() + useMemo. Stringifying a 7-element settings array on every
   render was ~2-3ms per render for nothing.

All 140 download-manager tests green. Renderer + main both build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 13:46:57 +02:00
Sucukdeluxe
b92330863a Release v1.7.136 2026-04-19 13:31:08 +02:00
Sucukdeluxe
5b4ad99923 Performance: cloneSession shallow refs + scheduler 1-pass + speed obj alloc
Three deeper optimizations focused on hot allocations:

1. cloneSession(): items/packages references shared instead of per-item
   shallow clone. The IPC layer runs structuredClone() in the same tick
   so the renderer always gets an isolated copy; in-process consumers
   read snapshots synchronously without mutating. Eliminates ~5000
   object allocations per emit on a 5000-item queue.

2. findNextQueuedItem(): single-pass priority scan instead of 3 separate
   passes (high → normal → low). Returns immediately on high-priority
   match; collects best normal/low candidate while iterating. Saves up
   to 2x O(n) iterations per scheduler tick.

3. packageSpeedBps: direct loop assembly instead of
   Object.fromEntries([...Map].map(...)) (3 allocs per entry → 1).
   Idle case now returns a stable EMPTY_PACKAGE_SPEED_BPS reference
   so the renderer's useMemo on it doesn't recompute on every snapshot
   while the queue is paused/stopped.

All 565 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 13:30:42 +02:00
Sucukdeluxe
459d078cb0 Release v1.7.135 2026-04-19 13:08:08 +02:00
Sucukdeluxe
3c9894c7b0 Performance: ItemRow extraction + scheduler single-pass + selectedIds memo fix
Major optimizations to reduce UI lag with large queues (5000+ items):

1. ItemRow extracted to its own memoized component (renderer)
   Previously every package re-render mapped all its items inline,
   producing N×M re-renders per state update. Now each item-row only
   re-renders when ITS specific data changes, with custom equality on
   the visible fields (status, progress, speed, fullStatus, etc.).
   Also adds stable useCallback handlers per item.

2. PackageCard stats consolidated into single useMemo (renderer)
   Replaces 5 separate filter()/some() + 2 reduce() calls (O(7N)) with
   one O(N) pass collecting all aggregates (done/failed/cancelled/
   extracted/extracting/activeProgress/extractingProgress).

3. selectedIds memo comparator fixed (renderer)
   Custom equality now checks if selection state changed for items in
   THIS package only. Previously any selection anywhere broke memo on
   all 200+ visible PackageCards.

4. Scheduler single-pass queue presence (main)
   New getQueuePresence() returns hasImmediate + hasDelayed in one
   iteration. Replaces hasQueuedItems() + hasDelayedQueuedItems() that
   each scanned packages independently. Saves one full O(n) iteration
   per scheduler tick.

No functional changes. All 565 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 13:07:42 +02:00
Sucukdeluxe
c1edb07009 Release v1.7.134 2026-04-19 12:33:07 +02:00
Sucukdeluxe
bece2f3e85 Performance: prune long-lived caches, hoist regexes, idle chart redraws
Three low-risk optimizations that reduce CPU/memory footprint without
changing user-visible behavior:

1. Periodic cleanup of unbounded module-level Maps (24/7 stability):
   - debridLinkKeyCooldowns, debridLinkKeyCooldownDetails,
     debridLinkKeyRuntimeStatuses (debrid.ts)
   - megaDebridAccountCooldowns (debrid.ts)
   - allDebridHostInfoCache (download-manager.ts)
   - All pruned every 10 min via existing resetStaleRetryState() with a
     1h grace window for debugging
   - Without this, modules accumulated entries indefinitely over days
     of continuous server operation

2. Hoist regex literals in resolveArchiveItemsFromList() to module scope.
   Avoids 5 RegExp constructions per call. The function is called per
   archive set during extraction discovery — adds up on packages with
   many archives.

3. BandwidthChart: skip the 250ms redraw interval while the session is
   stopped or paused. The chart renders once on state change to show the
   latest history, then sleeps until downloads resume. Saves 4 canvas
   redraws per second when idle (renderer CPU).

No functional changes. All 565 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 12:32:38 +02:00
Sucukdeluxe
26edc79784 Release v1.7.133 2026-04-19 11:53:37 +02:00
Sucukdeluxe
d4b98ad172 Better error logging for non-Administrator/headless server scenarios
Three improvements for users running on servers where the Windows account
is not "Administrator" or where the environment is headless (Service, RDP-
disconnected, no interactive desktop):

1. readSettingsFile / readSessionFile: distinguish ENOENT (normal first run)
   from EACCES/EPERM (permission problem). The latter logs an explicit
   message including the current Windows username so the user can spot
   misconfigured ACLs immediately.

2. ensureBaseDir: log EACCES/EPERM with the current username before re-
   throwing. Previously the error bubbled up without any hint why the
   AppData directory creation failed.

3. createTray: log a warning when Tray creation fails (typical on Windows
   Service / headless servers / RDP disconnected sessions). Previously the
   error was silently swallowed and minimize-to-tray would just not work
   without explanation.

These errors were silently swallowed before, making it impossible to
diagnose problems on servers with restricted user accounts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 11:53:07 +02:00
Sucukdeluxe
f44a321e74 Release v1.7.132 2026-04-14 15:03:33 +02:00
Sucukdeluxe
6e936cd5bc Bonus dir detection: normalize separators (Making.Of, Behind.The.Scenes)
The v1.7.130 BONUS_DIR_PATTERNS used substring matching with space-separated
patterns like "making of" and "behind the scenes", but real-world subfolder
names use dot/dash/underscore separators (e.g. "Breaking.Bad.S05.Making.Of").
These were NOT detected as bonus dirs, causing the safety net in v1.7.131 to
apply the source filename's episode token to the package name, producing
mislabeled bonus files like "Breaking.Bad.S05E10.GERMAN.BluRay.720p.TSCC".

Fix: normalize folder segments by stripping all separators ([._-\s]+) before
matching against BONUS_DIR_NORMALIZED_PATTERNS. "Breaking.Bad.S05.Making.Of"
normalizes to "breakingbads05makingof" which matches "makingof".

Also extend BONUS_FILENAME_RE with "inside-e\d+" and "making-of-e\d+" to
catch more filename variants from Breaking Bad BluRay extras.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:03:02 +02:00
Sucukdeluxe
9f59b6e7ca Release v1.7.131 2026-04-14 11:46:15 +02:00
Sucukdeluxe
1dfb486145 Auto-rename safety net: never strip valid SxxExx episode token
Real-world scenario from user logs: package "Drei.Meter.ueber.dem.Himmel.
S01GERMAN.DL.720P.WEB.X264-WAYNE" (note malformed S01GERMAN with no
separator) caused the auto-renamer to strip the source's S01E01..S01E08
episode tokens because SCENE_SEASON_ONLY_RE doesn't match a season followed
by an immediate letter (no separator).

Result: all 8 episodes in the season pack collapsed to the same target name
and collided in the MKV library with (2)(3)(4)(5)(6)(7)(8) suffixes.

Fix: After buildAutoRenameBaseNameFromFoldersWithOptions, check if the
source filename has a valid episode token. If yes:
  1. If target has NO episode token: try to insert it via regex replacement
     (Sxx<garbage> -> SxxExx.<garbage>), then via applyEpisodeTokenToFolderName.
     If both fail, skip the rename entirely (preserve source name).
  2. If target has a DIFFERENT episode token: skip the rename (mislabel risk).

This guard is the last line of defense against the helper's regex
limitations on malformed package names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:45:41 +02:00
Sucukdeluxe
90473b13cb Release v1.7.130 2026-04-14 11:29:18 +02:00
Sucukdeluxe
6713771144 Skip bonus/extras content in MKV collection and auto-rename
Bonus content (Featurettes, Behind-The-Scenes, Making-Of, Deleted Scenes,
etc.) was being moved into the flat MKV library with generic names like
"Schrotflinte.mkv" or "White.House.mkv", losing all show context. Auto-rename
also touched these files and would mislabel them with episode tokens.

Real-world impact: 397 bonus files from Breaking Bad S03/S04/S05 BluRay
extras subdirectories landed in the user's main library with nonsense names.

Fix:
- Add isInsideBonusDir() that walks the path from file to package root,
  checking each directory segment for bonus indicators (Extras, Bonus,
  Featurettes, Specials, Behind-The-Scenes, Making-Of, Deleted-Scenes, etc.)
- Add BONUS_FILENAME_RE to catch bonus indicators in filenames (making-of-e02,
  deleted-scene, alternate-ending, gag-reel, behind-the-scenes, etc.)
- Auto-rename: skip files matching either pattern
- MKV collection: skip files matching either pattern, log skipped count

Bonus files now stay in the package output directory with their original
names; only the actual episodes get moved to the flat library.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:28:44 +02:00
Sucukdeluxe
16a59acaef Release v1.7.129 2026-04-04 21:28:51 +02:00
Sucukdeluxe
49efebd001 Extend hybrid companion stem matching to all archive types
The stem extraction regex for matching companion metadata files (.sfv,
.nfo) to their archives only handled RAR patterns. Now also covers
ZIP, 7z, tar, generic splits, and recovery volumes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:28:06 +02:00
Sucukdeluxe
ee69dcf4cc Release v1.7.128 2026-04-04 20:42:20 +02:00
Sucukdeluxe
711147fc10 Fix companion files stuck at extraction labels in hybrid mode
When all archive parts were already extracted in a prior hybrid round and
a companion file (.sfv, .nfo, .md5) downloads later, result.extracted is
0 so the companion's status was never updated from "Entpacken - Ausstehend"
or "Entpacken - Warten auf Parts".

Now companion metadata files are explicitly marked as "Entpackt (Metadaten)"
when no archives need extraction and no failures occurred, preventing them
from blocking package completion indefinitely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:41:38 +02:00
Sucukdeluxe
85889de790 Release v1.7.127 2026-04-04 20:32:36 +02:00
Sucukdeluxe
ab5fcaf836 Clean up companion metadata files (.sfv, .nfo, .md5) with their archives
collectArchiveCleanupTargets() now includes companion files (.sfv, .nfo,
.md5, .sha1, .sha256, .crc, .srr) that share the same base stem as the
archive parts. Previously these were left as orphans after archive cleanup.

Applies to all archive types: multipart RAR, single RAR, ZIP, split ZIP,
7z, and split 7z.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:31:58 +02:00
Sucukdeluxe
da9417c4f7 Release v1.7.126 2026-04-04 20:21:52 +02:00
Sucukdeluxe
021401e3b6 Mark companion metadata files (.sfv) as extracted during hybrid extraction
SFV files belong to the same archive set as the RAR parts but were not
included in hybridFileNames, causing them to stay stuck on "Entpacken -
Ausstehend" after the RAR parts were successfully extracted. This blocked
package completion in hybrid extraction mode.

Fix: collect archive base stems from extracted parts and match companion
metadata files (.sfv, .nfo, etc.) by stem, adding them to hybridFileNames
so they get marked as "Entpackt" together with their archive parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:21:16 +02:00
Sucukdeluxe
68bfeb574f Release v1.7.125 2026-04-04 20:11:55 +02:00
Sucukdeluxe
9d611bd749 Accept small metadata files (.sfv, .nfo, .nzb) without retry loops
SFV checksum verification files are legitimately tiny (~128 bytes) but were
rejected by the "suspicious small download" detection, causing infinite
"Direktlink erneuern" retry loops that blocked package extraction.

- Add KNOWN_SMALL_FILE_RE for .sfv, .nfo, .nzb, .md5, .sha1, .sha256, .crc,
  .txt, .url, .lnk, .srr file extensions
- Skip suspicious-small-download rejection for known small files when they
  match their expected size (or have no size expectation)
- Skip tiny-download error detection for known small metadata files
- Add test: verifies .sfv file downloads without retries and completes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:07:26 +02:00
Sucukdeluxe
8ab01f3da4 Release v1.7.124 2026-03-29 03:27:11 +02:00
Sucukdeluxe
650dafb535 Fix support bundle export freeze and resume prealloc recovery 2026-03-29 03:25:58 +02:00
Sucukdeluxe
6105a08728 Release v1.7.123 2026-03-28 16:28:20 +01:00
Sucukdeluxe
653e756010 Harden download integrity, extraction safety, and update security 2026-03-28 16:27:21 +01:00
Sucukdeluxe
792a4249d0 Release v1.7.122 2026-03-28 02:31:22 +01:00
Sucukdeluxe
a1d72b6dbc Fix resume tail corruption after terminated streams 2026-03-28 02:30:30 +01:00
Sucukdeluxe
30737f9320 Release v1.7.121 2026-03-26 19:48:37 +01:00
Sucukdeluxe
e8c6761bf0 Harden state persistence and fix provider abort handling
- Add safeJsonReplacer to all JSON.stringify calls in storage.ts to prevent
  NaN/Infinity values from corrupting state files and causing queue loss
- Fix LinkSnappy and 1Fichier retry loops: use sleepWithSignal() instead of
  sleep() so abort signals are respected during retry delays
- Fix Debrid-Link polling: replace raw setTimeout with sleepWithSignal() so
  URL generation polling can be cancelled
- Fix Mega-Debrid doConnectApi: clear token cache on 401/403 responses
  instead of caching invalid credentials for 20 minutes
- Add logging when normalizeLoadedSession removes orphaned items so data
  loss during startup is visible in logs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:47:58 +01:00
Sucukdeluxe
b41b7c9de6 Release v1.7.120 2026-03-26 19:35:32 +01:00
Sucukdeluxe
5aeab9ecad Prevent queue loss during app updates
- Increase quit timeout from 900ms to 5000ms to ensure pending saves complete
- Add persistNowSync() called before update install to flush queue to disk
- Remove blockAllPersistence from shutdown save condition — shutdown must
  always persist to prevent data loss across restarts
- Add temp file recovery as last resort when both primary and backup
  session files are corrupted

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:34:48 +01:00
Sucukdeluxe
ffb48a8883 Release v1.7.119 2026-03-26 13:29:24 +01:00
Sucukdeluxe
52bafed0b2 Add archive deobfuscation for hoster-mangled filenames
Some hosters/debrid services obfuscate downloaded archive filenames by
mutating characters and changing extensions (e.g. .part06.rar → .part06.mov,
star_crossed → star_crossfed). This breaks extraction since the extractor
relies on filename patterns to discover archive parts.

New deobfuscateArchiveFiles() method runs after download, before extraction:
- Reads magic bytes of non-archive files via detectArchiveSignature()
- If RAR/7z/ZIP signature found: corrects the extension
- Uses correctly-named sibling .rar files as reference to reconstruct
  the full correct filename including part number
- Updates item.fileName and item.targetPath after rename

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:28:40 +01:00
Sucukdeluxe
a5f5d76c37 Release v1.7.118 2026-03-26 13:05:14 +01:00
Sucukdeluxe
38179881f5 Fix Debrid-Link key rotation cascade failure, case-sensitive rename, and sample filter
- notDebrid (host-level) no longer burns all keys: stops rotation immediately
  with 5min cooldown instead of cycling through all 9 keys pointlessly
- Remove double provider-blockade: debrid_link_cooldown no longer stacks
  recordProviderFailure + applyProviderBusyBackoff on top of key cooldowns
- Detect timeout cascades: 2+ consecutive transport failures trigger 3min
  cooldown instead of burning remaining keys
- Case-sensitive rename: files with different casing (e.g. lowercase scene
  names) now get properly renamed instead of being skipped as "already matching"
- Extended sample filter: detect -s.mkv suffix and \Sample\ subdirectories
  in auto-rename (already worked in MKV-move)
- Add key status display with state pills in Debrid-Link key stats popup
- Add parseDebridLinkTerminalFailure for fast-fail on exhausted keys

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:04:42 +01:00
Sucukdeluxe
1d0b2ee8e3 Release v1.7.117 2026-03-25 19:54:42 +01:00
Sucukdeluxe
c5dd6f4f30 Harden Debrid-Link key failover and pending-state handling
- Add polling loop (5x 2s) in resolveDownloaderEntry when /add returns
  no downloadUrl — Debrid-Link sometimes needs seconds to generate links
- Classify missing/expired downloadUrl as temporary instead of fatal so
  key rotation kicks in before giving up
- Change notDebrid from fatal to temporary — "host may be down" is
  transient, all keys should be tried before failing
- Raise parseRetryAfterMs cap from 2min to 1h — floodDetected mandates
  "retry after 1 hour" per API docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:53:54 +01:00
Sucukdeluxe
cbb3694dd3 Release v1.7.116 2026-03-24 10:15:53 +01:00
Sucukdeluxe
2d9fbb07ea Revert daily-log and queue-scope changes back to v1.7.112 state
Remove daily-log module entirely (caused UI freezes due to sync I/O
even after async rewrite). Revert queue-scope stop() change (was for
a different project). All source files now match v1.7.112.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:15:28 +01:00
Sucukdeluxe
a27d0ec8f2 Release v1.7.115 2026-03-24 10:04:00 +01:00
Sucukdeluxe
45310f0bf7 Fix daily-log freezes and revert unrelated queue scope change
- Rewrite daily-log from synchronous fs.writeSync to async buffered
  writes (500ms flush interval), matching the main logger's pattern.
  The sync writes blocked the event loop on every log line.
- Revert the stop() queue scope change from v1.7.114 which was
  intended for a different project.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:03:35 +01:00
Sucukdeluxe
c51b52b86a Release v1.7.114 2026-03-24 09:29:29 +01:00
Sucukdeluxe
c215fdd658 Preserve selected-only run scope across stop/start cycles
When startItems() was used with a subset of items (e.g. 2000 of 6020),
stopping and restarting would pick up ALL 6020 queued items instead of
just the original 2000. Now stop() marks items outside the run set as
"Gestoppt" so they are not automatically included in the next start().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:29:01 +01:00
Sucukdeluxe
e84859d15b Release v1.7.113 2026-03-24 09:16:52 +01:00
Sucukdeluxe
d7149829ea Add daily log rotation with monthly folder structure
All log output is now additionally written to daily log files:
  daily-logs/YYYY-MM/YYYY-MM-DD.log (main log)
  daily-logs/YYYY-MM/YYYY-MM-DD-rename.log (rename log)

Automatic cleanup of daily logs older than 30 days. The existing
rd_downloader.log and rename.log continue to work as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:16:24 +01:00
Sucukdeluxe
1c78bb61c6 Release v1.7.112 2026-03-24 08:58:36 +01:00
Sucukdeluxe
180397f10a Revert post-MKV-move auto-rename that corrupted other packages
The post-MKV-move rename pass added in v1.7.107 ran on the shared
mkvLibraryDir (Entpackt/), causing files from OTHER packages to be
renamed to the current package's name. For example, Orange.Is.The.New.Black
files were renamed to Ted.S02E13...SAUERKRAUT.mkv.

Remove the post-MKV-move rename entirely. The original hybrid race
condition (1 file per season not renamed) is far less damaging than
cross-package corruption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:58:07 +01:00
Sucukdeluxe
bc4cdd3d81 Release v1.7.111 2026-03-23 21:35:56 +01:00
Sucukdeluxe
65650737b1 Refactor Mega-Debrid account UI from textarea to proper account list
Replace the raw login:password textarea with a proper form-based UI:
- Individual Login + Password input fields with "Hinzufügen" button
- List of configured accounts with masked logins and "Entfernen" button
- Duplicate login detection
- Storage format unchanged (megaCredentials stays login:password pairs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:35:17 +01:00
Sucukdeluxe
c182bc9269 Release v1.7.110 2026-03-23 20:13:38 +01:00
Sucukdeluxe
6df0834b67 Add Mega-Debrid multi-account support with automatic fallback
Multiple Mega-Debrid accounts can now be configured as login:password
pairs (one per line). When an account hits Fair-Use limits or errors,
the next account is tried automatically.

- New parser module mega-debrid-accounts.ts (parse, ID generation,
  masking, serialization)
- Per-account daily limits, usage tracking, enable/disable
- Account rotation with per-mode cooldowns (API failures don't
  block Web attempts)
- Backward compatible: existing single megaLogin/megaPassword
  is auto-migrated to the new format
- UI: textarea for credentials, account list with masked logins

Follows the existing Debrid-Link multi-key pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:12:51 +01:00
Sucukdeluxe
672c74f98f Release v1.7.109 2026-03-23 18:04:21 +01:00
Sucukdeluxe
d91621bd6d Handle SxxSxx scene typo in episode token extraction
Scene releases occasionally use SxxSxx (e.g. s05s01) instead of
SxxExx — the second S is a typo for E. Add a fallback regex to
detect this pattern and correctly interpret it as S05E01.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:03:45 +01:00
Sucukdeluxe
f0c37bed80 Release v1.7.108 2026-03-23 12:07:11 +01:00
Sucukdeluxe
79c178eb0d Skip sample files during auto-rename to prevent (2) MKV duplicates
Sample files like wayne-sample.mkv were renamed by auto-rename which
stripped the -sample suffix. After rename they were indistinguishable
from the main MKV, causing MKV collection to create (2) copies
(e.g. Messiah.Superstar.S01E01...WAYNE (2).mkv at 17 MB alongside
the real 470 MB episode).

Auto-rename now skips files with a "sample" token in their name,
matching the same detection used by MKV collection's sample filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:06:41 +01:00
Sucukdeluxe
8cc08a422f Release v1.7.107 2026-03-23 11:54:31 +01:00
Sucukdeluxe
87c097c822 Fix auto-rename race in hybrid extraction missing MKV files
During hybrid extraction, files can finish extracting between the
auto-rename scan and MKV-move, causing them to be moved to the MKV
library dir with their original scene names (e.g. awa-diethundermans03e21hd.mkv).

Add a post-MKV-move auto-rename pass on the MKV library directory to
catch these stragglers and rename them to the proper folder-based name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:53:51 +01:00
Sucukdeluxe
024e0995b5 Release v1.7.106 2026-03-23 11:25:48 +01:00
Sucukdeluxe
7b764be769 Fix auto-rename episode range folders producing duplicate MKV names
Folder names with episode ranges like S01E01-E08 (common in season
packs from debrid servers) were returned unchanged as target name,
causing all episodes to get the same filename and producing (2)(3)(4)
suffixes during MKV collection.

- Detect episode ranges (S01E01-E08, S01E01-08) in folder names and
  replace them with the source file's specific episode token
- Extend applyEpisodeTokenToFolderName regex to match and replace
  full episode ranges instead of only single episode tokens

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:25:10 +01:00
Sucukdeluxe
15ed49e783 Release v1.7.105 2026-03-22 16:24:38 +01:00
Sucukdeluxe
3f648127e6 Update download-manager tests for current behavior
- Adjust extract error label expectations to match new format with
  archive name and German error summaries
- Add timeouts for tests affected by archive settle delay
- Relax byte-exact assertions to ALLOCATION_UNIT_SIZE tolerance
- Skip mini-file retry assertion on Windows (pre-allocation masks it)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:20:57 +01:00
Sucukdeluxe
78fef627bb Fix auto-rename episode pollution and deferred nested extraction abort
- Fix episode-token pollution: packageExtraCandidates included ALL item
  filenames, causing resolveEpisodeTokenForAutoRename to pick up episode
  tokens from unrelated files (e.g. S01E07 from 4sf-...-s01e07 applied
  to all hrs-...-101/102/103 files). This also caused (2)(3) MKV
  suffixes when multiple files were renamed to the same wrong episode.
  Now only the package name (outputDir) is used as extra candidate.
- Fix deferred nested extraction missing abort signal: the nested
  extractPackageArchives call in runDeferredPostExtraction did not
  receive deferredController.signal, making it unabortable on
  stop/cancel/restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:20:47 +01:00
Sucukdeluxe
6dcd3796e9 Release v1.7.104 2026-03-11 20:05:52 +01:00
Sucukdeluxe
30a5832498 Fix auto-rename mixed scene group suffixes 2026-03-11 20:05:12 +01:00
Sucukdeluxe
fc9342577f Release v1.7.103 2026-03-11 14:18:29 +01:00
Sucukdeluxe
99455eca94 Fix auto-rename raw episode folder selection 2026-03-11 14:17:51 +01:00
Sucukdeluxe
f9530e015f Release v1.7.102 2026-03-10 23:53:21 +01:00
Sucukdeluxe
d9170f4167 Refactor: Extractor in 18 Sektionen reorganisiert 2026-03-10 23:47:02 +01:00
Sucukdeluxe
b49de16534 Release v1.7.101 2026-03-10 20:23:00 +01:00
Sucukdeluxe
1a0f49b29c Rebuild download completion verification 2026-03-10 20:22:19 +01:00
Sucukdeluxe
1a076c49cb Release v1.7.100 2026-03-10 20:09:36 +01:00
Sucukdeluxe
83640b8f1f Honor configured parallel extraction slots 2026-03-10 20:08:43 +01:00
Sucukdeluxe
ed4baf9240 Release v1.7.99 2026-03-10 19:58:17 +01:00
Sucukdeluxe
fbae8a1496 Fix cleanup after partial extraction failures 2026-03-10 19:57:26 +01:00
Sucukdeluxe
56aaf99464 Release v1.7.98 2026-03-10 19:35:22 +01:00
Sucukdeluxe
113b34fadf Fix parallel extraction false positives 2026-03-10 19:34:42 +01:00
Sucukdeluxe
d8535990ae Release v1.7.97 2026-03-10 19:15:03 +01:00
Sucukdeluxe
a054eface5 Improve extraction failure diagnostics 2026-03-10 19:14:21 +01:00
Sucukdeluxe
734c4e9968 Release v1.7.96 2026-03-10 18:28:03 +01:00
Sucukdeluxe
722fe071cc Harden Debrid-Link completion recovery 2026-03-10 18:27:26 +01:00
Sucukdeluxe
356b8c3080 Release v1.7.95 2026-03-10 18:20:55 +01:00
Sucukdeluxe
c1a4d8037f Fix Debrid-Link retry recovery 2026-03-10 18:20:19 +01:00
Sucukdeluxe
a892c1ad8f Release v1.7.94 2026-03-10 14:20:23 +01:00
Sucukdeluxe
0a724aed71 Fix BSOD MEMORY_MANAGEMENT on low-RAM servers
- Dynamically compute JVM -Xmx based on system RAM instead of hardcoded 32g
- Reduce peak memory during session loading (inline JSON string, skip redundant clone)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:19:49 +01:00
Sucukdeluxe
4b624693ec Release v1.7.93 2026-03-10 12:31:45 +01:00
Sucukdeluxe
6c7b1bb088 Keep failed packages out of package cleanup 2026-03-10 12:30:43 +01:00
Sucukdeluxe
df27bf2252 Release v1.7.92 2026-03-10 05:55:05 +01:00
Sucukdeluxe
17e947fc6b Harden type safety and recovery guards 2026-03-10 05:54:19 +01:00
Sucukdeluxe
f6558470c3 Release v1.7.91 2026-03-10 01:50:55 +01:00
Sucukdeluxe
17604910b5 Harden startup recovery and stats edge cases 2026-03-10 01:50:16 +01:00
Sucukdeluxe
484819325e Release v1.7.90 2026-03-10 00:44:31 +01:00
Sucukdeluxe
6d7b3686dc Add AVI video-library support and startup recovery fixes 2026-03-10 00:43:51 +01:00
Sucukdeluxe
542eb416f3 Add cleanup regression coverage 2026-03-10 00:07:05 +01:00
Sucukdeluxe
8012caaf78 Release v1.7.89 2026-03-09 23:49:11 +01:00
Sucukdeluxe
b41bb0aeb5 Fix deferred cleanup after MKV move 2026-03-09 23:48:28 +01:00
Sucukdeluxe
a5d53eff74 Release v1.7.88 2026-03-09 20:38:59 +01:00
Sucukdeluxe
ecb5df0a31 Fix startup duplicate archive recovery 2026-03-09 20:38:23 +01:00
Sucukdeluxe
8f5358323c Release v1.7.87 2026-03-09 20:15:08 +01:00
Sucukdeluxe
e6b8ea0abe Fix stale extract pending states 2026-03-09 20:14:35 +01:00
Sucukdeluxe
ef26237b3e Release v1.7.86 2026-03-09 19:44:10 +01:00
Sucukdeluxe
446c41a9b3 Fix extract error status bleed 2026-03-09 19:43:35 +01:00
Sucukdeluxe
2aa6516b37 Release v1.7.85 2026-03-09 18:17:04 +01:00
Sucukdeluxe
51d4e2100f Rename backup menu actions 2026-03-09 18:16:30 +01:00
Sucukdeluxe
0762c15170 Release v1.7.84 2026-03-09 17:24:08 +01:00
Sucukdeluxe
a70eacf9cd Harden deferred cleanup races 2026-03-09 17:23:28 +01:00
Sucukdeluxe
2f44e050bc Release v1.7.83 2026-03-09 06:07:05 +01:00
Sucukdeluxe
4374119f9e Fix session runtime reset 2026-03-09 06:06:39 +01:00
Sucukdeluxe
f447c0f37a Release v1.7.82 2026-03-09 05:53:37 +01:00
Sucukdeluxe
05df79d518 Refine statistics overview layout 2026-03-09 05:53:05 +01:00
Sucukdeluxe
914bbcc59a Release v1.7.81 2026-03-09 05:39:27 +01:00
Sucukdeluxe
40f097249c Fix manual extract relabeling 2026-03-09 05:38:56 +01:00
Sucukdeluxe
e9ec35a6a6 Release v1.7.80 2026-03-09 05:33:44 +01:00
Sucukdeluxe
3de4ba3e90 Fix final post-process requeue stall 2026-03-09 05:33:09 +01:00
Sucukdeluxe
49066f51bc Release v1.7.79 2026-03-09 05:17:23 +01:00
Sucukdeluxe
1afce943ae Fix history timing and retention controls 2026-03-09 05:16:41 +01:00
Sucukdeluxe
09da670eeb Release v1.7.78 2026-03-09 04:59:29 +01:00
Sucukdeluxe
1f9a26e4b0 Add app runtime statistics 2026-03-09 04:59:00 +01:00
Sucukdeluxe
971f669bb6 Release v1.7.77 2026-03-09 04:49:32 +01:00
Sucukdeluxe
55e0ebd0f8 Add dedicated rename support logging 2026-03-09 04:48:58 +01:00
Sucukdeluxe
08cd1c4bf8 Release v1.7.76 2026-03-09 04:30:37 +01:00
Sucukdeluxe
2066d0ad26 Keep manually collapsed packages closed 2026-03-09 04:30:08 +01:00
Sucukdeluxe
abcedb339c Release v1.7.75 2026-03-09 04:22:24 +01:00
Sucukdeluxe
a3e3d6faf7 Throttle Mega-Debrid Web validation starts 2026-03-09 04:21:56 +01:00
Sucukdeluxe
9cbf917140 Release v1.7.74 2026-03-09 04:11:50 +01:00
Sucukdeluxe
85a9a2fa9f Add package and item link export 2026-03-09 04:11:18 +01:00
Sucukdeluxe
7503611893 Release v1.7.73 2026-03-09 03:01:06 +01:00
Sucukdeluxe
7027e11cbd Add support self-check diagnostics 2026-03-09 03:00:36 +01:00
Sucukdeluxe
28452373bf Release v1.7.72 2026-03-09 02:48:27 +01:00
Sucukdeluxe
fc4fafa0d6 Harden support logging and debug setup 2026-03-09 02:47:49 +01:00
Sucukdeluxe
af73934b0f Release v1.7.71 2026-03-09 02:16:11 +01:00
Sucukdeluxe
f5f7f14104 Add support bundle and trace tooling 2026-03-09 02:15:32 +01:00
Sucukdeluxe
db6e7d81a4 Release v1.7.70 2026-03-09 01:59:50 +01:00
Sucukdeluxe
78fc80f04b Add support audit logging and AI debug manifest 2026-03-09 01:59:08 +01:00
Sucukdeluxe
01e0f27841 Release v1.7.69 2026-03-09 01:44:15 +01:00
Sucukdeluxe
47742ad7a4 Skip startup cleanup for extract errors 2026-03-09 01:43:43 +01:00
Sucukdeluxe
58e2612306 Release v1.7.68 2026-03-09 01:36:47 +01:00
Sucukdeluxe
c898c6de65 Mirror extractor logs to item logs 2026-03-09 01:36:08 +01:00
Sucukdeluxe
9449788e56 Release v1.7.67 2026-03-09 01:21:54 +01:00
Sucukdeluxe
56ce7c2aea Add remote host and item diagnostics 2026-03-09 01:21:11 +01:00
Sucukdeluxe
90a06d2926 Release v1.7.66 2026-03-09 00:53:51 +01:00
Sucukdeluxe
3320f38e47 Adjust unrestrict retry status wording 2026-03-09 00:53:23 +01:00
Sucukdeluxe
c225df0bcd Release v1.7.65 2026-03-09 00:33:26 +01:00
Sucukdeluxe
87212ddf76 Fix Real-Debrid resume size mismatch handling 2026-03-09 00:32:41 +01:00
Sucukdeluxe
157feb8eb7 Release v1.7.64 2026-03-09 00:03:43 +01:00
Sucukdeluxe
e86c9576e7 Fix Real-Debrid web login session reuse 2026-03-09 00:03:05 +01:00
Sucukdeluxe
3774511654 Release v1.7.63 2026-03-08 22:37:40 +01:00
Sucukdeluxe
5ef9575b95 Improve rename and extractor diagnostics 2026-03-08 22:37:07 +01:00
Sucukdeluxe
9fd4dd452a Release v1.7.62 2026-03-08 22:16:30 +01:00
Sucukdeluxe
bd3c14ad3c Use fixed AllDebrid slot countdowns 2026-03-08 22:15:52 +01:00
Sucukdeluxe
d542ea0bf6 Release v1.7.61 2026-03-08 22:02:57 +01:00
Sucukdeluxe
83c0c18dca Hide generic waiting states in queue 2026-03-08 22:02:27 +01:00
Sucukdeluxe
fff0589154 Release v1.7.60 2026-03-08 21:47:45 +01:00
Sucukdeluxe
8b941a2777 Limit visible AllDebrid start countdowns 2026-03-08 21:47:11 +01:00
Sucukdeluxe
fdb575b0c1 Release v1.7.59 2026-03-08 21:39:39 +01:00
Sucukdeluxe
239154a2c8 Fix AllDebrid paced start scheduling 2026-03-08 21:39:00 +01:00
Sucukdeluxe
178b743163 Release v1.7.58 2026-03-08 21:18:52 +01:00
Sucukdeluxe
63f404285f Tighten idle queue and disk busy status display 2026-03-08 21:18:18 +01:00
Sucukdeluxe
102dbb7da3 Release v1.7.57 2026-03-08 21:08:44 +01:00
Sucukdeluxe
066ef14806 Fix AllDebrid start reservation pacing 2026-03-08 21:08:13 +01:00
Sucukdeluxe
9d6e1ad795 Release v1.7.56 2026-03-08 21:01:31 +01:00
Sucukdeluxe
320518dc58 Fix AllDebrid Rapidgator parallel start limit 2026-03-08 21:00:55 +01:00
Sucukdeluxe
eb41b87344 Release v1.7.55 2026-03-08 20:39:31 +01:00
Sucukdeluxe
df8cbcf1c9 Fix Debrid-Link notDebrid handling 2026-03-08 20:39:00 +01:00
Sucukdeluxe
e19cdf247c Release v1.7.54 2026-03-08 20:31:18 +01:00
Sucukdeluxe
6a0079f9d0 Harden Debrid-Link cooldown and quota handling 2026-03-08 20:30:33 +01:00
Sucukdeluxe
fa0f85acb0 Release v1.7.53 2026-03-08 20:08:04 +01:00
Sucukdeluxe
78b06f2975 Rewrite Debrid-Link v2 unrestrict flow 2026-03-08 20:07:28 +01:00
Sucukdeluxe
f828a871e2 Release v1.7.52 2026-03-08 19:37:29 +01:00
Sucukdeluxe
8224910091 Harden Debrid-Link HTTP 416 recovery and rename coverage 2026-03-08 19:36:54 +01:00
Sucukdeluxe
09d757782f Release v1.7.51 2026-03-08 17:08:52 +01:00
Sucukdeluxe
c62e3ced0c Fix resume preflight recovery and rename regression coverage 2026-03-08 17:08:09 +01:00
Sucukdeluxe
63b412a43f Release v1.7.50 2026-03-08 04:49:53 +01:00
Sucukdeluxe
2123a48bea Fix resume completion and rar fallback handling 2026-03-08 04:49:13 +01:00
Sucukdeluxe
4a27fd72c7 Release v1.7.49 2026-03-08 03:58:11 +01:00
Sucukdeluxe
2bd7a187f8 Fix resume retry fallback for truncated direct links 2026-03-08 03:57:37 +01:00
Sucukdeluxe
7c2c8def51 Release v1.7.48 2026-03-08 03:42:39 +01:00
Sucukdeluxe
38c9058beb Fix session stats, extraction UX, and queue UI issues 2026-03-08 03:42:06 +01:00
Sucukdeluxe
842933e748 Release v1.7.47 2026-03-08 02:55:03 +01:00
Sucukdeluxe
ef7905eeb4 Fix legacy extractor path handling 2026-03-08 02:54:37 +01:00
Sucukdeluxe
6c1db14e24 Release v1.7.46 2026-03-08 02:49:23 +01:00
Sucukdeluxe
935f05e214 Fix RAR native extractor fallback 2026-03-08 02:48:49 +01:00
Sucukdeluxe
53c411f635 Release v1.7.45 2026-03-08 02:36:11 +01:00
Sucukdeluxe
28113f57f3 Release v1.7.44 2026-03-08 02:28:51 +01:00
Sucukdeluxe
2a51c443b8 Release v1.7.43 2026-03-08 02:18:53 +01:00
Sucukdeluxe
4dd43c8d91 Release v1.7.42 2026-03-08 02:03:17 +01:00
Sucukdeluxe
e4b0f9001e Fix extraction retry loop on CRC/password failures 2026-03-08 02:02:39 +01:00
Sucukdeluxe
9eb28cee2e Add per-package detailed logs 2026-03-08 01:41:23 +01:00
Sucukdeluxe
ecfaf52ce9 Release v1.7.40 2026-03-08 01:04:05 +01:00
Sucukdeluxe
6b22c93554 Fix auto-recovery re-download for encrypted RAR5 archives
- Always force re-download once when both JVM and legacy extractors fail
  (suggestRedownload=true), regardless of valid archive signature
- Add autoRecoveredForRedownload Set for loop protection (one attempt per archive)
- Clear loop protection on package reset (clearHybridArchiveState)
- Previous sibling-items check failed when other episodes were already
  cleaned up after hybrid extraction
- Lower mini-download retry threshold from 100KB to 5KB

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 01:03:37 +01:00
Sucukdeluxe
cd5c0c5e7b Release v1.7.39 2026-03-08 00:50:33 +01:00
Sucukdeluxe
9e255d8110 Fix extraction failures on encrypted RAR5 archives with correct file content
- Retry extraction with 2.5s delay on CRC/password errors (Windows file handle race)
- Improve auto-recovery: force re-download when known password fails (content corruption)
- Expand auto-recovery to wrong_password category for encrypted RAR5
- Add fsync after download for pre-allocated files
- Fix permanent extraction failure loop for archives with valid headers but corrupt content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 00:50:03 +01:00
Sucukdeluxe
94126943d5 Release v1.7.38 2026-03-08 00:03:34 +01:00
Sucukdeluxe
27d75153b9 Boost hybrid extraction speed: G1GC, more threads, larger heap
- Switch JVM GC from SerialGC to G1GC with MaxGCPauseMillis=50
  for shorter pause times in long-lived daemon
- Increase JVM heap from 512m/8g to 1g/32g to reduce GC pressure
  on systems with plenty of RAM
- Raise hybrid thread cap from floor(cpuCount/2) capped 8
  to ceil(cpuCount*0.75) capped 12 — downloads are I/O-bound
  and don't need much CPU headroom
- Refresh daemon process priority before each request so hybrid
  extraction gets correct CPU priority even if daemon was started
  with a different priority level

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 00:02:59 +01:00
Sucukdeluxe
fd3b5033a4 Release v1.7.37 2026-03-07 23:46:53 +01:00
Sucukdeluxe
d2689aa425 Fix stall-timeout for providers without fileSize (Mega-Debrid Web)
- Early-exit now also uses raw Content-Length as fallback when
  totalBytes is unknown (provider returned fileSize=0)
- Stall handler checks if file is already complete on disk before
  deleting and retrying — prevents re-download loop for files that
  finished but server delayed closing the connection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:46:16 +01:00
Sucukdeluxe
143d0921fc Release v1.7.36 2026-03-07 23:35:17 +01:00
Sucukdeluxe
00d873445c Fix stall-timeout false positives on fully downloaded small archive parts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:34:46 +01:00
Sucukdeluxe
46a64f9e95 Release v1.7.35 2026-03-07 23:15:42 +01:00
Sucukdeluxe
307dcf0815 Fix stop→start race conditions causing potential hang
- Add scheduler generation counter to prevent stale scheduler from
  continuing after stop/start cycle
- Guard processItem stop-abort handler: skip status overwrite when a
  new start() has already re-activated the session
- Yield in start() after recoverRetryableItems to let pending abort
  handlers complete before evaluating item states
- Add test: rapid stop → disable provider → start must resolve

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:15:05 +01:00
Sucukdeluxe
3032604496 Release v1.7.34 2026-03-07 22:33:46 +01:00
Sucukdeluxe
291c80c7fc Fix auto-recovery re-download loop: check archive magic bytes before forcing re-download
Files with valid RAR/7z/ZIP signature are not corrupt (wrong password),
only files with invalid signature get force-redownloaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:33:10 +01:00
Sucukdeluxe
3ab7b6c9a3 Release v1.7.33 2026-03-07 22:27:22 +01:00
Sucukdeluxe
556cbd1d85 Fix auto-recovery for CRC-corrupt archives with correct file sizes
- Trust extractor CRC verdict over file size checks
- Re-queue incomplete downloads instead of just warning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:26:49 +01:00
Sucukdeluxe
2a429b49c0 Release v1.7.32 2026-03-07 22:14:24 +01:00
Sucukdeluxe
5c29355e9a Prevent repeated hybrid extraction retries 2026-03-07 22:13:51 +01:00
Sucukdeluxe
960b1fa046 Release v1.7.31 2026-03-07 21:53:49 +01:00
Sucukdeluxe
9bc9c984cb Fix hybrid auto recovery loops 2026-03-07 21:53:10 +01:00
Sucukdeluxe
1222cb08b5 Release v1.7.30 2026-03-07 21:27:42 +01:00
Sucukdeluxe
fb036733e3 Fix auto-recovery for stale archive parts 2026-03-07 21:27:03 +01:00
Sucukdeluxe
a322a16b7b Release v1.7.29 2026-03-07 21:09:28 +01:00
Sucukdeluxe
16bfbfc106 Fix archive underflow and extraction readiness 2026-03-07 21:08:43 +01:00
Sucukdeluxe
0f2e8d5567 Release v1.7.27 2026-03-07 20:34:46 +01:00
Sucukdeluxe
b8bf9c491c Fallback to UnRAR when 7-Zip fails on encrypted RAR archives
If the primary extractor (7-Zip) fails with wrong_password/checksum
error on a .rar file, automatically try the alternative extractor
(UnRAR/WinRAR) which handles RAR format natively and more reliably.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:34:17 +01:00
Sucukdeluxe
ed0070c711 Release v1.7.26 2026-03-07 20:26:10 +01:00
Sucukdeluxe
167c28f945 Show base provider name in status text instead of key details
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:25:32 +01:00
Sucukdeluxe
a3b72d4cbf Release v1.7.25 2026-03-07 20:18:59 +01:00
Sucukdeluxe
934f24ae22 Add shift-click range selection for downloads list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:18:27 +01:00
Sucukdeluxe
c7c6134d48 Release v1.7.24 2026-03-07 20:14:48 +01:00
Sucukdeluxe
24e457d84d Revalidate completed items on startup, fix stale session data
Items incorrectly marked as "completed" by the old 50% recovery threshold
persist in the session file across updates. On startup, check all completed
items: if the file on disk is smaller than expected totalBytes, reset to
"queued" so it gets re-downloaded. Also reset items whose files are missing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:14:11 +01:00
Sucukdeluxe
afef8dae6f Release v1.7.23 2026-03-07 20:10:39 +01:00
Sucukdeluxe
e80948df54 Fix disk-fallback in hybrid extract allowing partial files
Two bugs in findReadyArchiveSets disk-fallback:

1. Failed items were explicitly excluded from the blocking check
   (status !== "failed"), so partial downloads with "failed" status
   would not block extraction. Now ANY non-completed item blocks.

2. The disk size check was only > 10 KB, allowing 627 MB partial
   files of 1001 MB archives to pass. Now requires the file to be
   within one allocation unit of the item's expected totalBytes.

Added findItemByDiskPath helper to look up the owning item for a
file on disk and get its expected size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 20:10:04 +01:00
Sucukdeluxe
7d09479b44 Release v1.7.22 2026-03-07 19:48:54 +01:00
Sucukdeluxe
0f2823bdc2 Fix premature hybrid extraction of incomplete archive parts
Item-Recovery incorrectly marked partially downloaded files as "completed"
when the file size was >= 50% of expected size. A 627 MB partial download
of a 1001 MB file (62.7%) would pass the check and trigger hybrid
extraction on incomplete RAR archives.

Fix: require file to be within one allocation unit (4 KB) of the expected
size instead of 50%. Also add a pre-allocation guard: if the file appears
to be at the expected size but downloadedBytes is significantly behind
(< 95%), skip recovery (likely a pre-allocated sparse file from a crash).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:48:22 +01:00
Sucukdeluxe
99074464fb Release v1.7.21 2026-03-07 19:37:07 +01:00
Sucukdeluxe
927ff5c21a Auto-fix legacy (N) suffix filenames on startup
Detect items whose targetPath has a " (N)" suffix from the previous
duplicate filename bug and rename them back to the original filename.
This fixes extraction failures for RAR split archives that were
downloaded with (1) suffix before v1.7.19.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:36:30 +01:00
Sucukdeluxe
135ba8616f Release v1.7.20 2026-03-07 19:34:13 +01:00
Sucukdeluxe
afbd425227 Fix Debrid-Link key selection: always use first available key
Replace shared currentKeyIndex round-robin with sequential scan from
first key. All parallel items now consistently use the same key (e.g.
Key 3) until it hits a quota/error, then all move to Key 4, etc.

Previously, currentKeyIndex was shared across parallel unrestrict calls,
causing items to scatter across keys (3, 5, 7) even when Key 3 still
had capacity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:33:40 +01:00
Sucukdeluxe
fef9ff6318 Release v1.7.19 2026-03-07 19:27:04 +01:00
Sucukdeluxe
0e0c211d35 Restore target path reservations on startup to prevent (1) duplicates
After restart, reservedTargetPaths (in-memory) was empty so claimTargetPath
could not distinguish between "file belongs to this item" and "file belongs
to another item". The naive fix (allow overwrite if unclaimed) would have
risked overwriting completed files from other items.

Proper fix: restore reservedTargetPaths from persisted session data on init.
This way each item's targetPath is correctly claimed, and claimTargetPath
can safely reuse the item's own file while protecting other items' files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:26:29 +01:00
Sucukdeluxe
dadc2d1b57 Release v1.7.18 2026-03-07 19:21:00 +01:00
Sucukdeluxe
cb02dd3aac Fix duplicate (1) filenames after app restart with partial downloads
When the app restarts (or updates) while downloads are in progress,
partial files remain on disk but reservedTargetPaths (in-memory) is empty.
claimTargetPath treated the unclaimed-but-existing file as a conflict and
appended (1) to the filename, breaking RAR split archive extraction.

Fix: if a file exists on disk but no other item has reserved the path,
allow overwriting instead of creating a duplicate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:20:26 +01:00
Sucukdeluxe
31a579f07c Release v1.7.17 2026-03-07 18:03:05 +01:00
Sucukdeluxe
a0800045ec Fix Debrid-Link key rotation: skip broken keys immediately, add cooldown cache
- Add "notDebrid", "disabledServerHost", "notFree" as immediate-skip errors
  (no 3x retry per key, break to next key instantly like quota errors)
- Add per-key cooldown cache (2 min) so parallel items skip recently-failed keys
  instead of all starting at the same broken key
- Set cooldown on quota errors too, preventing repeated checks on exhausted keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:02:34 +01:00
Sucukdeluxe
7e0d4e210f Release v1.7.16 2026-03-07 17:56:25 +01:00
Sucukdeluxe
e00c5b5344 Fix shelve mechanism: reset provider + circuit breaker, reduce pause to 90s
Shelve (15+ failures) now mimics manual stop/start behavior:
- Clears item.provider for fresh provider selection on retry
- Resets provider circuit breaker (providerFailures) for the old provider
- Reduces shelve duration from 5 min to 90s since the issue is stale
  provider state, not a timing problem (manual restart works instantly)

Also adds comprehensive session-load logging:
- Logs package/item count on every session file read
- Logs errors when session file parsing fails (was silent before)
- Safety net: if primary session is empty but backup has packages,
  automatically restores from backup
- Logs shutdown save with package/item counts
- Logs DownloadManager init state and cleanup policy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:55:56 +01:00
Sucukdeluxe
0042ce0adb Release v1.7.15 2026-03-07 17:15:15 +01:00
Sucukdeluxe
ccf4dc2e08 Fix hosterNotAvailable skipping provider cooldown in inner catch
The inner unrestrict error handler still called recordProviderFailure()
for hosterNotAvailable errors, causing provider-level cooldown escalation
(up to 180s) even though the issue is hoster-side, not provider-side.
This made auto-retry stall while manual reset worked instantly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:14:38 +01:00
Sucukdeluxe
b02f37cda5 Release v1.7.14 2026-03-07 17:10:07 +01:00
Sucukdeluxe
35975d7333 Fix hosterNotAvailable retry stalling due to provider cooldown
hosterNotAvailable was added to isTemporaryUnrestrictError which
triggered aggressive provider-level cooldowns (up to 180s) that
blocked ALL items for the affected provider. Since items kept
failing, the cooldown never expired (15-min reset threshold never
reached), causing retries to effectively stall.

Fix: remove hosterNotAvailable from isTemporaryUnrestrictError.
It still gets normal unrestrict retry with item-level backoff
(5s -> 120s) via isUnrestrictFailure, but without provider-wide
cooldown blocking other items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:09:29 +01:00
Sucukdeluxe
8a64289924 Release v1.7.13 2026-03-07 17:02:32 +01:00
Sucukdeluxe
67fc3a8e1c Treat hosterNotAvailable as temporary error with retry
Move hosterNotAvailable from isPermanentLinkError to
isTemporaryUnrestrictError — hoster being unavailable is usually
transient (overload, maintenance) and should be retried with backoff
instead of immediately failing as "Link ungültig".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:01:58 +01:00
Sucukdeluxe
4bdc95a055 Release v1.7.12 2026-03-07 16:45:00 +01:00
Sucukdeluxe
8e4b29a155 Encrypted backup system + hide extracted items in package list
Backup redesign (JDownloader 2 style):
- AES-256-GCM encrypted .mdd format with fixed app key
- All credentials exported (no more ***-masking), works on any PC
- Includes settings, session AND history in backup
- Backward-compatible: auto-detects legacy JSON backups
- Normalize history entries on import
- Added debridLinkApiKeys, linkSnappy credentials to sensitive keys

Hide extracted items:
- New setting: hide completed/extracted items from package item list
- Items still count in progress stats (done/total), only hidden in UI
- Default: enabled
- Toggle in settings: "Entpackte Items in Paketliste ausblenden"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:44:21 +01:00
Sucukdeluxe
0edd8f6be5 Redesign backup system: AES-256-GCM encrypted .mdd format
- Replace plaintext JSON export with encrypted binary format (JDownloader 2 style)
- Fixed app-internal key, works on any machine without password
- Export now includes ALL credentials (no more ***-masking), session AND history
- Add debridLinkApiKeys, linkSnappy credentials to sensitive keys list
- Backward-compatible import: auto-detects legacy JSON backups
- File extension changed from .json to .mdd
- MDD1 magic bytes + random IV + GCM auth tag for integrity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:39:19 +01:00
Sucukdeluxe
ca534196b8 Release v1.7.11 2026-03-07 15:12:08 +01:00
Sucukdeluxe
5fbcdc1722 Cache flat-mode flag per package to skip redundant password cycles
Archives with absolute internal paths (e.g. scene groups storing full
Windows paths) fail all password attempts in normal mode at ~98%, then
succeed only after the flat-mode fallback kicks in. Previously every
archive in such a package wasted all password cycles before discovering
flat mode was needed again.

Now the first successful flat-mode extraction sets a package-level flag
so subsequent archives skip the normal loop entirely and go straight to
flat-mode extraction, saving ~4x password attempts per archive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 15:11:37 +01:00
Sucukdeluxe
8cb3640057 Release v1.7.10 2026-03-07 14:39:54 +01:00
Sucukdeluxe
f5fe3efb73 Fix episode regex for rrp suffixes, skip 0-byte MKVs, prevent duplicate (2) copies
- SCENE_EPISODE_RE/JOINED_RE: use (?!\d) lookahead instead of requiring
  separator after episode number, so filenames like s09e06rrp now match
- MKV-Sammelordner: skip 0-byte files from failed/partial extractions
- MKV-Sammelordner: detect same-name same-size duplicates in target dir
  and skip instead of creating (2) copies; remove duplicate source file

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 14:39:17 +01:00
Sucukdeluxe
036d117d66 docs: sort supported providers 2026-03-07 14:21:10 +01:00
Sucukdeluxe
fabb2ae9ed docs: refresh README 2026-03-07 14:19:54 +01:00
Sucukdeluxe
f3e0e54da7 Release v1.7.9 2026-03-07 14:18:51 +01:00
Sucukdeluxe
8f6b87ae8c Fix parallel extraction wrong_password false positives, preserve session download counter across stop/resume
- Retry failed wrong_password archives serially after parallel extraction
  to recover from CRC mismatches caused by concurrent UnRAR I/O contention
- Stop resetting sessionDownloadedBytes on start/resume so the session
  total accurately reflects all bytes downloaded since app launch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 14:18:09 +01:00
Sucukdeluxe
369d25a365 Release v1.7.8 2026-03-07 12:19:20 +01:00
Sucukdeluxe
918ec33987 Fix broken Unicode arrows in provider order and schedule cancel buttons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:18:10 +01:00
Sucukdeluxe
696263ae4e Release v1.7.7 2026-03-07 04:16:03 +01:00
Sucukdeluxe
6327068fed Release v1.7.6 2026-03-07 04:12:09 +01:00
Sucukdeluxe
31c9f118e2 Release v1.7.5 2026-03-07 04:09:01 +01:00
Sucukdeluxe
c125a5a804 Release v1.7.4 2026-03-07 04:06:28 +01:00
Sucukdeluxe
c8d911c9b0 Release v1.7.3 2026-03-07 04:01:21 +01:00
Sucukdeluxe
d9276479e5 Release v1.7.2 2026-03-07 03:58:27 +01:00
Sucukdeluxe
3c37cdba85 Add account column reset button 2026-03-07 03:56:54 +01:00
Sucukdeluxe
7737a4b0da Release v1.7.1 2026-03-07 03:52:41 +01:00
Sucukdeluxe
576be53b83 Release v1.7.0 2026-03-07 02:36:41 +01:00
Sucukdeluxe
72936b7c6b Compact provider labels in package account column
Multiple accounts from the same provider are now merged:
"Debrid-Link (#3), Debrid-Link (#4)" becomes "Debrid-Link (#3 + #4)"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:35:23 +01:00
Sucukdeluxe
541860db0a Release v1.6.99 2026-03-07 02:31:09 +01:00
Sucukdeluxe
dd4264a936 Fix remaining broken umlauts in main.ts and debrid.ts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:30:26 +01:00
Sucukdeluxe
e212ccc86f Add daily traffic limits, auto-sort packages, Debrid-Link multi-key improvements
Daily traffic limits:
- Per-provider daily download limit (configurable in GB per provider)
- Per Debrid-Link API key daily limit (individual limits per key)
- Usage tracking with automatic daily reset at midnight
- Provider is skipped when daily limit reached, falls back to next provider
- Reset button per provider and per Debrid-Link key in account settings
- Hoster routing skips daily-limited providers gracefully

Debrid-Link multi-key improvements:
- Keys now display with labels (#1, #2...) and masked tokens in account list
- Option to show detailed per-key view with individual usage stats
- Keys that hit their daily limit are automatically skipped
- providerAccountId/providerAccountLabel stored per download item

Auto-sort packages by progress:
- Active packages automatically sorted to top during downloads
- Sorted by completion ratio, then downloaded bytes
- Toggle in settings (autoSortPackagesByProgress)

UI polish:
- Package column headers: flatter, more transparent design
- LinkSnappy mode label: "Login" renamed to "Web"
- Account list: new toggle for detailed Debrid-Link key display
- Account usage stats section with warning styling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:29:48 +01:00
Sucukdeluxe
71b3612e82 Fix LinkSnappy mode label from "Login" to "Web"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:26:54 +01:00
Sucukdeluxe
8e1a117cbb Fix broken German umlauts in UI (142 occurrences)
All ? placeholders in App.tsx replaced with correct UTF-8 umlauts
(ä, ö, ü, ß). File is now properly encoded as UTF-8.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:23:50 +01:00
Sucukdeluxe
01b6ef7bdd Release v1.6.98 2026-03-07 00:59:59 +01:00
Sucukdeluxe
68a05f2a21 Fix account settings save normalization 2026-03-07 00:55:39 +01:00
Sucukdeluxe
fba05d2add Release v1.6.97 2026-03-07 00:39:03 +01:00
Sucukdeluxe
1cbda1350e Fix absolute archive paths, show provider account number in UI
- extractor: detect UnRAR "Cannot create...\..." error (archive with
  leading-backslash internal paths) and retry in flat mode (-e) which
  strips all paths and avoids the invalid double-separator on Windows
- types/download-manager: add providerLabel field to DownloadItem,
  store full label (e.g. "Debrid-Link #1") set at unrestrict time
- App: display providerLabel in Service column (falls back to generic
  provider name if label not yet set)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:38:34 +01:00
Sucukdeluxe
3e2d70485c Release v1.6.96 2026-03-07 00:21:40 +01:00
Sucukdeluxe
3287504618 Fix invalid path when package name contains forward slash
sanitizeFilename() is now applied before constructing outputDir and
extractDir, so names like "TMSF/4SF" no longer produce broken Windows paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:21:14 +01:00
Sucukdeluxe
7141579289 Release v1.6.95 2026-03-07 00:04:02 +01:00
Sucukdeluxe
dfc5d73105 Fix disk-fallback extraction triggering with incomplete archive parts
When a pending item has neither targetPath nor fileName (e.g. after a
reset before re-unrestrict), it is invisible to pendingItemStatus and
the disk-fallback could incorrectly start extraction with a partial file.
Add a guard that skips disk-fallback for any archive set if the package
contains such an untracked pending item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:03:31 +01:00
Sucukdeluxe
33658503a8 Release v1.6.94 2026-03-06 23:52:38 +01:00
Sucukdeluxe
9d374b97cf Fix provider order/routing respecting on restart, schedule timer on manual start
- Clear item.provider on stop/restart so provider order/routing changes
  are respected on next download attempt
- Reset item.provider for all non-completed items when providerOrder or
  hosterRouting changes in settings
- Cancel scheduled-start timer when queue is started manually
- Track disk-fallback hybrid-extract archives per session to prevent
  infinite post-processing loop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 23:52:09 +01:00
Sucukdeluxe
9dd5d4eef8 Release v1.6.93 2026-03-06 23:06:35 +01:00
Sucukdeluxe
905b55e7d8 fix: provider fallback on cooldown, hoster routing dirty flag, provider order in DM 2026-03-06 23:06:05 +01:00
Sucukdeluxe
0c9bbb0153 fix: release script idempotent recovery when tag already exists 2026-03-06 22:56:30 +01:00
Sucukdeluxe
9bbeffb2df Release v1.6.92 2026-03-06 22:53:50 +01:00
Sucukdeluxe
2e6074337a feat: re-download from history, reset-all-failed, scheduled start, fix provider order dirty flag 2026-03-06 22:53:20 +01:00
Sucukdeluxe
6d6453dc4b Release v1.6.91 2026-03-06 22:18:19 +01:00
Sucukdeluxe
716b516900 feat: dynamic provider order, hoster routing, MKV sample fix
- Replace fixed primary/secondary/tertiary slots with unlimited ordered
  providerOrder: DebridProvider[] list; supports as many accounts as needed
- Provider list reorderable via up/down buttons in Accounts settings tab
- Migration: derives order from legacy primary/secondary/tertiary if empty
- Mega-Debrid split into megadebrid-api and megadebrid-web as separate providers
- Add per-hoster routing (hosterRouting) to assign specific debrid provider per hoster
- Fix duplicate MKV files in library: filter out sample files from Sample subfolders
- Remove legacy provider-selection dropdowns from hidden settings section
- Add CSS for provider-order-list/row/num/label/actions classes
- Update debrid tests: add providerOrder: [] to use legacy fallback path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:17:44 +01:00
Sucukdeluxe
0003d786d8 Release v1.6.90 2026-03-06 20:43:15 +01:00
Sucukdeluxe
0eb3403e40 Release v1.6.89 2026-03-06 20:19:17 +01:00
Sucukdeluxe
272b43d59e Release v1.6.88 2026-03-06 20:05:53 +01:00
Sucukdeluxe
74d9047f4c Release v1.6.87 2026-03-06 19:33:59 +01:00
Sucukdeluxe
06e649ba5b fix: skip sample MKVs during library collection
MKV library collection now filters out sample files before moving.
Files in "sample"/"samples" directories and files with "sample" in
their name are excluded. This prevents duplicate "(2)" entries in
the library folder caused by samples having the same base name as
the real episodes (just different casing from the archive).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:33:15 +01:00
Sucukdeluxe
977a5c4175 Release v1.6.86 2026-03-06 19:14:52 +01:00
Sucukdeluxe
c811649b9d feat: add per-hoster provider routing (Hoster-Zuordnung)
- New settings field hosterRouting maps file hosters to specific debrid providers
- 27 known hosters predefined (Rapidgator, Uploaded, Turbobit, Nitroflare, etc.)
- Custom hoster support via prompt dialog
- Routing takes priority over default provider chain
- Falls back to normal chain on error when autoProviderFallback is enabled
- Logs routing decisions: "Hoster-Zuordnung: rapidgator → Debrid-Link"
- Full UI section in settings with add/remove/change provider per hoster
- Storage validation and normalization for hosterRouting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:14:16 +01:00
Sucukdeluxe
9f94404435 Release v1.6.85 2026-03-06 19:00:50 +01:00
Sucukdeluxe
22ed37d67c feat: add LinkSnappy provider, account deactivation, UI polish
- Add LinkSnappy provider with cookie-based session auth and /api/linkgen
- Upgrade LinkSnappy download URLs from http to https (fix 425 errors)
- Add account deactivation toggle (disabledProviders in settings)
- Show account type (API/Web/Login) in provider dropdowns
- Show API key count for Debrid-Link in status label
- Fix all missing German umlauts throughout the UI
- Wider modal for textarea, compact action buttons in one row
- Debrid-Link: log which API key (#1/#2) is used for unrestrict

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:00:19 +01:00
Sucukdeluxe
a41c99e294 Release v1.6.84 2026-03-06 18:24:05 +01:00
Sucukdeluxe
af188d96c4 feat: add Debrid-Link provider with multi-account key rotation
- New DebridLinkClient with automatic API key rotation on quota errors
  (maxLink, maxLinkHost, maxData, maxDataHost, maxAttempts, maxTransfer)
- Multi-account support: comma or newline-separated API keys
- Full UI integration: account settings, provider dropdowns, summary display
- Safe fallback for undefined debridLinkApiKeys on settings upgrade

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:23:36 +01:00
Sucukdeluxe
fac17497f0 Release v1.6.83 2026-03-06 17:43:26 +01:00
Sucukdeluxe
e92cf59d86 Release v1.6.82 2026-03-06 16:43:49 +01:00
Sucukdeluxe
3cf1bc825e Release v1.6.81 2026-03-06 12:09:39 +01:00
Sucukdeluxe
359fb93be3 Release v1.6.80 2026-03-06 12:04:56 +01:00
108 changed files with 35659 additions and 4046 deletions

14
.gitignore vendored
View File

@ -19,7 +19,6 @@ apply_update.cmd
.claude/ .claude/
.github/ .github/
docs/plans/
CHANGELOG.md CHANGELOG.md
node_modules/ node_modules/
@ -29,7 +28,6 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# Forgejo deployment runtime files
deploy/forgejo/.env deploy/forgejo/.env
deploy/forgejo/forgejo/ deploy/forgejo/forgejo/
deploy/forgejo/postgres/ deploy/forgejo/postgres/
@ -37,3 +35,15 @@ deploy/forgejo/caddy/data/
deploy/forgejo/caddy/config/ deploy/forgejo/caddy/config/
deploy/forgejo/caddy/logs/ deploy/forgejo/caddy/logs/
deploy/forgejo/backups/ deploy/forgejo/backups/
.secrets
*.log.old
*.bak
rust-postprocess/
electron-postprocess/
python-postprocess/
scripts/*.py
scripts/*.ps1
scripts/*.md
scripts/fix-library-renames.mjs

View File

@ -6,10 +6,15 @@
## Releasen ## Releasen
1. Token setzen: Der Token liegt in `.secrets` (gitignored) und wird automatisch geladen.
- PowerShell: `$env:GITEA_TOKEN="<token>"`
2. Release ausführen: Als KI-Agent: Token aus `.secrets` lesen und als Umgebungsvariable setzen, dann Release-Script ausführen:
- `npm run release:gitea -- <version> [notes]` ```bash
export $(cat .secrets | xargs) && npm run release:gitea -- <version> [notes]
```
Manuell in PowerShell (falls nötig):
- `npm run release:gitea -- <version> [notes]` (Token ist bereits als Benutzervariable gesetzt)
Das Script: Das Script:
- bumped `package.json` - bumped `package.json`

362
README.md
View File

@ -1,6 +1,6 @@
# Multi Debrid Downloader # Multi Debrid Downloader
Desktop downloader with fast queue management, automatic extraction, and robust error handling. Desktop downloader for Windows with package-based queue management, multi-provider fallback, automatic extraction, auto-rename, provider statistics, and built-in updates.
![Platform](https://img.shields.io/badge/platform-Windows%2010%2F11-0078D6) ![Platform](https://img.shields.io/badge/platform-Windows%2010%2F11-0078D6)
![Electron](https://img.shields.io/badge/Electron-31.x-47848F) ![Electron](https://img.shields.io/badge/Electron-31.x-47848F)
@ -10,83 +10,133 @@ Desktop downloader with fast queue management, automatic extraction, and robust
## Why this tool? ## Why this tool?
- Familiar download-manager workflow: collect links, start, pause, resume, and finish cleanly. - JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing.
- Multiple debrid providers in one app, including automatic fallback. - Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
- Built for stability with large queues: session persistence, reconnect handling, resume support, and integrity verification. - Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
- Includes an in-app updater for releases published on `git.24-music.de`.
## Supported providers
- AllDebrid API
- AllDebrid Web via browser login
- BestDebrid API
- BestDebrid Web via cookie import
- Debrid-Link with multi-key support
- DDownload login
- 1fichier API
- LinkSnappy login
- Mega-Debrid API
- Mega-Debrid Web
- Real-Debrid
## Core features ## Core features
### Queue and download engine ### Queue and package handling
- Package-based queue with file status, progress, ETA, speed, and retry counters. - Package-based queue with item status, retries, ETA, speed, provider, and account label.
- Start, pause, stop, and cancel for both single items and full packages. - Start, pause, stop, cancel, reset, rename, and delete for packages and items.
- Multi-select via Ctrl+Click for batch operations on packages and items. - Ctrl+Click multi-select and bulk actions.
- Queue backup import/export as JSON.
- Context-menu export for selected packages or selected items as structured TXT re-import files.
- Duplicate handling when adding links: keep, skip, or overwrite. - Duplicate handling when adding links: keep, skip, or overwrite.
- Session recovery after restart, including optional auto-resume. - Optional start scheduling for a specific time.
- Circuit breaker with escalating backoff cooldowns to handle provider outages gracefully. - Session recovery after restart with optional auto-resume.
- Optional auto-sorting by progress.
### Debrid and link handling ### Link collection
- Supported providers: `realdebrid`, `megadebrid`, `bestdebrid`, `alldebrid`. - Paste links directly into the collector.
- Configurable provider order: primary + secondary + tertiary. - Import `.txt` export files that preserve package names and optional per-file names.
- Optional automatic fallback to alternative providers on failures. - Clipboard watcher with automatic link detection.
- `.dlc` import via file picker and drag-and-drop. - `.dlc` import via file picker and drag-and-drop.
- Drag-and-drop of plain links, `.txt` export files, and supported container files.
### Extraction, cleanup, and quality ### Provider routing and fallback
- JVM-based extraction backend using SevenZipJBinding + Zip4j (supports RAR, 7z, ZIP, and more). - Configurable provider order with primary, secondary, and tertiary fallback.
- Automatic fallback to legacy UnRAR/7z CLI tools when JVM is unavailable. - Optional automatic provider fallback on unrestrict/download failures.
- Auto-extract with separate target directory and conflict strategies. - Per-hoster routing override, so specific hosters can always use a specific provider.
- Hybrid extraction: simultaneous downloading and extracting with smart I/O priority throttling. - Providers can be disabled without deleting stored account data.
- Nested extraction: archives within archives are automatically extracted (one level deep). - Daily traffic limits per provider.
- Pre-extraction disk space validation to prevent incomplete extracts. - Debrid-Link per-key daily limits and per-key daily usage tracking.
- Right-click "Extract now" on any package with at least one completed item.
- Post-download integrity checks (`CRC32`, `MD5`, `SHA1`) with auto-retry on failures.
- Completed-item cleanup policy: `never`, `immediate`, `on_start`, `package_done`.
- Optional removal of link artifacts and sample files after extraction.
### Auto-rename ### Accounts and provider tools
- Automatic renaming of extracted files based on series/episode patterns. - Central Accounts view with account type, status, info, access data, and actions.
- Multi-episode token parsing for batch renames. - BestDebrid cookie import directly from a Netscape cookies file.
- AllDebrid browser-login flow and in-app Rapidgator host status display.
- Debrid-Link multi-key management with optional detailed line-by-line key display.
- Debrid-Link API-key statistics popup with per-key Rapidgator traffic quota, link quota, reset, activate/deactivate, and click-to-copy masked keys.
- Reset button for stored account column widths in the Accounts table.
### UI and progress ### Download engine
- Visual progress bars with percentage overlay for packages and individual items. - Parallel downloads with resumable transfers when supported.
- Real-time bandwidth chart showing current download speeds. - Reconnect handling with configurable wait time.
- Persistent download counters: all-time totals and per-session statistics. - Circuit-breaker style cooldown and retry handling for provider issues.
- Download history for completed packages. - Global speed limit or per-download speed limit mode.
- Vertical sidebar with organized settings tabs. - Bandwidth schedules with time windows and speed caps.
- Hoster display showing both the original source and the debrid provider used. - Live bandwidth chart and session statistics.
- Persistent all-time download counter.
### Convenience and automation ### Extraction and post-processing
- Clipboard watcher for automatic link detection. - Automatic extraction after download.
- Minimize-to-tray with tray menu controls. - Extraction can continue even when the session is stopped or after app restart.
- Speed limits globally or per download. - Hybrid download + extract workflow.
- Bandwidth schedules for time-based speed profiles. - Extraction backend using native tools by default, with JVM sidecar support available.
- Built-in auto-updater via `git.24-music.de` Releases. - Supports common archive formats including RAR, ZIP, and 7z.
- Long path support (>260 characters) on Windows. - Nested extraction for archives found inside extracted output.
- Conflict handling: overwrite, skip, rename, or ask.
- Disk-space validation before extraction.
- Package-scoped password reuse for multi-archive sets.
- Optional cleanup of downloaded archives after extraction.
- Optional cleanup of link artifacts and sample files after extraction.
- Optional flat MKV collection folder after package completion.
### Auto-rename and media cleanup
- Auto-rename for extracted scene-style files based on folder/source naming.
- Multi-episode token parsing.
- Handles compact episode tokens like `s02e01` directly attached to the title.
- Optional skip of already extracted packages on start.
### Integrity, history, and backup
- Optional integrity verification with `CRC32`, `MD5`, and `SHA1`.
- Download history with package details, duration, size, provider, and target folder.
- Backup export/import for restoring app state.
- Persistent config, session, and history files in the Electron `userData` directory.
### UI and desktop integration
- Downloads, history, statistics, and settings tabs.
- Progress bars for packages and single items.
- Hoster/provider display showing both source and effective debrid account.
- Minimize-to-tray support.
- Dark/light theme setting.
- Long path support on Windows.
- Default startup window size of `1920x1080`.
## Installation ## Installation
### Option A: prebuilt releases (recommended) ### Prebuilt releases
1. Download a release from the `git.24-music.de` Releases page. 1. Download the latest installer or portable build from the releases page.
2. Run the installer or portable build. 2. Start the app.
3. Add your debrid tokens in Settings. 3. Add your provider credentials in `Settings > Accounts`.
Releases: `https://git.24-music.de/Administrator/real-debrid-downloader/releases` Releases: [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases)
### Option B: build from source ### Build from source
Requirements: Requirements:
- Node.js `20+` (recommended `22+`) - Node.js `20+`
- npm - npm
- Windows `10/11` (for packaging and regular desktop use) - Windows `10/11`
- Java Runtime `8+` (for SevenZipJBinding sidecar backend) - Java Runtime `8+` for the optional JVM extraction backend
- Optional fallback: 7-Zip/UnRAR if you force legacy extraction mode - Optional native extraction tools: 7-Zip / WinRAR / UnRAR
```bash ```bash
npm install npm install
@ -97,106 +147,160 @@ npm run dev
| Command | Description | | Command | Description |
| --- | --- | | --- | --- |
| `npm run dev` | Starts main process, renderer, and Electron in dev mode | | `npm run dev` | Starts Vite, tsup watchers, and Electron in development mode |
| `npm run build` | Builds main and renderer bundles | | `npm run build` | Builds main and renderer bundles |
| `npm run start` | Starts the app locally in production mode | | `npm run start` | Starts the built app in production mode |
| `npm test` | Runs Vitest unit tests | | `npm test` | Runs Vitest unit tests |
| `npm run self-check` | Runs integrated end-to-end self-checks | | `npm run self-check` | Runs integrated self-checks |
| `npm run release:win` | Creates Windows installer and portable build | | `npm run release:win` | Builds Windows installer and portable EXE |
| `npm run release:gitea -- <version> [notes]` | One-command version bump + build + tag + release upload to `git.24-music.de` | | `npm run release:gitea -- <version> [notes]` | Builds, tags, and uploads a release to `git.24-music.de` |
| `npm run release:codeberg -- <version> [notes]` | Legacy path for old Codeberg workflow | | `npm run release:forgejo -- <version> [notes]` | Alias for the same release workflow |
### One-command git.24-music release
```bash
npm run release:gitea -- 1.6.31 "- Maintenance update"
```
This command will:
1. Bump `package.json` version.
2. Build setup/portable artifacts (`npm run release:win`).
3. Commit and push `main` to your `git.24-music.de` remote.
4. Create and push tag `v<version>`.
5. Create/update the Gitea release and upload required assets.
Required once before release:
```bash
git remote add gitea https://git.24-music.de/<user>/<repo>.git
```
PowerShell token setup:
```powershell
$env:GITEA_TOKEN="<dein-token>"
```
## Typical workflow ## Typical workflow
1. Add provider tokens in Settings. 1. Add one or more provider accounts in `Settings > Accounts`.
2. Paste/import links or `.dlc` containers. 2. Configure provider order, fallback, and optional hoster routing.
3. Optionally set package names, target folders, extraction, and cleanup rules. 3. Paste links or import `.dlc` files.
4. Start the queue and monitor progress in the Downloads tab. 4. Adjust package names, target folders, extraction, and cleanup settings if needed.
5. Review integrity results and summary after completion. 5. Start the queue and monitor downloads, extraction, and provider status.
6. Review history and statistics after completion.
## Link export format
Selected packages or items can be exported from the context menu as a structured text file. Re-importing that file restores the original package grouping, even if it only contains a subset of items from a larger package.
Example:
```txt
# rd-link-export: 1
# package: Dave Staffel 1
# file: Dave.S01E01.rar
https://example.com/e01
# file: Dave.S01E02.rar
https://example.com/e02
```
Supported import sources:
- collector text input
- `Datei importieren`
- drag-and-drop of `.txt` and `.json`
The optional `# file:` marker preserves the original item name so the imported subset can be rebuilt with the same package name and per-item filename hints.
## Project structure ## Project structure
- `src/main` - Electron main process, queue/download/provider logic - `src/main` - Electron main process, download engine, provider clients, updater, storage
- `src/preload` - secure IPC bridge between main and renderer - `src/preload` - secure IPC bridge
- `src/renderer` - React UI - `src/renderer` - React UI
- `src/shared` - shared types and IPC contracts - `src/shared` - shared types and IPC contracts
- `tests` - unit tests and self-check tests - `tests` - unit and integration-style tests
- `resources/extractor-jvm` - SevenZipJBinding + Zip4j sidecar JAR and native libraries - `resources/extractor-jvm` - optional JVM extraction runtime
- `scripts` - release and build helpers
## Data and logs ## Data and logs
The app stores runtime files in Electron's `userData` directory, including: Runtime files are stored in Electron's `userData` directory, including:
- `rd_downloader_config.json` - `rd_downloader_config.json`
- `rd_session_state.json` - `rd_session_state.json`
- `rd_history.json`
- `rd_downloader.log` - `rd_downloader.log`
- `audit.log`
- `rename.log`
- `debug_ai_manifest.json`
- `trace.log`
- `trace_config.json`
- `session-logs/session_*.txt`
- `package-logs/package_*.txt`
- `item-logs/item_*.txt`
`audit.log`, `rename.log`, and `trace.log` are rotated automatically. The current file is kept plus one `.old` backup, and outdated backups are purged automatically.
### Remote debug server
For headless or server-style troubleshooting, the app can expose a small authenticated HTTP debug API with live status and log tails.
Enable it by creating these files in the same runtime folder that contains `rd_downloader.log`:
- `debug_token.txt`
Example: a long random token such as `rd-debug-please-change-me`
- `debug_port.txt`
Example: `9868`
- `debug_host.txt` (optional)
Default is `127.0.0.1`. Set `0.0.0.0` only if you really want remote access and protect it with firewall, VPN, or reverse proxy.
After startup, the app also writes `debug_ai_manifest.json` into the same runtime folder. This file is meant for support tooling and AI agents: it lists all available endpoints, the auth method, the related runtime files, and the one remaining external value the assistant may still need from you for remote access: the server IP or DNS name.
If you want extra support detail during a flaky or hard-to-reproduce issue, the app also maintains a `trace.log` plus `trace_config.json`. You can enable or disable the support trace from the app menu or remotely via the debug API. By default, the support trace now auto-disables again after 2 hours so it does not stay enabled forever by accident.
The app menu under `Hilfe` also includes a `Debug-Setup prüfen` action. It verifies the current host/port/token/AI-manifest/trace setup locally and now also reports free disk space, current support-log sizes, and an estimated support-bundle size.
Available endpoints after restart:
- `GET /health`
- `GET /meta`
- `GET /debug/setup`
- `GET /self-check`
- `GET /host/diagnostics`
- `GET /status`
- `GET /settings`
- `GET /accounts`
- `GET /stats`
- `GET /history?limit=50&status=completed`
- `GET /packages?package=Release&includeItems=1`
- `GET /items?status=downloading&package=Release`
- `GET /session?package=Release`
- `GET /log?lines=100&grep=keyword`
- `GET /logs/main?lines=100&grep=keyword`
- `GET /logs/audit?lines=100&grep=keyword`
- `GET /logs/rename?lines=100&grep=keyword`
- `GET /logs/trace?lines=100&grep=keyword`
- `GET /logs/session?lines=100&grep=keyword`
- `GET /logs/package?package=Release&lines=100&grep=keyword`
- `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword`
- `GET /trace/config?enable=1&note=support&durationMinutes=120`
- `GET /support/bundle`
- `GET /diagnostics?package=Release&lines=150`
Authentication works with either:
- header: `Authorization: Bearer <token>`
- query param: `?token=<token>`
Example from PowerShell:
```powershell
Invoke-RestMethod "http://SERVER:9868/diagnostics?token=YOUR_TOKEN&package=Release"
Invoke-RestMethod "http://SERVER:9868/settings?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20"
Invoke-RestMethod "http://SERVER:9868/debug/setup?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/self-check?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200"
Invoke-RestMethod "http://SERVER:9868/logs/rename?token=YOUR_TOKEN&lines=200"
Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200"
Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1&note=support&durationMinutes=120"
Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200"
Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200"
Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN"
Invoke-WebRequest "http://SERVER:9868/support/bundle?token=YOUR_TOKEN" -OutFile ".\\rd-support-bundle.zip"
```
This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, rename/MKV move traces, trace data, package/session/item logs, host-side Windows crash hints, disk space, support-log volume, support-bundle size estimates, and even a full ZIP support bundle can be inspected remotely.
## Troubleshooting ## Troubleshooting
- Download does not start: verify token and selected provider in Settings. - Provider does not work: verify credentials, enabled state, provider order, and daily limits.
- Extraction fails: check archive passwords and native extractor installation (7-Zip/WinRAR). Optional JVM extractor can be forced with `RD_EXTRACT_BACKEND=jvm`. - Debrid-Link quota looks wrong: open the API-key statistics popup and check the Rapidgator quota for the affected key.
- Very slow downloads: check active speed limit and bandwidth schedules. - Extraction fails: verify passwords and installed extraction tools. The native backend is the default; JVM extraction is optional.
- Unexpected interruptions: enable reconnect and fallback providers. - Downloads stall: check active speed limits, bandwidth schedules, reconnect settings, and provider health.
- Stalled downloads: the app auto-detects stalls within 10 seconds and retries automatically. - Accounts table looks misaligned on one machine: use `Spalten zuruecksetzen` in the Accounts view to clear the locally stored column widths.
## Changelog ## Changelog
Release history is available on [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases). Detailed release history is published on [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases).
### v1.6.61 (2026-03-05)
- Fixed leftover empty package folders in `Downloader Unfertig` after successful extraction.
- Resume marker files (`.rd_extract_progress*.json`) are now treated as ignorable for empty-folder cleanup.
- Deferred post-processing now clears resume markers before running empty-directory removal.
### v1.6.60 (2026-03-05)
- Added package-scoped password cache for extraction: once the first archive in a package is solved, following archives in the same package reuse that password first.
- Kept fallback behavior intact (`""` and other candidates are still tested), but moved empty-password probing behind the learned password to reduce per-archive delays.
- Added cache invalidation on real `wrong_password` failures so stale passwords are automatically discarded.
### v1.6.59 (2026-03-05)
- Switched default extraction backend to native tools (`legacy`) for more stable archive-to-archive flow.
- Prioritized 7-Zip as primary native extractor, with WinRAR/UnRAR as fallback.
- JVM extractor remains available as opt-in via `RD_EXTRACT_BACKEND=jvm`.
### v1.6.58 (2026-03-05)
- Fixed extraction progress oscillation (`1% -> 100% -> 1%` loops) during password retries.
- Kept strict archive completion logic, but normalized in-progress archive percent to avoid false visual done states before real completion.
### v1.6.57 (2026-03-05)
- Fixed extraction flow so archives are marked done only on real completion, not on temporary `100%` progress spikes.
- Improved password handling: after the first successful archive, the discovered password is prioritized for subsequent archives.
- Fixed progress parsing for password retries (reset/restart handling), reducing visible and real gaps between archive extractions.
## License ## License

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.6.69", "version": "1.7.45",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.6.69", "version": "1.7.45",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

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

View File

@ -98,13 +98,12 @@ public final class JBindExtractorMain {
System.out.flush(); System.out.flush();
} }
} catch (IOException ignored) { } catch (IOException ignored) {
// stdin closed parent process exited
} }
} }
private static ExtractionRequest parseDaemonRequest(String jsonLine) { private static ExtractionRequest parseDaemonRequest(String jsonLine) {
// Minimal JSON parsing without external dependencies.
// Expected format: {"archive":"...","target":"...","conflict":"...","backend":"...","passwords":["...","..."]}
ExtractionRequest request = new ExtractionRequest(); ExtractionRequest request = new ExtractionRequest();
request.archiveFile = new File(extractJsonString(jsonLine, "archive")); request.archiveFile = new File(extractJsonString(jsonLine, "archive"));
request.targetDir = new File(extractJsonString(jsonLine, "target")); request.targetDir = new File(extractJsonString(jsonLine, "target"));
@ -116,7 +115,7 @@ public final class JBindExtractorMain {
if (backend.length() > 0) { if (backend.length() > 0) {
request.backend = Backend.fromValue(backend); request.backend = Backend.fromValue(backend);
} }
// Parse passwords array
int pwStart = jsonLine.indexOf("\"passwords\""); int pwStart = jsonLine.indexOf("\"passwords\"");
if (pwStart >= 0) { if (pwStart >= 0) {
int arrStart = jsonLine.indexOf('[', pwStart); int arrStart = jsonLine.indexOf('[', pwStart);
@ -161,7 +160,7 @@ public final class JBindExtractorMain {
for (int i = from; i < s.length(); i++) { for (int i = from; i < s.length(); i++) {
char c = s.charAt(i); char c = s.charAt(i);
if (c == '\\') { if (c == '\\') {
i++; // skip escaped character i++;
continue; continue;
} }
if (c == '"') return i; if (c == '"') return i;
@ -367,7 +366,6 @@ public final class JBindExtractorMain {
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath()); throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
} }
// Pre-scan: collect file indices, sizes, output paths, and detect encryption
long totalUnits = 0; long totalUnits = 0;
boolean encrypted = false; boolean encrypted = false;
List<Integer> fileIndices = new ArrayList<Integer>(); List<Integer> fileIndices = new ArrayList<Integer>();
@ -391,7 +389,7 @@ public final class JBindExtractorMain {
Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED); Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED);
encrypted = encrypted || Boolean.TRUE.equals(isEncrypted); encrypted = encrypted || Boolean.TRUE.equals(isEncrypted);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// ignore encrypted flag read issues
} }
Long rawSize = (Long) archive.getProperty(i, PropID.SIZE); Long rawSize = (Long) archive.getProperty(i, PropID.SIZE);
@ -400,12 +398,12 @@ public final class JBindExtractorMain {
File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
fileIndices.add(i); fileIndices.add(i);
outputFiles.add(output); // null if skipped outputFiles.add(output);
fileSizes.add(itemSize); fileSizes.add(itemSize);
} }
if (fileIndices.isEmpty()) { if (fileIndices.isEmpty()) {
// All items are folders or skipped
ProgressTracker progress = new ProgressTracker(1); ProgressTracker progress = new ProgressTracker(1);
progress.emitStart(); progress.emitStart();
progress.emitDone(); progress.emitDone();
@ -415,19 +413,16 @@ public final class JBindExtractorMain {
ProgressTracker progress = new ProgressTracker(totalUnits); ProgressTracker progress = new ProgressTracker(totalUnits);
progress.emitStart(); progress.emitStart();
// Build index array for bulk extract
int[] indices = new int[fileIndices.size()]; int[] indices = new int[fileIndices.size()];
for (int i = 0; i < fileIndices.size(); i++) { for (int i = 0; i < fileIndices.size(); i++) {
indices[i] = fileIndices.get(i); indices[i] = fileIndices.get(i);
} }
// Map from archive index to our position in fileIndices/outputFiles
Map<Integer, Integer> indexToPos = new HashMap<Integer, Integer>(); Map<Integer, Integer> indexToPos = new HashMap<Integer, Integer>();
for (int i = 0; i < fileIndices.size(); i++) { for (int i = 0; i < fileIndices.size(); i++) {
indexToPos.put(fileIndices.get(i), i); indexToPos.put(fileIndices.get(i), i);
} }
// Bulk extraction state
final boolean encryptedFinal = encrypted; final boolean encryptedFinal = encrypted;
final String effectivePassword = password == null ? "" : password; final String effectivePassword = password == null ? "" : password;
final File[] currentOutput = new File[1]; final File[] currentOutput = new File[1];
@ -674,7 +669,7 @@ public final class JBindExtractorMain {
if (entry.length() == 0) { if (entry.length() == 0) {
return fallback; return fallback;
} }
// Sanitize Windows special characters from each path segment
String[] segments = entry.split("/", -1); String[] segments = entry.split("/", -1);
StringBuilder sanitized = new StringBuilder(); StringBuilder sanitized = new StringBuilder();
for (int i = 0; i < segments.length; i++) { for (int i = 0; i < segments.length; i++) {
@ -708,7 +703,7 @@ public final class JBindExtractorMain {
if (Files.isSymbolicLink(file.toPath())) { if (Files.isSymbolicLink(file.toPath())) {
throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath()); throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath());
} }
// Also check parent directories for symlinks
File parent = file.getParentFile(); File parent = file.getParentFile();
while (parent != null) { while (parent != null) {
if (Files.isSymbolicLink(parent.toPath())) { if (Files.isSymbolicLink(parent.toPath())) {
@ -879,12 +874,6 @@ public final class JBindExtractorMain {
private final List<String> passwords = new ArrayList<String>(); private final List<String> passwords = new ArrayList<String>();
} }
/**
* Bulk extraction callback that implements both IArchiveExtractCallback and
* ICryptoGetTextPassword. Using the bulk IInArchive.extract() API instead of
* per-item extractSlow() is critical for performance solid RAR archives
* otherwise re-decode from the beginning for every single item.
*/
private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword { private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword {
private final IInArchive archive; private final IInArchive archive;
private final Map<Integer, Integer> indexToPos; private final Map<Integer, Integer> indexToPos;
@ -930,12 +919,12 @@ public final class JBindExtractorMain {
@Override @Override
public void setTotal(long total) { public void setTotal(long total) {
// 7z reports total compressed bytes; we track uncompressed via ProgressTracker
} }
@Override @Override
public void setCompleted(long complete) { public void setCompleted(long complete) {
// Not used we track per-write progress
} }
@Override @Override
@ -990,7 +979,7 @@ public final class JBindExtractorMain {
@Override @Override
public void prepareOperation(ExtractAskMode extractAskMode) { public void prepareOperation(ExtractAskMode extractAskMode) {
// no-op
} }
@Override @Override
@ -1011,7 +1000,7 @@ public final class JBindExtractorMain {
currentOutput[0].setLastModified(modified.getTime()); currentOutput[0].setLastModified(modified.getTime());
} }
} catch (Throwable ignored) { } catch (Throwable ignored) {
// best effort
} }
} }
} else { } else {
@ -1179,12 +1168,12 @@ public final class JBindExtractorMain {
@Override @Override
public void setTotal(Long files, Long bytes) { public void setTotal(Long files, Long bytes) {
// no-op
} }
@Override @Override
public void setCompleted(Long files, Long bytes) { public void setCompleted(Long files, Long bytes) {
// no-op
} }
@Override @Override
@ -1196,8 +1185,7 @@ public final class JBindExtractorMain {
if (filename == null || filename.trim().length() == 0) { if (filename == null || filename.trim().length() == 0) {
return null; return null;
} }
// Always resolve relative to the archive's parent directory.
// Never accept absolute paths to prevent path traversal.
String baseName = new File(filename).getName(); String baseName = new File(filename).getName();
if (archiveDir != null) { if (archiveDir != null) {
File relative = new File(archiveDir, baseName); File relative = new File(archiveDir, baseName);

View File

@ -66,8 +66,6 @@ async function callRealDebrid(link) {
}; };
} }
// megaCookie is intentionally cached at module scope so that multiple
// callMegaDebrid() invocations reuse the same session cookie.
async function callMegaDebrid(link) { async function callMegaDebrid(link) {
if (!megaCookie) { if (!megaCookie) {
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", { const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {

View File

@ -116,7 +116,6 @@ function getGiteaRepo() {
} }
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` }; return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
} catch { } catch {
// try next remote
} }
} }
@ -256,53 +255,78 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
target_commitish: "main", target_commitish: "main",
name: tag, name: tag,
body: notes || `Release ${tag}`, body: notes || `Release ${tag}`,
draft: false, draft: true,
prerelease: false prerelease: false
}; };
const created = await apiRequest("POST", `${baseApi}/releases`, authHeader, JSON.stringify(payload)); const created = await apiRequest("POST", `${baseApi}/releases`, authHeader, JSON.stringify(payload));
if (!created.ok) { if (created.ok) {
throw new Error(`Failed to create release (${created.status}): ${JSON.stringify(created.body)}`); return created.body;
} }
return created.body; if (created.status === 409 || created.status === 422 || created.status === 500) {
const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
if (retry.ok) {
process.stdout.write(`Release already exists, using existing release.\n`);
return retry.body;
}
}
throw new Error(`Failed to create release (${created.status}): ${JSON.stringify(created.body)}`);
} }
async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, files) { async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, files) {
const MAX_ATTEMPTS = 3;
for (const fileName of files) { for (const fileName of files) {
const filePath = path.join(releaseDir, fileName); const filePath = path.join(releaseDir, fileName);
const fileSize = fs.statSync(filePath).size; const fileSize = fs.statSync(filePath).size;
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`; const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
// Stream large files instead of loading them entirely into memory for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const fileStream = fs.createReadStream(filePath); const fileStream = fs.createReadStream(filePath);
const response = await fetch(uploadUrl, { let response;
method: "POST", try {
headers: { response = await fetch(uploadUrl, {
Accept: "application/json", method: "POST",
Authorization: authHeader, headers: {
"Content-Type": "application/octet-stream", Accept: "application/json",
"Content-Length": String(fileSize) Authorization: authHeader,
}, "Content-Type": "application/octet-stream",
body: fileStream, "Content-Length": String(fileSize)
duplex: "half" },
}); body: fileStream,
duplex: "half"
});
} catch (error) {
fileStream.destroy();
if (attempt < MAX_ATTEMPTS) {
process.stdout.write(`Upload ${fileName} abgebrochen (Netzwerk, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
continue;
}
throw new Error(`Asset upload failed for ${fileName} after ${MAX_ATTEMPTS} attempts: ${String(error?.message || error)}`);
}
const text = await response.text(); const text = await response.text();
let parsed; let parsed;
try { try {
parsed = text ? JSON.parse(text) : null; parsed = text ? JSON.parse(text) : null;
} catch { } catch {
parsed = text; parsed = text;
} }
if (response.ok) { if (response.ok) {
process.stdout.write(`Uploaded: ${fileName}\n`); process.stdout.write(`Uploaded: ${fileName}\n`);
continue; break;
}
if (response.status === 409 || response.status === 422) {
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
break;
}
if (response.status >= 500 && attempt < MAX_ATTEMPTS) {
process.stdout.write(`Upload ${fileName} fehlgeschlagen (${response.status}, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
continue;
}
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
} }
if (response.status === 409 || response.status === 422) {
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
continue;
}
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
} }
} }
@ -322,31 +346,43 @@ async function main() {
const releaseNotes = args.notes || `- Release ${tag}`; const releaseNotes = args.notes || `- Release ${tag}`;
const repo = getGiteaRepo(); const repo = getGiteaRepo();
ensureNoTrackedChanges(); const tagExists = spawnSync("git", ["rev-parse", "--verify", `refs/tags/${tag}`], { cwd: process.cwd(), stdio: "ignore" }).status === 0;
ensureTagMissing(tag);
if (args.dryRun) { if (tagExists) {
process.stdout.write(`Dry run: would release ${tag}. No changes made.\n`); process.stdout.write(`Tag ${tag} already exists locally — skipping version bump and git operations (recovery mode).\n`);
return; } else {
ensureNoTrackedChanges();
if (args.dryRun) {
process.stdout.write(`Dry run: would release ${tag}. No changes made.\n`);
return;
}
updatePackageVersion(rootDir, version);
} }
updatePackageVersion(rootDir, version);
process.stdout.write(`Building release artifacts for ${tag}...\n`); process.stdout.write(`Building release artifacts for ${tag}...\n`);
run(NPM_RELEASE_WIN.command, NPM_RELEASE_WIN.args); run(NPM_RELEASE_WIN.command, NPM_RELEASE_WIN.args);
const assets = ensureAssetsExist(rootDir, version); const assets = ensureAssetsExist(rootDir, version);
run("git", ["add", "package.json"]); if (!tagExists) {
run("git", ["commit", "-m", `Release ${tag}`]); run("git", ["add", "package.json"]);
run("git", ["push", repo.remote, "main"]); run("git", ["commit", "-m", `Release ${tag}`]);
run("git", ["tag", tag]); run("git", ["push", repo.remote, "main"]);
run("git", ["push", repo.remote, tag]); run("git", ["tag", tag]);
run("git", ["push", repo.remote, tag]);
}
const authHeader = getAuthHeader(repo.host); const authHeader = getAuthHeader(repo.host);
const baseApi = `${repo.baseUrl}/api/v1/repos/${repo.owner}/${repo.repo}`; const baseApi = `${repo.baseUrl}/api/v1/repos/${repo.owner}/${repo.repo}`;
const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes); const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes);
await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files); await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files);
const published = await apiRequest("PATCH", `${baseApi}/releases/${release.id}`, authHeader, JSON.stringify({ draft: false }));
if (!published.ok) {
throw new Error(`Failed to publish release (${published.status}): ${JSON.stringify(published.body)}`);
}
process.stdout.write(`Release published: ${release.html_url || `${repo.baseUrl}/${repo.owner}/${repo.repo}/releases/tag/${tag}`}\n`); process.stdout.write(`Release published: ${release.html_url || `${repo.baseUrl}/${repo.owner}/${repo.repo}/releases/tag/${tag}`}\n`);
} }

197
src/main/account-check.ts Normal file
View File

@ -0,0 +1,197 @@
import type { AppSettings, DebridAccountStatus } from "../shared/types";
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
import { logger } from "./logger";
import { compactErrorText } from "./utils";
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
const CHECK_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
const CHECK_TIMEOUT_MS = 20000;
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
const timeout = AbortSignal.timeout(ms);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
function parseJsonSafe(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text) as unknown;
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function formatRemaining(premiumUntilMs: number | null, now: number): string {
if (premiumUntilMs == null) {
return "Premium-Status unbekannt";
}
if (premiumUntilMs <= 0) {
return "Kein Premium";
}
const remainingMs = premiumUntilMs - now;
if (remainingMs <= 0) {
return "Premium abgelaufen";
}
const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
if (days >= 1) {
return `Premium noch ${days} Tag${days === 1 ? "" : "e"}`;
}
const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000)));
return `Premium noch ${hours} Std`;
}
export async function checkMegaDebridAccount(
account: MegaDebridAccountEntry,
signal?: AbortSignal,
now = Date.now()
): Promise<DebridAccountStatus> {
const base: DebridAccountStatus = {
accountId: account.id,
provider: "megadebrid",
label: account.label,
maskedLogin: account.maskedLogin,
valid: false,
isPremium: false,
premiumUntilMs: null,
message: "",
checkedAt: now
};
try {
const url = `${MEGA_DEBRID_API}?action=connectUser&login=${encodeURIComponent(account.login)}&password=${encodeURIComponent(account.password)}`;
const response = await fetch(url, {
headers: { "User-Agent": CHECK_USER_AGENT },
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
});
const text = await response.text();
const payload = parseJsonSafe(text);
if (!response.ok || !payload) {
return { ...base, message: `Login fehlgeschlagen (HTTP ${response.status})` };
}
if (payload.response_code !== "ok") {
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
return { ...base, message: `Ungueltiger Login: ${reason}` };
}
const vipEndRaw = Number(payload.vip_end || 0);
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
const isPremium = premiumUntilMs > now;
const email = String(payload.email || "").trim() || undefined;
return {
...base,
valid: true,
isPremium,
premiumUntilMs,
email,
message: formatRemaining(premiumUntilMs, now)
};
} catch (error) {
const errText = compactErrorText(error);
const aborted = signal?.aborted || /aborted/i.test(errText);
return {
...base,
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
};
}
}
export async function checkDebridLinkKey(
key: DebridLinkApiKeyEntry,
signal?: AbortSignal,
now = Date.now()
): Promise<DebridAccountStatus> {
const base: DebridAccountStatus = {
accountId: key.id,
provider: "debridlink",
label: key.label,
maskedLogin: key.masked,
valid: false,
isPremium: false,
premiumUntilMs: null,
message: "",
checkedAt: now
};
try {
const response = await fetch(`${DEBRID_LINK_API}/account/infos`, {
headers: {
Authorization: `Bearer ${key.token}`,
"User-Agent": CHECK_USER_AGENT
},
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
});
const text = await response.text();
const payload = parseJsonSafe(text);
if (!response.ok || !payload) {
if (response.status === 401 || response.status === 403) {
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
}
return { ...base, message: `Pruefung fehlgeschlagen (HTTP ${response.status})` };
}
if (payload.success === false) {
const reason = String(payload.error || "Key abgelehnt");
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
}
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
const premiumLeftSec = Number(value.premiumLeft || 0);
const accountType = Number(value.accountType || 0);
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
const isPremium = premiumUntilMs > now || accountType > 0;
const username = String(value.username || "").trim() || undefined;
return {
...base,
valid: true,
isPremium,
premiumUntilMs: premiumUntilMs > 0 ? premiumUntilMs : (accountType > 0 ? null : 0),
email: username,
message: premiumUntilMs > 0
? formatRemaining(premiumUntilMs, now)
: (accountType > 0 ? "Premium aktiv" : "Kein Premium (Free)")
};
} catch (error) {
const errText = compactErrorText(error);
const aborted = signal?.aborted || /aborted/i.test(errText);
return {
...base,
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
};
}
}
export async function checkAllDebridAccounts(
settings: AppSettings,
signal?: AbortSignal
): Promise<DebridAccountStatus[]> {
const now = Date.now();
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
];
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
logger.info(
`Account-Check abgeschlossen: ${results.length} Accounts geprueft ` +
`(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)`
);
return results;
}
const CHECK_CONCURRENCY = 4;
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
const results: T[] = new Array(taskFns.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < taskFns.length) {
const current = nextIndex;
nextIndex += 1;
results[current] = await taskFns[current]();
}
};
const workers = Array.from({ length: Math.min(limit, taskFns.length) }, () => worker());
await Promise.all(workers);
return results;
}

View File

@ -0,0 +1,204 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
import { AsyncLocalStorage } from "node:async_hooks";
import type { RotationEvent } from "../shared/types";
export type RotationItemSink = (event: RotationEvent) => void;
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
return rotationItemContext.run(sink, fn);
}
type RotationLevel = "INFO" | "WARN" | "ERROR";
const ROTATION_EVENT_RING_MAX = 60;
const rotationEventRing: RotationEvent[] = [];
let rotationEventSeq = 0;
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
rotationEventListener = listener;
}
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
const slice = rotationEventRing.slice(-limit);
slice.reverse();
return slice;
}
function isUiRelevantRotationEvent(event: string): boolean {
return event !== "TEST";
}
function pushRotationEvent(
level: RotationLevel,
provider: string,
accountLabel: string,
event: string,
fields?: Record<string, unknown>,
at = Date.now()
): void {
rotationEventSeq += 1;
const entry: RotationEvent = {
id: `rot_${at}_${rotationEventSeq}`,
at,
level,
provider,
accountLabel,
event,
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
category: fields && fields.category != null ? String(fields.category) : undefined,
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
next: fields && fields.next != null ? String(fields.next) : undefined
};
const itemSink = rotationItemContext.getStore();
if (itemSink) {
try {
itemSink(entry);
} catch {
}
}
if (!isUiRelevantRotationEvent(event)) {
return;
}
rotationEventRing.push(entry);
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
}
if (rotationEventListener) {
try {
rotationEventListener(entry);
} catch {
}
}
}
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
let rotationLogPath: string | null = null;
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function rotateIfNeeded(filePath: string): void {
try {
const stat = fs.statSync(filePath);
if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
}
fs.renameSync(filePath, backup);
} catch {
}
}
function cleanupOldBackup(filePath: string): void {
const backup = `${filePath}.old`;
try {
const stat = fs.statSync(backup);
const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (stat.mtimeMs < cutoff) {
fs.rmSync(backup, { force: true });
}
} catch {
}
}
export function initAccountRotationLog(baseDir: string): void {
rotationLogPath = path.join(baseDir, "account-rotation.log");
try {
fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true });
cleanupOldBackup(rotationLogPath);
if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8");
}
rotateIfNeeded(rotationLogPath);
if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8");
}
fs.appendFileSync(
rotationLogPath,
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`,
"utf8"
);
} catch {
rotationLogPath = null;
}
}
export function logAccountRotation(
level: RotationLevel,
provider: string,
accountLabel: string,
event: string,
fields?: Record<string, unknown>
): void {
pushRotationEvent(level, provider, accountLabel, event, fields);
if (!rotationLogPath) {
return;
}
try {
rotateIfNeeded(rotationLogPath);
if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8");
}
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
} catch {
}
}
export function getAccountRotationLogPath(): string | null {
if (!rotationLogPath) {
return null;
}
return fs.existsSync(rotationLogPath) ? rotationLogPath : null;
}
export function shutdownAccountRotationLog(): void {
if (!rotationLogPath) {
return;
}
try {
fs.appendFileSync(
rotationLogPath,
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`,
"utf8"
);
} catch {
}
rotationLogPath = null;
}

View File

@ -243,12 +243,10 @@ export class AllDebridWebFallback {
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
}); });
} catch { } catch {
// ignore
} }
try { try {
await currentSession.clearCache(); await currentSession.clearCache();
} catch { } catch {
// ignore
} }
} }
} }

View File

@ -1,9 +1,13 @@
import path from "node:path"; import path from "node:path";
import v8 from "node:v8";
import { app } from "electron"; import { app } from "electron";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { import {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
DebridAccountStatus,
DebridProvider,
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
@ -16,20 +20,38 @@ import {
UpdateInstallProgress, UpdateInstallProgress,
UpdateInstallResult UpdateInstallResult
} from "../shared/types"; } from "../shared/types";
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
import { importDlcContainers } from "./container"; import { importDlcContainers } from "./container";
import { APP_VERSION } from "./constants"; import { APP_VERSION } from "./constants";
import { DownloadManager } from "./download-manager"; import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo } from "./debrid"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { parseCollectorInput } from "./link-parser"; import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger"; import { configureLogger, getLogFilePath, logger } from "./logger";
import { AllDebridWebFallback } from "./all-debrid-web"; import { AllDebridWebFallback } from "./all-debrid-web";
import { BestDebridWebFallback } from "./bestdebrid-web"; import { BestDebridWebFallback } from "./bestdebrid-web";
import { RealDebridWebFallback } from "./realdebrid-web"; import { RealDebridWebFallback } from "./realdebrid-web";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-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, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage"; import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, 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 { startDebugServer, stopDebugServer } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto";
import { buildBackupPayload, planBackupImport } from "./backup-payload";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
import { runStartupHealthCheck } from "./startup-health-check";
import { getDebugSetupCheck } from "./debug-setup";
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog } from "./desktop-rename-log";
import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> { function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@ -62,11 +84,27 @@ export class AppController {
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
private autoResumePending = false; private autoResumePending = false;
private runtimeStatsTimer: NodeJS.Timeout | null = null;
private lastMemoryWarnAt = 0;
public constructor() { public constructor() {
configureLogger(this.storagePaths.baseDir); configureLogger(this.storagePaths.baseDir);
initSessionLog(this.storagePaths.baseDir); initSessionLog(this.storagePaths.baseDir);
initPackageLogs(this.storagePaths.baseDir);
initItemLogs(this.storagePaths.baseDir);
initAuditLog(this.storagePaths.baseDir);
initAccountRotationLog(this.storagePaths.baseDir);
initRenameLog(this.storagePaths.baseDir);
let desktopDir: string | null = null;
try {
desktopDir = app.getPath("desktop");
} catch {
desktopDir = null;
}
initDesktopRenameLog(desktopDir);
initTraceLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths); this.settings = loadSettings(this.storagePaths);
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const session = loadSession(this.storagePaths); const session = loadSession(this.storagePaths);
this.megaWebFallback = new MegaWebFallback(() => ({ this.megaWebFallback = new MegaWebFallback(() => ({
login: this.settings.megaLogin, login: this.settings.megaLogin,
@ -76,13 +114,13 @@ export class AppController {
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken); this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken); this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
this.manager = new DownloadManager(this.settings, session, this.storagePaths, { this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal), megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account),
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
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(),
onHistoryEntry: (entry: HistoryEntry) => { onHistoryEntry: (entry: HistoryEntry) => {
addHistoryEntry(this.storagePaths, entry); addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry);
} }
}); });
this.manager.on("state", (snapshot: UiSnapshot) => { this.manager.on("state", (snapshot: UiSnapshot) => {
@ -90,7 +128,45 @@ export class AppController {
}); });
logger.info(`App gestartet v${APP_VERSION}`); logger.info(`App gestartet v${APP_VERSION}`);
logger.info(`Log-Datei: ${getLogFilePath()}`); logger.info(`Log-Datei: ${getLogFilePath()}`);
logAuditEvent("INFO", "App gestartet", {
appVersion: APP_VERSION,
runtimeDir: this.storagePaths.baseDir
});
try {
const report = runStartupHealthCheck(this.settings, this.storagePaths);
if (report.errorCount > 0 || report.warnCount > 0) {
logger.warn(`Health-Check: ${report.errorCount} Fehler, ${report.warnCount} Warnungen, ${report.infoCount} Info`);
} else {
logger.info(`Health-Check: alles OK (${report.infoCount} Info)`);
}
for (const finding of report.findings) {
const line = finding.hint
? `Health-Check [${finding.code}]: ${finding.message}${finding.hint}`
: `Health-Check [${finding.code}]: ${finding.message}`;
if (finding.severity === "ERROR") {
logger.error(line);
} else if (finding.severity === "WARN") {
logger.warn(line);
} else {
logger.info(line);
}
if (finding.severity !== "INFO") {
logAuditEvent(finding.severity, `Health-Check: ${finding.code}`, {
message: finding.message,
hint: finding.hint || ""
});
}
}
} catch (err) {
logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`);
}
startDebugServer(this.manager, this.storagePaths.baseDir); startDebugServer(this.manager, this.storagePaths.baseDir);
this.runtimeStatsTimer = setInterval(() => {
this.manager.persistRuntimeStats();
this.settings = this.manager.getSettings();
this.checkMemoryPressure();
}, 60_000);
this.runtimeStatsTimer.unref?.();
if (this.settings.autoResumeOnStart) { if (this.settings.autoResumeOnStart) {
const snapshot = this.manager.getSnapshot(); const snapshot = this.manager.getSnapshot();
@ -99,8 +175,6 @@ export class AppController {
void this.manager.getStartConflicts().then((conflicts) => { void this.manager.getStartConflicts().then((conflicts) => {
const hasConflicts = conflicts.length > 0; const hasConflicts = conflicts.length > 0;
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) { if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
// If the onState handler is already set (renderer connected), start immediately.
// Otherwise mark as pending so the onState setter triggers the start.
if (this.onStateHandler) { if (this.onStateHandler) {
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)"); logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`)); void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
@ -116,6 +190,34 @@ export class AppController {
} }
} }
// Early-warning for OOM on a long-running process. Measured against the V8
// heap_size_limit (the real ceiling at which the process is killed), NOT against
// heapTotal: V8 routinely runs near-full of its current heapTotal just before it
// grows it, so a heapUsed/heapTotal ratio would cry wolf and — since every WARN
// now feeds the error ring — crowd real failures out. Throttled to 1 warning per
// 5 min so a genuine sustained-pressure run does not spam the log/ring.
private checkMemoryPressure(): void {
try {
const mem = process.memoryUsage();
const heapLimit = v8.getHeapStatistics().heap_size_limit;
const ratio = heapLimit > 0 ? mem.heapUsed / heapLimit : 0;
if (ratio < 0.9) {
return;
}
const now = Date.now();
if (now - this.lastMemoryWarnAt < 5 * 60_000) {
return;
}
this.lastMemoryWarnAt = now;
const mb = (bytes: number): number => Math.round(bytes / 1048576);
logger.warn(
`Speicherdruck: heapUsed=${mb(mem.heapUsed)}MB von Limit ${mb(heapLimit)}MB ` +
`(${Math.round(ratio * 100)}%), heapTotal=${mb(mem.heapTotal)}MB, rss=${mb(mem.rss)}MB, external=${mb(mem.external)}MB`
);
} catch {
}
}
private hasAnyProviderToken(settings: AppSettings): boolean { private hasAnyProviderToken(settings: AppSettings): boolean {
return Boolean( return Boolean(
settings.token.trim() settings.token.trim()
@ -143,7 +245,6 @@ export class AppController {
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`)); void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
logger.info("Auto-Resume beim Start aktiviert"); logger.info("Auto-Resume beim Start aktiviert");
} else { } else {
// Trigger pending extractions without starting the session
this.manager.triggerIdleExtractions(); this.manager.triggerIdleExtractions();
} }
} }
@ -161,6 +262,68 @@ export class AppController {
return this.settings; return this.settings;
} }
public getAuditLogPath(): string | null {
return getAuditLogPath();
}
public getRenameLogPath(): string | null {
return getRenameLogPath();
}
public getDesktopRenameLogPath(): string | null {
return getDesktopRenameLogPath();
}
public getTraceLogPath(): string | null {
return getTraceLogPath();
}
public getTraceConfig(): SupportTraceConfig {
return getTraceConfig();
}
public rotateDebugToken(): { path: string; token: string } {
const rotated = rotateDebugToken(this.storagePaths.baseDir);
this.audit("WARN", "Debug-Token rotiert", { path: rotated.path });
return rotated;
}
public getDebugSetupCheck(): DebugSetupCheckResult {
return getDebugSetupCheck(this.storagePaths.baseDir);
}
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
logAuditEvent(level, message, fields);
logTraceEvent(level, "audit", message, fields);
}
public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig {
const next = setTraceEnabled(enabled, note, durationMs);
this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note });
return next;
}
// Carry the live, runtime-maintained usage/status counters onto a settings
// object about to be applied, so they are never rolled back to a stale snapshot.
// All-time totals take the max; daily/total usage and account statuses are taken
// live; per-key Debrid-Link usage is filtered to keys that still exist.
private overlayLiveUsageCounters(target: AppSettings): void {
const liveSettings = this.manager.getSettings();
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
}
public updateSettings(partial: Partial<AppSettings>): AppSettings { public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial); const sanitizedPatch = sanitizeSettingsPatch(partial);
const previousSettings = this.settings; const previousSettings = this.settings;
@ -173,12 +336,18 @@ export class AppController {
return previousSettings; return previousSettings;
} }
// Preserve the live totalDownloadedAllTime from the download manager this.overlayLiveUsageCounters(nextSettings);
const liveSettings = this.manager.getSettings(); const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
this.settings = nextSettings; this.settings = nextSettings;
if (retentionChanged) {
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
}
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);
this.audit("INFO", "Einstellungen aktualisiert", {
changedKeys: Object.keys(sanitizedPatch),
accountChanges: diffAccountSummary(previousSettings, this.settings)
});
if (previousSettings.rememberToken && !this.settings.rememberToken) { if (previousSettings.rememberToken && !this.settings.rememberToken) {
void this.realDebridWebFallback.clearSessions().catch((error) => { void this.realDebridWebFallback.clearSessions().catch((error) => {
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
@ -193,16 +362,49 @@ export class AppController {
return this.settings; return this.settings;
} }
public resetProviderDailyUsage(provider: DebridProvider): AppSettings {
const liveSettings = this.manager.getSettings();
const nextSettings = normalizeSettings({
...liveSettings,
...resetProviderDailyUsage(liveSettings, provider)
});
this.settings = nextSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider });
return this.settings;
}
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
const liveSettings = this.manager.getSettings();
const nextSettings = normalizeSettings({
...liveSettings,
...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId)
});
this.settings = nextSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId });
return this.settings;
}
public async openRealDebridLoginWindow(): Promise<void> { public async openRealDebridLoginWindow(): Promise<void> {
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet");
await this.realDebridWebFallback.openLoginWindow(); await this.realDebridWebFallback.openLoginWindow();
} }
public async openAllDebridLoginWindow(): Promise<void> { public async openAllDebridLoginWindow(): Promise<void> {
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
await this.allDebridWebFallback.openLoginWindow(); await this.allDebridWebFallback.openLoginWindow();
} }
public async importBestDebridCookies(filePath: string): Promise<number> { public async importBestDebridCookies(filePath: string): Promise<number> {
return this.bestDebridWebFallback.importCookiesFromFile(filePath); const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath);
this.audit("INFO", "BestDebrid Cookies importiert", {
filePath,
imported
});
return imported;
} }
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> { public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
@ -216,6 +418,31 @@ export class AppController {
return fetchAllDebridHostInfo(token, host); return fetchAllDebridHostInfo(token, host);
} }
public async getDebridLinkHostLimits(host = "rapidgator") {
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
}
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
const statuses = await checkAllDebridAccounts(this.settings);
this.manager.applyDebridAccountStatuses(statuses);
this.audit("INFO", "Debrid-Accounts geprueft", {
total: statuses.length,
valid: statuses.filter((s) => s.valid).length,
premium: statuses.filter((s) => s.isPremium).length
});
return statuses;
}
public async checkSingleMegaDebridAccount(login: string, password: string): Promise<DebridAccountStatus | null> {
const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0];
if (!entry) {
return null;
}
const status = await checkMegaDebridAccount(entry);
this.manager.applyDebridAccountStatuses([status]);
this.audit("INFO", "Mega-Debrid-Account einzeln geprueft", { valid: status.valid, premium: status.isPremium });
return status;
}
public async checkUpdates(): Promise<UpdateCheckResult> { public async checkUpdates(): Promise<UpdateCheckResult> {
const result = await checkGitHubUpdate(this.settings.updateRepo); const result = await checkGitHubUpdate(this.settings.updateRepo);
if (!result.error) { if (!result.error) {
@ -226,11 +453,10 @@ export class AppController {
} }
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> { public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
// Stop active downloads before installing. Extractions may continue briefly
// until prepareForShutdown() is called during app quit.
if (this.manager.isSessionRunning()) { if (this.manager.isSessionRunning()) {
this.manager.stop(); this.manager.stop({ parkForRestart: true });
} }
this.manager.persistNowSync();
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt; const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000 const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
@ -247,9 +473,17 @@ export class AppController {
public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } { public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } {
const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName); const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName);
if (parsed.length === 0) { if (parsed.length === 0) {
this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", {
hasPackageName: Boolean(payload.packageName)
});
return { addedPackages: 0, addedLinks: 0, invalidCount: 1 }; return { addedPackages: 0, addedLinks: 0, invalidCount: 1 };
} }
const result = this.manager.addPackages(parsed); const result = this.manager.addPackages(parsed);
this.audit("INFO", "Links hinzugefügt", {
addedPackages: result.addedPackages,
addedLinks: result.addedLinks,
requestedPackages: parsed.length
});
return { ...result, invalidCount: 0 }; return { ...result, invalidCount: 0 };
} }
@ -261,6 +495,11 @@ export class AppController {
...(pkg.fileNames ? { fileNames: pkg.fileNames } : {}) ...(pkg.fileNames ? { fileNames: pkg.fileNames } : {})
})); }));
const result = this.manager.addPackages(merged); const result = this.manager.addPackages(merged);
this.audit("INFO", "Container importiert", {
files: filePaths.length,
addedPackages: result.addedPackages,
addedLinks: result.addedLinks
});
return result; return result;
} }
@ -273,134 +512,274 @@ export class AppController {
} }
public clearAll(): void { public clearAll(): void {
this.audit("WARN", "Queue komplett geleert");
this.manager.clearAll(); this.manager.clearAll();
} }
public async start(): Promise<void> { public async start(): Promise<void> {
this.audit("INFO", "Session-Start ausgelöst");
await this.manager.start(); await this.manager.start();
} }
public async startPackages(packageIds: string[]): Promise<void> { public async startPackages(packageIds: string[]): Promise<void> {
this.audit("INFO", "Paket-Start ausgelöst", { packageIds });
await this.manager.startPackages(packageIds); await this.manager.startPackages(packageIds);
} }
public async startItems(itemIds: string[]): Promise<void> { public async startItems(itemIds: string[]): Promise<void> {
this.audit("INFO", "Item-Start ausgelöst", { itemIds });
await this.manager.startItems(itemIds); await this.manager.startItems(itemIds);
} }
public stop(): void { public stop(): void {
this.audit("INFO", "Session-Stopp ausgelöst");
this.manager.stop(); this.manager.stop();
} }
public togglePause(): boolean { public togglePause(): boolean {
return this.manager.togglePause(); const paused = this.manager.togglePause();
this.audit("INFO", "Pause umgeschaltet", { paused });
return paused;
} }
public retryExtraction(packageId: string): void { public retryExtraction(packageId: string): void {
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
this.manager.retryExtraction(packageId); this.manager.retryExtraction(packageId);
} }
public extractNow(packageId: string): void { public extractNow(packageId: string): void {
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId });
this.manager.extractNow(packageId); this.manager.extractNow(packageId);
} }
public resetPackage(packageId: string): void { public resetPackage(packageId: string): void {
this.audit("INFO", "Paket zurückgesetzt", { packageId });
this.manager.resetPackage(packageId); this.manager.resetPackage(packageId);
} }
public cancelPackage(packageId: string): void { public cancelPackage(packageId: string): void {
this.audit("WARN", "Paket abgebrochen", { packageId });
this.manager.cancelPackage(packageId); this.manager.cancelPackage(packageId);
} }
public renamePackage(packageId: string, newName: string): void { public renamePackage(packageId: string, newName: string): void {
this.audit("INFO", "Paket umbenannt", { packageId, newName });
this.manager.renamePackage(packageId, newName); this.manager.renamePackage(packageId, newName);
} }
public reorderPackages(packageIds: string[]): void { public reorderPackages(packageIds: string[]): void {
this.audit("INFO", "Paketreihenfolge geändert", { packageIds });
this.manager.reorderPackages(packageIds); this.manager.reorderPackages(packageIds);
} }
public removeItem(itemId: string): void { public removeItem(itemId: string): void {
this.audit("WARN", "Item entfernt", { itemId });
this.manager.removeItem(itemId); this.manager.removeItem(itemId);
} }
public togglePackage(packageId: string): void { public togglePackage(packageId: string): void {
this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId });
this.manager.togglePackage(packageId); this.manager.togglePackage(packageId);
} }
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
this.audit("INFO", "Paket-Auswahl exportiert", {
packageCount: selection.packageCount,
linkCount: selection.linkCount,
packageIds
});
return {
text: serializeLinkExportText(selection.packages),
defaultFileName: selection.defaultFileName,
packageCount: selection.packageCount,
linkCount: selection.linkCount
};
}
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
this.audit("INFO", "Item-Auswahl exportiert", {
packageCount: selection.packageCount,
linkCount: selection.linkCount,
itemIds
});
return {
text: serializeLinkExportText(selection.packages),
defaultFileName: selection.defaultFileName,
packageCount: selection.packageCount,
linkCount: selection.linkCount
};
}
public exportQueue(): string { public exportQueue(): string {
return this.manager.exportQueue(); return this.manager.exportQueue();
} }
public importQueue(json: string): { addedPackages: number; addedLinks: number } { public importQueue(json: string): { addedPackages: number; addedLinks: number } {
return this.manager.importQueue(json); const result = this.manager.importQueue(json);
this.audit("INFO", "Import-Datei verarbeitet", result);
return result;
} }
public getSessionStats(): SessionStats { public getSessionStats(): SessionStats {
return this.manager.getSessionStats(); return this.manager.getSessionStats();
} }
public exportBackup(): string { public resetSessionStats(): void {
const settings = { ...this.settings }; this.audit("INFO", "Session-Statistik zurückgesetzt");
const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey"]; this.manager.resetSessionStats();
for (const key of SENSITIVE_KEYS) {
const val = settings[key];
if (typeof val === "string" && val.length > 0) {
(settings as Record<string, unknown>)[key] = `***${val.slice(-4)}`;
}
}
const session = this.manager.getSession();
return JSON.stringify({ version: 1, settings, session }, null, 2);
} }
public importBackup(json: string): { restored: boolean; message: string } { public resetDownloadStats(): void {
this.manager.resetDownloadStats();
this.settings = this.manager.getSettings();
this.audit("INFO", "Download-Statistik zurückgesetzt");
}
public exportBackup(): Buffer {
const includeDownloads = Boolean(this.settings.backupIncludeDownloads);
const payloadObj = buildBackupPayload({
settings: { ...this.settings },
appVersion: APP_VERSION,
exportedAt: new Date().toISOString(),
session: this.manager.getSession(),
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode)
});
this.audit("INFO", "Backup exportiert", {
kind: payloadObj.kind,
historyEntries: payloadObj.history ? payloadObj.history.length : 0,
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
});
return encryptBackup(JSON.stringify(payloadObj));
}
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
this.audit("INFO", "Support-Bundle exportiert");
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
});
return {
buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
defaultFileName: getSupportBundleDefaultFileName()
};
}
public getSupportBundleDefaultFileName(): string {
return getSupportBundleDefaultFileName();
}
public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } {
let parsed: Record<string, unknown>; let parsed: Record<string, unknown>;
try { try {
const json = decryptBackup(data);
parsed = JSON.parse(json) as Record<string, unknown>; parsed = JSON.parse(json) as Record<string, unknown>;
} catch { } catch {
return { restored: false, message: "Ungültiges JSON" }; try {
const json = data.toString("utf8");
parsed = JSON.parse(json) as Record<string, unknown>;
} catch {
return { restored: false, relaunch: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
}
} }
if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) { const plan = planBackupImport(parsed);
return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" }; if (!plan.valid) {
return { restored: false, relaunch: false, message: plan.message };
} }
const hasSession = plan.restoreDownloads;
const importedSettings = parsed.settings as AppSettings; const importedSettings = parsed.settings as AppSettings;
const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey"]; const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword"
];
for (const key of SENSITIVE_KEYS) { for (const key of SENSITIVE_KEYS) {
const val = (importedSettings as Record<string, unknown>)[key]; const val = importedSettingsRecord[key];
if (typeof val === "string" && val.startsWith("***")) { if (typeof val === "string" && val.startsWith("***")) {
(importedSettings as Record<string, unknown>)[key] = (this.settings as Record<string, unknown>)[key]; importedSettingsRecord[key] = currentSettingsRecord[key];
} }
} }
const restoredSettings = normalizeSettings(importedSettings); const restoredSettings = normalizeSettings(importedSettings);
// Settings-only backup: keep the running queue AND the live counters untouched.
// Overlay the live usage/status counters so they don't roll back to the backup's
// (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
// policy still governs FUTURE completions through the normal path. Do NOT stop the
// manager, wipe the session, block persistence or relaunch.
if (!hasSession) {
this.overlayLiveUsageCounters(restoredSettings);
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings)
});
return {
restored: true,
relaunch: false,
message: "Einstellungen wiederhergestellt"
};
}
this.settings = restoredSettings; this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);
// Full stop including extraction abort — the old session is being replaced,
// so no extraction tasks from it should keep running.
this.manager.stop(); this.manager.stop();
this.manager.abortAllPostProcessing(); this.manager.abortAllPostProcessing();
// Cancel any deferred persist timer and queued async writes so the old
// in-memory session does not overwrite the restored session file on disk.
this.manager.clearPersistTimer(); this.manager.clearPersistTimer();
cancelPendingAsyncSaves(); cancelPendingAsyncSaves();
const restoredSession = normalizeLoadedSessionTransientFields( const restoredSession = normalizeLoadedSessionTransientFields(
normalizeLoadedSession(parsed.session) normalizeLoadedSession(parsed.session)
); );
saveSession(this.storagePaths, restoredSession); saveSession(this.storagePaths, restoredSession);
// Prevent prepareForShutdown from overwriting the restored session file
// with the old in-memory session when the app quits after backup restore. if (Array.isArray(parsed.history) && parsed.history.length > 0) {
const normalizedHistory = (parsed.history as unknown[])
.map((raw, idx) => normalizeHistoryEntry(raw, idx))
.filter((entry): entry is HistoryEntry => entry !== null);
if (normalizedHistory.length > 0) {
saveHistory(this.storagePaths, normalizedHistory);
logger.info(`Backup: ${normalizedHistory.length} History-Einträge wiederhergestellt`);
}
}
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
this.manager.skipShutdownPersist = true; this.manager.skipShutdownPersist = true;
// Block all persistence (including persistSoon from any IPC operations
// the user might trigger before restarting) to protect the restored backup.
this.manager.blockAllPersistence = true; this.manager.blockAllPersistence = true;
return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; logger.info("Backup wiederhergestellt — App startet automatisch neu");
this.audit("WARN", "Backup importiert", {
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
accountSummary: buildAccountSummary(this.settings)
});
return { restored: true, relaunch: true, message: "Backup wiederhergestellt App startet automatisch neu…" };
} }
public getSessionLogPath(): string | null { public getSessionLogPath(): string | null {
return getSessionLogPath(); return getSessionLogPath();
} }
public getPackageLogPath(packageId: string): string | null {
return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId);
}
public getItemLogPath(itemId: string): string | null {
return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId);
}
public shutdown(): void { public shutdown(): void {
if (this.runtimeStatsTimer) {
clearInterval(this.runtimeStatsTimer);
this.runtimeStatsTimer = null;
}
stopDebugServer(); stopDebugServer();
abortActiveUpdateDownload(); abortActiveUpdateDownload();
this.manager.prepareForShutdown(); this.manager.prepareForShutdown();
@ -409,34 +788,57 @@ export class AppController {
this.allDebridWebFallback.dispose(); this.allDebridWebFallback.dispose();
this.bestDebridWebFallback.dispose(); this.bestDebridWebFallback.dispose();
shutdownSessionLog(); shutdownSessionLog();
shutdownPackageLogs();
shutdownItemLogs();
shutdownRenameLog();
shutdownDesktopRenameLog();
this.audit("INFO", "App beendet");
shutdownTraceLog();
shutdownAccountRotationLog();
shutdownAuditLog();
if (this.settings.historyRetentionMode === "session") {
clearHistory(this.storagePaths);
}
logger.info("App beendet"); logger.info("App beendet");
} }
public getHistory(): HistoryEntry[] { public getHistory(): HistoryEntry[] {
return loadHistory(this.storagePaths); return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
} }
public clearHistory(): void { public clearHistory(): void {
this.audit("WARN", "Verlauf geleert");
clearHistory(this.storagePaths); clearHistory(this.storagePaths);
} }
public setPackagePriority(packageId: string, priority: PackagePriority): void { public setPackagePriority(packageId: string, priority: PackagePriority): void {
this.audit("INFO", "Paket-Priorität geändert", { packageId, priority });
this.manager.setPackagePriority(packageId, priority); this.manager.setPackagePriority(packageId, priority);
} }
public skipItems(itemIds: string[]): void { public skipItems(itemIds: string[]): void {
this.audit("INFO", "Items übersprungen", { itemIds });
this.manager.skipItems(itemIds); this.manager.skipItems(itemIds);
} }
public resetItems(itemIds: string[]): void { public resetItems(itemIds: string[]): void {
this.audit("INFO", "Items zurückgesetzt", { itemIds });
this.manager.resetItems(itemIds); this.manager.resetItems(itemIds);
} }
public removeHistoryEntry(entryId: string): void { public removeHistoryEntry(entryId: string): void {
this.audit("INFO", "Verlaufseintrag entfernt", { entryId });
removeHistoryEntry(this.storagePaths, entryId); removeHistoryEntry(this.storagePaths, entryId);
} }
public addToHistory(entry: HistoryEntry): void { public addToHistory(entry: HistoryEntry): void {
this.audit("INFO", "Verlaufseintrag hinzugefügt", {
id: entry.id,
name: entry.name,
status: entry.status,
provider: entry.provider,
fileCount: entry.fileCount
});
addHistoryEntry(this.storagePaths, entry); addHistoryEntry(this.storagePaths, entry);
} }
} }

119
src/main/audit-log.ts Normal file
View File

@ -0,0 +1,119 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
type AuditLevel = "INFO" | "WARN" | "ERROR";
const AUDIT_LOG_MAX_FILE_BYTES = Number(process.env.RD_AUDIT_LOG_MAX_BYTES || 10 * 1024 * 1024);
const AUDIT_LOG_RETENTION_DAYS = Number(process.env.RD_AUDIT_LOG_RETENTION_DAYS || 30);
let auditLogPath: string | null = null;
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function rotateIfNeeded(filePath: string): void {
try {
const stat = fs.statSync(filePath);
if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
}
fs.renameSync(filePath, backup);
} catch {
}
}
function cleanupOldBackup(filePath: string): void {
const backup = `${filePath}.old`;
try {
const stat = fs.statSync(backup);
const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (stat.mtimeMs < cutoff) {
fs.rmSync(backup, { force: true });
}
} catch {
}
}
export function initAuditLog(baseDir: string): void {
auditLogPath = path.join(baseDir, "audit.log");
try {
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true });
cleanupOldBackup(auditLogPath);
if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8");
}
rotateIfNeeded(auditLogPath);
if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8");
}
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8");
} catch {
auditLogPath = null;
}
}
export function logAuditEvent(level: AuditLevel, message: string, fields?: Record<string, unknown>): void {
if (!auditLogPath) {
return;
}
try {
rotateIfNeeded(auditLogPath);
if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8");
}
fs.appendFileSync(
auditLogPath,
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
"utf8"
);
} catch {
}
}
export function getAuditLogPath(): string | null {
if (!auditLogPath) {
return null;
}
return fs.existsSync(auditLogPath) ? auditLogPath : null;
}
export function shutdownAuditLog(): void {
if (!auditLogPath) {
return;
}
try {
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch {
}
auditLogPath = null;
}

View File

@ -1,66 +1,39 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
export const SENSITIVE_KEYS = [ const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
"token", const ALGORITHM = "aes-256-gcm";
"megaLogin", const IV_LENGTH = 12;
"megaPassword", const AUTH_TAG_LENGTH = 16;
"bestToken", const MAGIC = Buffer.from("MDD1");
"allDebridToken",
"archivePasswordList"
] as const;
export type SensitiveKey = (typeof SENSITIVE_KEYS)[number]; function deriveKey(): Buffer {
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
export interface EncryptedCredentials {
salt: string;
iv: string;
tag: string;
data: string;
} }
const PBKDF2_ITERATIONS = 100_000; export function encryptBackup(plaintext: string): Buffer {
const KEY_LENGTH = 32; // 256 bit const key = deriveKey();
const IV_LENGTH = 12; // 96 bit for GCM
const SALT_LENGTH = 16;
function deriveKey(username: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(username, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256");
}
export function encryptCredentials(
fields: Record<string, string>,
username: string
): EncryptedCredentials {
const salt = crypto.randomBytes(SALT_LENGTH);
const iv = crypto.randomBytes(IV_LENGTH); const iv = crypto.randomBytes(IV_LENGTH);
const key = deriveKey(username, salt); const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const plaintext = JSON.stringify(fields);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag(); const authTag = cipher.getAuthTag();
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
return {
salt: salt.toString("hex"),
iv: iv.toString("hex"),
tag: tag.toString("hex"),
data: encrypted.toString("hex")
};
} }
export function decryptCredentials( export function decryptBackup(data: Buffer): string {
encrypted: EncryptedCredentials, if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
username: string throw new Error("Backup-Datei zu kurz oder ungültig");
): Record<string, string> { }
const salt = Buffer.from(encrypted.salt, "hex"); const magic = data.subarray(0, MAGIC.length);
const iv = Buffer.from(encrypted.iv, "hex"); if (!magic.equals(MAGIC)) {
const tag = Buffer.from(encrypted.tag, "hex"); throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)");
const data = Buffer.from(encrypted.data, "hex"); }
const key = deriveKey(username, salt); const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LENGTH);
const authTag = data.subarray(MAGIC.length + IV_LENGTH, MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = data.subarray(MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); const key = deriveKey();
decipher.setAuthTag(tag); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]); decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return JSON.parse(decrypted.toString("utf8")) as Record<string, string>; return decrypted.toString("utf8");
} }

View File

@ -0,0 +1,77 @@
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
export type BackupKind = "full" | "settings-only";
export interface BackupPayload {
version: 2;
kind: BackupKind;
appVersion: string;
exportedAt: string;
settings: AppSettings;
session?: SessionState;
history?: HistoryEntry[];
}
export interface BuildBackupInput {
settings: AppSettings;
appVersion: string;
exportedAt: string;
/** Only bundled when includeDownloads is true. */
session: SessionState;
history: HistoryEntry[];
}
/**
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
* payload contains ONLY settings no session, no history. The download list is
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
* marker makes the import side unambiguous and survives hand-edited files.
*/
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
const base: BackupPayload = {
version: 2,
kind: includeDownloads ? "full" : "settings-only",
appVersion: input.appVersion,
exportedAt: input.exportedAt,
settings: input.settings
};
if (includeDownloads) {
base.session = input.session;
base.history = input.history;
}
return base;
}
export interface ImportPlan {
valid: boolean;
/** Restore the download list (session + history) and relaunch. */
restoreDownloads: boolean;
message: string;
}
/**
* Decide how to apply an imported backup based on what the FILE physically
* contains NOT the local toggle. A backup without a session restores settings
* only (no queue wipe, no relaunch); a full backup (with session) restores the
* queue too. This way an old full backup still restores fully even if the local
* toggle is currently off, and a settings-only backup never disturbs a running
* queue.
*/
export function planBackupImport(parsed: unknown): ImportPlan {
if (!parsed || typeof parsed !== "object") {
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
}
const record = parsed as Record<string, unknown>;
if (!record.settings || typeof record.settings !== "object") {
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
}
const hasSession = Boolean(record.session) && typeof record.session === "object";
return {
valid: true,
restoreDownloads: hasSession,
message: hasSession
? "Backup wiederhergestellt App startet automatisch neu…"
: "Einstellungen wiederhergestellt"
};
}

View File

@ -1,5 +1,5 @@
import fs from "node:fs"; import fs from "node:fs";
import { session } from "electron"; import { session, type Session } from "electron";
import { UnrestrictedLink } from "./realdebrid"; import { UnrestrictedLink } from "./realdebrid";
import { filenameFromUrl, sleep } from "./utils"; import { filenameFromUrl, sleep } from "./utils";
import { logger } from "./logger"; import { logger } from "./logger";
@ -43,6 +43,7 @@ function parseJson(text: string): Record<string, unknown> | null {
interface NetscapeCookie { interface NetscapeCookie {
domain: string; domain: string;
includeSubdomains: boolean;
httpOnly: boolean; httpOnly: boolean;
path: string; path: string;
secure: boolean; secure: boolean;
@ -51,20 +52,56 @@ interface NetscapeCookie {
value: string; value: string;
} }
function normalizeCookieDomain(domain: string): string {
return String(domain || "").trim().replace(/^\./, "").toLowerCase();
}
function dedupeCookies(cookies: NetscapeCookie[]): NetscapeCookie[] {
const deduped = new Map<string, NetscapeCookie>();
for (const cookie of cookies) {
const key = `${normalizeCookieDomain(cookie.domain)}\t${cookie.path}\t${cookie.name}`;
const existing = deduped.get(key);
if (!existing) {
deduped.set(key, cookie);
continue;
}
if (cookie.httpOnly && !existing.httpOnly) {
deduped.set(key, cookie);
continue;
}
if (cookie.expirationDate > existing.expirationDate) {
deduped.set(key, cookie);
}
}
return [...deduped.values()];
}
function parseNetscapeCookieFile(text: string): NetscapeCookie[] { function parseNetscapeCookieFile(text: string): NetscapeCookie[] {
const cookies: NetscapeCookie[] = []; const cookies: NetscapeCookie[] = [];
for (const line of text.split(/\r?\n/)) { for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) { if (!trimmed) {
continue; continue;
} }
const parts = trimmed.split("\t");
let normalizedLine = trimmed;
let httpOnly = false;
if (normalizedLine.startsWith("#HttpOnly_")) {
httpOnly = true;
normalizedLine = normalizedLine.slice("#HttpOnly_".length);
} else if (normalizedLine.startsWith("#")) {
continue;
}
const parts = normalizedLine.split("\t");
if (parts.length < 7) { if (parts.length < 7) {
continue; continue;
} }
cookies.push({ cookies.push({
domain: parts[0], domain: parts[0],
httpOnly: parts[1].toUpperCase() === "TRUE", includeSubdomains: parts[1].toUpperCase() === "TRUE",
httpOnly,
path: parts[2], path: parts[2],
secure: parts[3].toUpperCase() === "TRUE", secure: parts[3].toUpperCase() === "TRUE",
expirationDate: Number(parts[4]) || 0, expirationDate: Number(parts[4]) || 0,
@ -75,6 +112,25 @@ function parseNetscapeCookieFile(text: string): NetscapeCookie[] {
return cookies; return cookies;
} }
function isLikelyBestDebridAuthCookie(name: string): boolean {
const normalized = String(name || "").trim();
return /phpsessid|sess(?:ion)?|auth|login/i.test(normalized);
}
function isAuthenticatedBestDebridHtml(html: string): boolean {
const normalized = String(html || "");
if (!normalized) {
return false;
}
return /href\s*=\s*["']logout["']/i.test(normalized)
|| /title\s*=\s*["'][^"']*premium until/i.test(normalized)
|| (/user-profile-image/i.test(normalized) && !/>\s*guest\s*</i.test(normalized));
}
function looksLikeGuestAccessMessage(message: string): boolean {
return /free users are not allowed|purchase a premium plan|premium required/i.test(String(message || ""));
}
export class BestDebridWebFallback { export class BestDebridWebFallback {
private queue: Promise<unknown> = Promise.resolve(); private queue: Promise<unknown> = Promise.resolve();
@ -102,6 +158,7 @@ export class BestDebridWebFallback {
if (result.kind === "success") { if (result.kind === "success") {
return result.value; return result.value;
} }
this.cookiesImported = false;
throw new Error("BestDebrid: Nicht eingeloggt. Bitte neue Cookie-Datei importieren."); throw new Error("BestDebrid: Nicht eingeloggt. Bitte neue Cookie-Datei importieren.");
}, overallSignal); }, overallSignal);
} }
@ -109,29 +166,36 @@ export class BestDebridWebFallback {
public async importCookiesFromFile(filePath: string): Promise<number> { public async importCookiesFromFile(filePath: string): Promise<number> {
const text = fs.readFileSync(filePath, "utf-8"); const text = fs.readFileSync(filePath, "utf-8");
const cookies = parseNetscapeCookieFile(text); const cookies = parseNetscapeCookieFile(text);
const bestDebridCookies = cookies.filter((c) => const bestDebridCookies = dedupeCookies(cookies.filter((c) =>
c.domain.includes("bestdebrid.com") c.domain.includes("bestdebrid.com")
); ));
if (bestDebridCookies.length === 0) { if (bestDebridCookies.length === 0) {
throw new Error("Keine BestDebrid-Cookies in der Datei gefunden"); throw new Error("Keine BestDebrid-Cookies in der Datei gefunden");
} }
if (!bestDebridCookies.some((cookie) => isLikelyBestDebridAuthCookie(cookie.name))) {
throw new Error("BestDebrid: Cookie-Datei enthält keinen Login-Cookie. Bitte nach dem Login erneut exportieren.");
}
const currentSession = session.fromPartition(this.getPartition()); const currentSession = session.fromPartition(this.getPartition());
currentSession.setUserAgent(BESTDEBRID_USER_AGENT); await this.clearPartitionState(currentSession);
for (const cookie of bestDebridCookies) { for (const cookie of bestDebridCookies) {
const url = `https://${cookie.domain.replace(/^\./, "")}${cookie.path}`; const url = `https://${cookie.domain.replace(/^\./, "")}${cookie.path}`;
await currentSession.cookies.set({ const details: Parameters<typeof currentSession.cookies.set>[0] = {
url, url,
name: cookie.name, name: cookie.name,
value: cookie.value, value: cookie.value,
domain: cookie.domain,
path: cookie.path, path: cookie.path,
secure: cookie.secure, secure: cookie.secure,
httpOnly: cookie.httpOnly, httpOnly: cookie.httpOnly,
expirationDate: cookie.expirationDate > 0 ? cookie.expirationDate : undefined expirationDate: cookie.expirationDate > 0 ? cookie.expirationDate : undefined
}); };
if (cookie.includeSubdomains || cookie.domain.startsWith(".")) {
details.domain = cookie.domain;
}
await currentSession.cookies.set(details);
} }
this.cookiesImported = true; this.cookiesImported = true;
@ -148,18 +212,15 @@ export class BestDebridWebFallback {
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
}); });
} catch { } catch {
// ignore
} }
try { try {
await currentSession.clearCache(); await currentSession.clearCache();
} catch { } catch {
// ignore
} }
} }
} }
public dispose(): void { public dispose(): void {
// nothing to clean up
} }
private getPartition(): string { private getPartition(): string {
@ -217,6 +278,12 @@ export class BestDebridWebFallback {
if (/login|log in|sign in|not logged|session|auth/i.test(message)) { if (/login|log in|sign in|not logged|session|auth/i.test(message)) {
return { kind: "login_required" }; return { kind: "login_required" };
} }
if (looksLikeGuestAccessMessage(message)) {
const authenticated = await this.isAuthenticated(currentSession, signal).catch(() => null);
if (authenticated === false) {
return { kind: "login_required" };
}
}
throw new Error(`BestDebrid Web: ${message || "Unbekannter Fehler"}`); throw new Error(`BestDebrid Web: ${message || "Unbekannter Fehler"}`);
} }
@ -248,4 +315,32 @@ export class BestDebridWebFallback {
} }
}; };
} }
private async isAuthenticated(currentSession: Session, signal?: AbortSignal): Promise<boolean> {
throwIfAborted(signal);
const response = await currentSession.fetch(BESTDEBRID_DOWNLOADER_URL, {
method: "GET",
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Referer: BESTDEBRID_BASE_URL,
"User-Agent": BESTDEBRID_USER_AGENT
},
signal: withTimeoutSignal(signal, 20_000)
});
if (!response.ok) {
return false;
}
const text = await response.text();
return isAuthenticatedBestDebridHtml(text);
}
private async clearPartitionState(currentSession: Session): Promise<void> {
await currentSession.clearStorageData({
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
});
try {
await currentSession.clearCache();
} catch {
}
}
} }

View File

@ -39,7 +39,6 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
fs.rmSync(full, { force: true }); fs.rmSync(full, { force: true });
removed += 1; removed += 1;
} catch { } catch {
// ignore
} }
} }
} }
@ -47,7 +46,10 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
return removed; return removed;
} }
export async function cleanupCancelledPackageArtifactsAsync(packageDir: string): Promise<number> { export async function cleanupCancelledPackageArtifactsAsync(
packageDir: string,
options: { shouldAbort?: () => boolean } = {}
): Promise<number> {
try { try {
await fs.promises.access(packageDir, fs.constants.F_OK); await fs.promises.access(packageDir, fs.constants.F_OK);
} catch { } catch {
@ -58,6 +60,9 @@ export async function cleanupCancelledPackageArtifactsAsync(packageDir: string):
let touched = 0; let touched = 0;
const stack = [packageDir]; const stack = [packageDir];
while (stack.length > 0) { while (stack.length > 0) {
if (options.shouldAbort?.()) {
return removed;
}
const current = stack.pop() as string; const current = stack.pop() as string;
let entries: fs.Dirent[] = []; let entries: fs.Dirent[] = [];
try { try {
@ -67,6 +72,9 @@ export async function cleanupCancelledPackageArtifactsAsync(packageDir: string):
} }
for (const entry of entries) { for (const entry of entries) {
if (options.shouldAbort?.()) {
return removed;
}
const full = path.join(current, entry.name); const full = path.join(current, entry.name);
if (entry.isDirectory() && !entry.isSymbolicLink()) { if (entry.isDirectory() && !entry.isSymbolicLink()) {
stack.push(full); stack.push(full);
@ -75,7 +83,6 @@ export async function cleanupCancelledPackageArtifactsAsync(packageDir: string):
await fs.promises.rm(full, { force: true }); await fs.promises.rm(full, { force: true });
removed += 1; removed += 1;
} catch { } catch {
// ignore
} }
} }
@ -88,7 +95,10 @@ export async function cleanupCancelledPackageArtifactsAsync(packageDir: string):
return removed; return removed;
} }
export async function removeDownloadLinkArtifacts(extractDir: string): Promise<number> { export async function removeDownloadLinkArtifacts(
extractDir: string,
options: { shouldAbort?: () => boolean } = {}
): Promise<number> {
try { try {
await fs.promises.access(extractDir); await fs.promises.access(extractDir);
} catch { } catch {
@ -97,10 +107,16 @@ export async function removeDownloadLinkArtifacts(extractDir: string): Promise<n
let removed = 0; let removed = 0;
const stack = [extractDir]; const stack = [extractDir];
while (stack.length > 0) { while (stack.length > 0) {
if (options.shouldAbort?.()) {
return removed;
}
const current = stack.pop() as string; const current = stack.pop() as string;
let entries: fs.Dirent[] = []; let entries: fs.Dirent[] = [];
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; } try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
for (const entry of entries) { for (const entry of entries) {
if (options.shouldAbort?.()) {
return removed;
}
const full = path.join(current, entry.name); const full = path.join(current, entry.name);
if (entry.isDirectory() && !entry.isSymbolicLink()) { if (entry.isDirectory() && !entry.isSymbolicLink()) {
stack.push(full); stack.push(full);
@ -132,7 +148,6 @@ export async function removeDownloadLinkArtifacts(extractDir: string): Promise<n
await fs.promises.rm(full, { force: true }); await fs.promises.rm(full, { force: true });
removed += 1; removed += 1;
} catch { } catch {
// ignore
} }
} }
} }
@ -140,7 +155,10 @@ export async function removeDownloadLinkArtifacts(extractDir: string): Promise<n
return removed; return removed;
} }
export async function removeSampleArtifacts(extractDir: string): Promise<{ files: number; dirs: number }> { export async function removeSampleArtifacts(
extractDir: string,
options: { shouldAbort?: () => boolean } = {}
): Promise<{ files: number; dirs: number }> {
try { try {
await fs.promises.access(extractDir); await fs.promises.access(extractDir);
} catch { } catch {
@ -184,10 +202,16 @@ export async function removeSampleArtifacts(extractDir: string): Promise<{ files
}; };
while (stack.length > 0) { while (stack.length > 0) {
if (options.shouldAbort?.()) {
return { files: removedFiles, dirs: removedDirs };
}
const current = stack.pop() as string; const current = stack.pop() as string;
let entries: fs.Dirent[] = []; let entries: fs.Dirent[] = [];
try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; } try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } catch { continue; }
for (const entry of entries) { for (const entry of entries) {
if (options.shouldAbort?.()) {
return { files: removedFiles, dirs: removedDirs };
}
const full = path.join(current, entry.name); const full = path.join(current, entry.name);
if (entry.isDirectory() || entry.isSymbolicLink()) { if (entry.isDirectory() || entry.isSymbolicLink()) {
const base = entry.name.toLowerCase(); const base = entry.name.toLowerCase();
@ -213,7 +237,6 @@ export async function removeSampleArtifacts(extractDir: string): Promise<{ files
await fs.promises.rm(full, { force: true }); await fs.promises.rm(full, { force: true });
removedFiles += 1; removedFiles += 1;
} catch { } catch {
// ignore
} }
} }
} }
@ -221,6 +244,9 @@ export async function removeSampleArtifacts(extractDir: string): Promise<{ files
sampleDirs.sort((a, b) => b.length - a.length); sampleDirs.sort((a, b) => b.length - a.length);
for (const dir of sampleDirs) { for (const dir of sampleDirs) {
if (options.shouldAbort?.()) {
return { files: removedFiles, dirs: removedDirs };
}
try { try {
const stat = await fs.promises.lstat(dir); const stat = await fs.promises.lstat(dir);
if (stat.isSymbolicLink()) { if (stat.isSymbolicLink()) {
@ -233,7 +259,6 @@ export async function removeSampleArtifacts(extractDir: string): Promise<{ files
removedFiles += filesInDir; removedFiles += filesInDir;
removedDirs += 1; removedDirs += 1;
} catch { } catch {
// ignore
} }
} }

View File

@ -1,6 +1,7 @@
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import { AppSettings } from "../shared/types"; import { AppSettings } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import packageJson from "../../package.json"; import packageJson from "../../package.json";
export const APP_NAME = "Multi Debrid Downloader"; export const APP_NAME = "Multi Debrid Downloader";
@ -16,11 +17,12 @@ export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
export const REQUEST_RETRIES = 3; export const REQUEST_RETRIES = 3;
export const CHUNK_SIZE = 512 * 1024; export const CHUNK_SIZE = 512 * 1024;
export const WRITE_BUFFER_SIZE = 512 * 1024; // 512 KB write buffer (JDownloader: 500 KB) export const WRITE_BUFFER_SIZE = 512 * 1024;
export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout export const WRITE_FLUSH_TIMEOUT_MS = 2000;
export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment export const ALLOCATION_UNIT_SIZE = 4096;
export const STREAM_HIGH_WATER_MARK = 512 * 1024; // 512 KB stream buffer — lower than before (2 MB) so backpressure triggers sooner when disk is slow export const STREAM_HIGH_WATER_MARK = 512 * 1024;
export const DISK_BUSY_THRESHOLD_MS = 300; // Show "Warte auf Festplatte" if writableLength > 0 for this long export const DISK_BUSY_THRESHOLD_MS = 300;
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500;
export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]); export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]);
export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]); export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]);
@ -44,6 +46,9 @@ export function defaultSettings(): AppSettings {
realDebridUseWebLogin: false, realDebridUseWebLogin: false,
megaLogin: "", megaLogin: "",
megaPassword: "", megaPassword: "",
megaCredentials: "",
megaDebridApiEnabled: false,
megaDebridWebEnabled: false,
megaDebridPreferApi: true, megaDebridPreferApi: true,
bestToken: "", bestToken: "",
bestDebridUseWebLogin: false, bestDebridUseWebLogin: false,
@ -52,16 +57,23 @@ export function defaultSettings(): AppSettings {
ddownloadLogin: "", ddownloadLogin: "",
ddownloadPassword: "", ddownloadPassword: "",
oneFichierApiKey: "", oneFichierApiKey: "",
debridLinkApiKeys: "",
debridLinkDisabledKeyIds: [],
linkSnappyLogin: "",
linkSnappyPassword: "",
archivePasswordList: "", archivePasswordList: "",
rememberToken: true, rememberToken: true,
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
providerPrimary: "realdebrid", providerPrimary: "realdebrid",
providerSecondary: "megadebrid", providerSecondary: "megadebrid-api",
providerTertiary: "bestdebrid", providerTertiary: "bestdebrid",
autoProviderFallback: true, autoProviderFallback: true,
outputDir: baseDir, outputDir: baseDir,
packageName: "", packageName: "",
autoExtract: true, autoExtract: true,
autoRename4sf4sj: false, autoRename4sf4sj: false,
keepGermanAudioOnly: false,
germanAudioMode: "tag",
extractDir: path.join(baseDir, "_entpackt"), extractDir: path.join(baseDir, "_entpackt"),
collectMkvToLibrary: false, collectMkvToLibrary: false,
mkvLibraryDir: path.join(baseDir, "_mkv"), mkvLibraryDir: path.join(baseDir, "_mkv"),
@ -88,12 +100,34 @@ export function defaultSettings(): AppSettings {
minimizeToTray: false, minimizeToTray: false,
theme: "dark" as const, theme: "dark" as const,
collapseNewPackages: true, collapseNewPackages: true,
historyRetentionMode: "permanent",
accountListShowDetailedDebridLinkKeys: false,
autoSortPackagesByProgress: true,
autoSkipExtracted: false, autoSkipExtracted: false,
hideExtractedItems: true,
confirmDeleteSelection: true, confirmDeleteSelection: true,
backupIncludeDownloads: false,
totalDownloadedAllTime: 0, totalDownloadedAllTime: 0,
totalCompletedFilesAllTime: 0,
totalRuntimeAllTimeMs: 0,
bandwidthSchedules: [], bandwidthSchedules: [],
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
extractCpuPriority: "high", extractCpuPriority: "high",
autoExtractWhenStopped: true autoExtractWhenStopped: true,
disabledProviders: [],
hosterRouting: {},
providerDailyLimitBytes: {},
providerDailyUsageBytes: {},
providerTotalUsageBytes: {},
debridLinkApiKeyDailyLimitBytes: {},
debridLinkApiKeyDailyUsageBytes: {},
debridLinkApiKeyTotalUsageBytes: {},
megaDebridDisabledAccountIds: [],
megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {},
debridAccountStatuses: {},
providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0
}; };
} }

View File

@ -113,13 +113,11 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
try { try {
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim(); fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
} catch { } catch {
// ignore
} }
} }
links.push(url); links.push(url);
fileNames.push(sanitizeFilename(fileName)); fileNames.push(sanitizeFilename(fileName));
} catch { } catch {
// skip broken entries
} }
} }
@ -132,7 +130,6 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
links.push(url); links.push(url);
} }
} catch { } catch {
// skip broken entries
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,105 @@
import http from "node:http"; import http from "node:http";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto";
import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { logger, getLogFilePath } from "./logger"; import { logger, getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { getRenameLogPath } from "./rename-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
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";
const DEFAULT_PORT = 9868; const DEFAULT_PORT = 9868;
const DEFAULT_HOST = "127.0.0.1";
const MAX_LOG_LINES = 10000; const MAX_LOG_LINES = 10000;
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
type DebugEndpointDescriptor = {
method: "GET";
path: string;
queryExample?: string;
description: string;
};
const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/health", description: "Basic health, uptime, and memory information." },
{ method: "GET", path: "/meta", description: "Lists runtime metadata and all available endpoints." },
{ method: "GET", path: "/debug/setup", description: "Checks whether the local debug setup is configured for support." },
{ method: "GET", path: "/self-check", description: "Extended support self-check with disk space, log sizes, and support bundle estimate." },
{ method: "GET", path: "/host/diagnostics", description: "Returns Windows host crash and dump diagnostics." },
{ method: "GET", path: "/log", queryExample: "lines=100&grep=keyword", description: "Legacy alias for the main application log tail." },
{ method: "GET", path: "/logs/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." },
{ method: "GET", path: "/logs/audit", queryExample: "lines=100&grep=keyword", description: "Reads the audit log for support-relevant UI and admin actions." },
{ 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/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
{ 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: "/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: "/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: "/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: "/status", description: "Returns a live high-level status overview." },
{ method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." },
{ method: "GET", path: "/items", queryExample: "status=downloading&package=Release", description: "Lists items and supports status/package filters." },
{ method: "GET", path: "/session", queryExample: "package=Release", description: "Returns session-wide or package-scoped item state." },
{ method: "GET", path: "/support/bundle", description: "Downloads a ZIP support bundle with logs, diagnostics, and redacted state." },
{ method: "GET", path: "/diagnostics", queryExample: "package=Release&lines=150", description: "Returns a combined support snapshot with logs, status, settings, accounts, stats, history, and host diagnostics." }
];
let server: http.Server | null = null; let server: http.Server | null = null;
let manager: DownloadManager | null = null; let manager: DownloadManager | null = null;
let authToken = ""; let authToken = "";
let bindHost = DEFAULT_HOST;
let bindPort = DEFAULT_PORT;
let runtimeBaseDir = "";
function getStoragePaths() {
return createStoragePaths(runtimeBaseDir);
}
function readSupportSettings() {
return loadSettings(getStoragePaths());
}
function readSupportHistory() {
return loadHistory(getStoragePaths());
}
function extractDebugClientIp(req: http.IncomingMessage): string {
const forwarded = req.headers["x-forwarded-for"];
const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const forwardedIp = String(forwardedValue || "").split(",")[0]?.trim();
if (forwardedIp) {
return forwardedIp;
}
const realIp = String(req.headers["x-real-ip"] || "").trim();
if (realIp) {
return realIp;
}
const remote = String(req.socket.remoteAddress || req.socket.address()?.address || "").trim();
return remote.replace(/^::ffff:/i, "");
}
function getAiManifestPath(baseDir: string = runtimeBaseDir): string {
return path.join(baseDir, AI_MANIFEST_FILE);
}
function getDebugTokenPath(baseDir: string = runtimeBaseDir): string {
return path.join(baseDir, "debug_token.txt");
}
function loadToken(baseDir: string): string { function loadToken(baseDir: string): string {
const tokenPath = path.join(baseDir, "debug_token.txt"); const tokenPath = path.join(baseDir, "debug_token.txt");
@ -28,11 +118,28 @@ function getPort(baseDir: string): number {
return n; return n;
} }
} catch { } catch {
// ignore
} }
return DEFAULT_PORT; return DEFAULT_PORT;
} }
function getHost(baseDir: string): string {
const hostPath = path.join(baseDir, "debug_host.txt");
try {
const raw = fs.readFileSync(hostPath, "utf8").trim();
if (!raw) {
return DEFAULT_HOST;
}
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
return raw;
}
if (/^[a-z0-9.-]+$/i.test(raw)) {
return raw;
}
} catch {
}
return DEFAULT_HOST;
}
function checkAuth(req: http.IncomingMessage): boolean { function checkAuth(req: http.IncomingMessage): boolean {
if (!authToken) { if (!authToken) {
return false; return false;
@ -55,10 +162,37 @@ function jsonResponse(res: http.ServerResponse, status: number, data: unknown):
res.end(body); res.end(body);
} }
function readLogTail(lines: number): string[] { function binaryResponse(
const logPath = getLogFilePath(); res: http.ServerResponse,
status: number,
body: Buffer,
contentType: string,
fileName?: string
): void {
res.writeHead(status, {
"Content-Type": contentType,
"Content-Length": String(body.length),
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {})
});
res.end(body);
}
function normalizeLinesParam(rawValue: string | null, fallback: number): number {
const parsed = Number(rawValue || String(fallback));
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.max(1, Math.min(Math.floor(parsed), MAX_LOG_LINES));
}
function readLogTailFromFile(filePath: string | null, lines: number): string[] {
if (!filePath) {
return ["(Log-Datei nicht gefunden)"];
}
try { try {
const content = fs.readFileSync(logPath, "utf8"); const content = fs.readFileSync(filePath, "utf8");
const allLines = content.split("\n").filter((l) => l.trim().length > 0); const allLines = content.split("\n").filter((l) => l.trim().length > 0);
return allLines.slice(-Math.min(lines, MAX_LOG_LINES)); return allLines.slice(-Math.min(lines, MAX_LOG_LINES));
} catch { } catch {
@ -66,98 +200,572 @@ function readLogTail(lines: number): string[] {
} }
} }
function filterLines(lines: string[], grep: string): string[] {
const pattern = String(grep || "").trim().toLowerCase();
if (!pattern) {
return lines;
}
return lines.filter((line) => line.toLowerCase().includes(pattern));
}
function toBooleanQuery(value: string | null): boolean | null {
if (value === null) {
return null;
}
if (/^(1|true|yes|on)$/i.test(value)) {
return true;
}
if (/^(0|false|no|off)$/i.test(value)) {
return false;
}
return null;
}
function sanitizeRequestUrlForTrace(rawUrl: string): string {
try {
const url = new URL(rawUrl || "/", "http://localhost");
if (url.searchParams.has("token")) {
url.searchParams.set("token", "***");
}
return `${url.pathname}${url.search}`;
} catch {
return String(rawUrl || "/");
}
}
function formatEndpointSummary(endpoint: DebugEndpointDescriptor): string {
return `${endpoint.method} ${endpoint.path}${endpoint.queryExample ? `?${endpoint.queryExample}` : ""}`;
}
function getEndpointSummaries(): string[] {
return DEBUG_ENDPOINTS.map((endpoint) => formatEndpointSummary(endpoint));
}
function buildAiManifest(baseDir: string): Record<string, unknown> {
const remoteHostHint = bindHost === "0.0.0.0"
? "Use the server IP or DNS name for remote access. Ask the user only for that host value if it is unknown."
: "If remote access is required and the bind host is local-only, switch debug_host.txt to 0.0.0.0 and reopen the firewall.";
return {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
appVersion: APP_VERSION,
runtimeBaseDir: baseDir,
purpose: "Machine-readable support manifest for AI tools and remote troubleshooting.",
quickstart: [
"Read debug_token.txt and debug_port.txt from this runtime folder.",
"If remote access is needed, ask the user only for the server IP or DNS name.",
"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 /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.",
"If a full handoff is needed, download /support/bundle as a ZIP."
],
auth: {
required: true,
methods: [
"Authorization: Bearer <token>",
"?token=<token>"
],
tokenFile: path.join(baseDir, "debug_token.txt")
},
runtimeFiles: {
hostFile: path.join(baseDir, "debug_host.txt"),
portFile: path.join(baseDir, "debug_port.txt"),
tokenFile: path.join(baseDir, "debug_token.txt"),
mainLogFile: getLogFilePath(),
auditLogFile: getAuditLogPath(),
renameLogFile: getRenameLogPath(),
traceLogFile: getTraceLogPath(),
traceConfigFile: getTraceConfigPath(),
sessionLogFile: getSessionLogPath(),
packageLogDir: path.join(baseDir, "package-logs"),
itemLogDir: path.join(baseDir, "item-logs"),
settingsFile: path.join(baseDir, "rd_downloader_config.json"),
sessionFile: path.join(baseDir, "rd_session_state.json"),
historyFile: path.join(baseDir, "rd_history.json")
},
debugServer: {
enabled: Boolean(authToken),
host: bindHost,
port: bindPort,
localBaseUrl: `http://127.0.0.1:${bindPort}`,
remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}`,
remoteHostHint
},
setupCheckEndpoint: "/debug/setup",
selfCheckEndpoint: "/self-check",
askUserFor: [
"Server IP or DNS name, if remote access is required and not already known."
],
endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({
...endpoint,
summary: formatEndpointSummary(endpoint)
}))
};
}
function writeAiManifest(baseDir: string): void {
try {
fs.writeFileSync(getAiManifestPath(baseDir), JSON.stringify(buildAiManifest(baseDir), null, 2), "utf8");
} catch (error) {
logger.warn(`Debug-Server: KI-Support-Datei konnte nicht geschrieben werden: ${String(error)}`);
}
}
export function rotateDebugToken(baseDir: string = runtimeBaseDir): { path: string; token: string } {
const token = crypto.randomBytes(24).toString("hex");
const tokenPath = getDebugTokenPath(baseDir);
fs.writeFileSync(tokenPath, `${token}\n`, "utf8");
if (baseDir === runtimeBaseDir) {
authToken = token;
writeAiManifest(baseDir);
}
logger.info(`Debug-Server Token rotiert: ${tokenPath}`);
logTraceEvent("INFO", "support", "Debug-Token rotiert", { tokenPath });
return { path: tokenPath, token };
}
function summarizeItem(item: DownloadItem): Record<string, unknown> {
return {
id: item.id,
packageId: item.packageId,
fileName: item.fileName,
status: item.status,
fullStatus: item.fullStatus,
provider: item.provider,
providerLabel: item.providerLabel || "",
progress: item.progressPercent,
speedMBs: +(item.speedBps / 1024 / 1024).toFixed(2),
downloadedMB: +(item.downloadedBytes / 1024 / 1024).toFixed(1),
totalMB: item.totalBytes ? +(item.totalBytes / 1024 / 1024).toFixed(1) : null,
retries: item.retries,
lastError: item.lastError,
targetPath: item.targetPath,
updatedAt: item.updatedAt
};
}
function summarizePackage(snapshot: UiSnapshot, pkg: PackageEntry, includeItems: boolean): Record<string, unknown> {
const ids = new Set(pkg.itemIds);
const packageItems = Object.values(snapshot.session.items).filter((item) => ids.has(item.id));
const byStatus: Record<string, number> = {};
for (const item of packageItems) {
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
}
return {
id: pkg.id,
name: pkg.name,
status: pkg.status,
enabled: pkg.enabled,
cancelled: pkg.cancelled,
outputDir: pkg.outputDir,
extractDir: pkg.extractDir,
postProcessLabel: pkg.postProcessLabel || "",
itemCount: pkg.itemIds.length,
itemCounts: byStatus,
updatedAt: pkg.updatedAt,
items: includeItems ? packageItems.map((item) => summarizeItem(item)) : undefined
};
}
function findPackage(snapshot: UiSnapshot, query: string): PackageEntry | null {
const needle = String(query || "").trim().toLowerCase();
if (!needle) {
return null;
}
return Object.values(snapshot.session.packages).find((pkg) =>
pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle)
) || null;
}
function findItem(snapshot: UiSnapshot, query: string): DownloadItem | null {
const needle = String(query || "").trim().toLowerCase();
if (!needle) {
return null;
}
return Object.values(snapshot.session.items).find((item) =>
item.id.toLowerCase() === needle || item.fileName.toLowerCase().includes(needle)
) || null;
}
function getPackageLogPathForQuery(snapshot: UiSnapshot, query: string): { pkg: PackageEntry | null; logPath: string | null } {
const pkg = findPackage(snapshot, query);
if (pkg) {
const livePath = manager?.getPackageLogPath(pkg.id) || null;
return { pkg, logPath: livePath || getPersistedPackageLogPath(pkg.id) };
}
const directPath = getPersistedPackageLogPath(String(query || "").trim());
return { pkg: null, logPath: directPath };
}
function getItemLogPathForQuery(snapshot: UiSnapshot, query: string): { item: DownloadItem | null; logPath: string | null } {
const item = findItem(snapshot, query);
if (item) {
const livePath = manager?.getItemLogPath(item.id) || null;
return { item, logPath: livePath || getPersistedItemLogPath(item.id) };
}
const directPath = getPersistedItemLogPath(String(query || "").trim());
return { item: null, logPath: directPath };
}
function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> {
const items = Object.values(snapshot.session.items);
const packages = Object.values(snapshot.session.packages);
const byStatus: Record<string, number> = {};
for (const item of items) {
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
}
const activeItems = items
.filter((item) => item.status === "downloading" || item.status === "validating")
.map((item) => summarizeItem(item));
const failedItems = items
.filter((item) => item.status === "failed")
.map((item) => summarizeItem(item));
return {
running: snapshot.session.running,
paused: snapshot.session.paused,
speed: snapshot.speedText,
eta: snapshot.etaText,
itemCounts: byStatus,
totalItems: items.length,
totalPackages: packages.length,
packages: packages.map((pkg) => summarizePackage(snapshot, pkg, false)),
activeItems,
failedItems: failedItems.length > 0 ? failedItems : undefined
};
}
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
const url = new URL(req.url || "/", "http://localhost");
const pathname = url.pathname;
const traceConfig = getTraceConfig();
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("INFO", "debug-http", "Request", {
method: req.method || "GET",
url: sanitizeRequestUrlForTrace(req.url || "/"),
clientIp: extractDebugClientIp(req)
});
}
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
res.writeHead(204, { res.writeHead(204, {
"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Authorization" "Access-Control-Allow-Headers": "Authorization",
"Access-Control-Allow-Methods": "GET,OPTIONS"
}); });
res.end(); res.end();
return; return;
} }
if (!checkAuth(req)) { if (!checkAuth(req)) {
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
method: req.method || "GET",
url: sanitizeRequestUrlForTrace(req.url || "/"),
clientIp: extractDebugClientIp(req)
});
}
jsonResponse(res, 401, { error: "Unauthorized" }); jsonResponse(res, 401, { error: "Unauthorized" });
return; return;
} }
const url = new URL(req.url || "/", "http://localhost");
const pathname = url.pathname;
if (pathname === "/health") { if (pathname === "/health") {
jsonResponse(res, 200, { jsonResponse(res, 200, {
status: "ok", status: "ok",
appVersion: APP_VERSION,
uptime: Math.floor(process.uptime()), uptime: Math.floor(process.uptime()),
memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024) memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024)
}); });
return; return;
} }
if (pathname === "/log") { if (pathname === "/meta") {
const count = Math.min(Number(url.searchParams.get("lines") || "100"), MAX_LOG_LINES); jsonResponse(res, 200, {
appVersion: APP_VERSION,
runtimeBaseDir,
debugServer: {
host: bindHost,
port: bindPort
},
supportFiles: {
aiManifest: getAiManifestPath(),
traceConfig: getTraceConfigPath(),
traceLog: getTraceLogPath()
},
supportChecks: {
setup: "/debug/setup",
selfCheck: "/self-check"
},
logPaths: {
main: getLogFilePath(),
audit: getAuditLogPath(),
rename: getRenameLogPath(),
session: getSessionLogPath(),
trace: getTraceLogPath()
},
endpoints: getEndpointSummaries()
});
return;
}
if (pathname === "/debug/setup" || pathname === "/self-check") {
jsonResponse(res, 200, getDebugSetupCheck(runtimeBaseDir));
return;
}
if (pathname === "/host/diagnostics") {
jsonResponse(res, 200, getWindowsHostDiagnostics());
return;
}
if (pathname === "/log" || pathname === "/logs/main") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || ""; const grep = url.searchParams.get("grep") || "";
let lines = readLogTail(count); const lines = filterLines(readLogTailFromFile(getLogFilePath(), count), grep);
if (grep) {
const pattern = grep.toLowerCase();
lines = lines.filter((l) => l.toLowerCase().includes(pattern));
}
jsonResponse(res, 200, { lines, count: lines.length }); jsonResponse(res, 200, { lines, count: lines.length });
return; return;
} }
if (pathname === "/errors") {
const levelFilter = (url.searchParams.get("level") || "").toUpperCase();
const limit = normalizeLinesParam(url.searchParams.get("limit"), 100);
let entries = getRecentErrors();
if (levelFilter === "ERROR" || levelFilter === "WARN") {
entries = entries.filter((entry) => entry.level === levelFilter);
}
const limited = entries.slice(-limit);
jsonResponse(res, 200, { count: limited.length, total: entries.length, entries: limited });
return;
}
if (pathname === "/logs/audit") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const logPath = getAuditLogPath();
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
jsonResponse(res, 200, {
path: logPath,
lines,
count: lines.length
});
return;
}
if (pathname === "/logs/rename") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const logPath = getRenameLogPath();
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
jsonResponse(res, 200, {
path: logPath,
lines,
count: lines.length
});
return;
}
if (pathname === "/logs/trace") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const logPath = getTraceLogPath();
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
jsonResponse(res, 200, {
path: logPath,
configPath: getTraceConfigPath(),
config: getTraceConfig(),
lines,
count: lines.length
});
return;
}
if (pathname === "/logs/session") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const logPath = getSessionLogPath();
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
jsonResponse(res, 200, {
path: logPath,
lines,
count: lines.length
});
return;
}
if (pathname === "/trace/config") {
const patch: Record<string, unknown> = {};
const enabled = toBooleanQuery(url.searchParams.get("enable"));
const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog"));
const includeAudit = toBooleanQuery(url.searchParams.get("includeAudit"));
const logDebugRequests = toBooleanQuery(url.searchParams.get("logDebugRequests"));
if (enabled !== null) {
patch.enabled = enabled;
}
if (includeMainLog !== null) {
patch.includeMainLog = includeMainLog;
}
if (includeAudit !== null) {
patch.includeAudit = includeAudit;
}
if (logDebugRequests !== null) {
patch.logDebugRequests = logDebugRequests;
}
const note = String(url.searchParams.get("note") || "").trim();
const durationMinutesRaw = Number(url.searchParams.get("durationMinutes") || "120");
const durationMinutes = Number.isFinite(durationMinutesRaw) && durationMinutesRaw > 0
? Math.min(Math.floor(durationMinutesRaw), 24 * 60)
: 120;
let config = getTraceConfig();
if (enabled !== null) {
config = setTraceEnabled(enabled, note, durationMinutes * 60 * 1000);
}
const configPatch = { ...patch };
delete configPatch.enabled;
if (Object.keys(configPatch).length > 0) {
config = updateTraceConfig(configPatch);
}
if (Object.keys(patch).length > 0) {
logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note, durationMinutes });
}
jsonResponse(res, 200, {
path: getTraceConfigPath(),
logPath: getTraceLogPath(),
config
});
return;
}
if (pathname === "/logs/package") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const packageQuery = url.searchParams.get("package") || url.searchParams.get("packageId") || "";
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const resolved = getPackageLogPathForQuery(snapshot, packageQuery);
if (!resolved.logPath) {
jsonResponse(res, 404, { error: "Package log not found", package: packageQuery });
return;
}
const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep);
jsonResponse(res, 200, {
package: resolved.pkg ? summarizePackage(snapshot, resolved.pkg, false) : undefined,
path: resolved.logPath,
lines,
count: lines.length
});
return;
}
if (pathname === "/logs/item") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const itemQuery = url.searchParams.get("item") || url.searchParams.get("itemId") || "";
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const resolved = getItemLogPathForQuery(snapshot, itemQuery);
if (!resolved.logPath) {
jsonResponse(res, 404, { error: "Item log not found", item: itemQuery });
return;
}
const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep);
jsonResponse(res, 200, {
item: resolved.item ? summarizeItem(resolved.item) : undefined,
path: resolved.logPath,
lines,
count: lines.length
});
return;
}
if (pathname === "/settings") {
const settings = readSupportSettings();
jsonResponse(res, 200, buildRedactedSettingsPayload(settings));
return;
}
if (pathname === "/accounts") {
const settings = readSupportSettings();
jsonResponse(res, 200, buildAccountSummary(settings));
return;
}
if (pathname === "/stats") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const settings = readSupportSettings();
jsonResponse(res, 200, {
...buildStatsPayload(snapshot),
allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
}
});
return;
}
if (pathname === "/history") {
const entries = readSupportHistory();
const limit = normalizeLinesParam(url.searchParams.get("limit"), 50);
const statusFilter = String(url.searchParams.get("status") || "").trim().toLowerCase();
const grep = String(url.searchParams.get("grep") || "").trim().toLowerCase();
let filtered = entries;
if (statusFilter) {
filtered = filtered.filter((entry) => String(entry.status || "").toLowerCase() === statusFilter);
}
if (grep) {
filtered = filtered.filter((entry) => JSON.stringify(summarizeHistoryEntry(entry)).toLowerCase().includes(grep));
}
const sliced = filtered
.sort((a, b) => Number(b.completedAt || 0) - Number(a.completedAt || 0))
.slice(0, limit);
jsonResponse(res, 200, {
count: sliced.length,
total: filtered.length,
entries: sliced.map((entry) => summarizeHistoryEntry(entry))
});
return;
}
if (pathname === "/status") { if (pathname === "/status") {
if (!manager) { if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" }); jsonResponse(res, 503, { error: "Manager not initialized" });
return; return;
} }
const snapshot = manager.getSnapshot(); const snapshot = manager.getSnapshot();
const items = Object.values(snapshot.session.items); jsonResponse(res, 200, buildStatusPayload(snapshot));
const packages = Object.values(snapshot.session.packages); return;
}
const byStatus: Record<string, number> = {}; if (pathname === "/packages") {
for (const item of items) { if (!manager) {
byStatus[item.status] = (byStatus[item.status] || 0) + 1; jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const packageQuery = url.searchParams.get("package") || "";
const includeItems = /^(1|true|yes)$/i.test(String(url.searchParams.get("includeItems") || ""));
let packages = Object.values(snapshot.session.packages);
if (packageQuery) {
const needle = packageQuery.toLowerCase();
packages = packages.filter((pkg) => pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle));
} }
const activeItems = items
.filter((i) => i.status === "downloading" || i.status === "validating")
.map((i) => ({
id: i.id,
fileName: i.fileName,
status: i.status,
fullStatus: i.fullStatus,
provider: i.provider,
progress: i.progressPercent,
speedMBs: +(i.speedBps / 1024 / 1024).toFixed(2),
downloadedMB: +(i.downloadedBytes / 1024 / 1024).toFixed(1),
totalMB: i.totalBytes ? +(i.totalBytes / 1024 / 1024).toFixed(1) : null,
retries: i.retries,
lastError: i.lastError
}));
const failedItems = items
.filter((i) => i.status === "failed")
.map((i) => ({
fileName: i.fileName,
lastError: i.lastError,
retries: i.retries,
provider: i.provider
}));
jsonResponse(res, 200, { jsonResponse(res, 200, {
running: snapshot.session.running, count: packages.length,
paused: snapshot.session.paused, packages: packages.map((pkg) => summarizePackage(snapshot, pkg, includeItems))
speed: snapshot.speedText,
eta: snapshot.etaText,
itemCounts: byStatus,
totalItems: items.length,
packages: packages.map((p) => ({
name: p.name,
status: p.status,
items: p.itemIds.length
})),
activeItems,
failedItems: failedItems.length > 0 ? failedItems : undefined
}); });
return; return;
} }
@ -175,9 +783,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
items = items.filter((i) => i.status === filter); items = items.filter((i) => i.status === filter);
} }
if (pkg) { if (pkg) {
const pkgLower = pkg.toLowerCase(); const matchedPkg = findPackage(snapshot, pkg);
const matchedPkg = Object.values(snapshot.session.packages)
.find((p) => p.name.toLowerCase().includes(pkgLower));
if (matchedPkg) { if (matchedPkg) {
const ids = new Set(matchedPkg.itemIds); const ids = new Set(matchedPkg.itemIds);
items = items.filter((i) => ids.has(i.id)); items = items.filter((i) => ids.has(i.id));
@ -185,18 +791,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
} }
jsonResponse(res, 200, { jsonResponse(res, 200, {
count: items.length, count: items.length,
items: items.map((i) => ({ items: items.map((i) => summarizeItem(i))
fileName: i.fileName,
status: i.status,
fullStatus: i.fullStatus,
provider: i.provider,
progress: i.progressPercent,
speedMBs: +(i.speedBps / 1024 / 1024).toFixed(2),
downloadedMB: +(i.downloadedBytes / 1024 / 1024).toFixed(1),
totalMB: i.totalBytes ? +(i.totalBytes / 1024 / 1024).toFixed(1) : null,
retries: i.retries,
lastError: i.lastError
}))
}); });
return; return;
} }
@ -209,16 +804,14 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
const snapshot = manager.getSnapshot(); const snapshot = manager.getSnapshot();
const pkg = url.searchParams.get("package"); const pkg = url.searchParams.get("package");
if (pkg) { if (pkg) {
const pkgLower = pkg.toLowerCase(); const matchedPkg = findPackage(snapshot, pkg);
const matchedPkg = Object.values(snapshot.session.packages)
.find((p) => p.name.toLowerCase().includes(pkgLower));
if (matchedPkg) { if (matchedPkg) {
const ids = new Set(matchedPkg.itemIds); const ids = new Set(matchedPkg.itemIds);
const pkgItems = Object.values(snapshot.session.items) const pkgItems = Object.values(snapshot.session.items)
.filter((i) => ids.has(i.id)); .filter((i) => ids.has(i.id));
jsonResponse(res, 200, { jsonResponse(res, 200, {
package: matchedPkg, package: summarizePackage(snapshot, matchedPkg, false),
items: pkgItems items: pkgItems.map((item) => summarizeItem(item))
}); });
return; return;
} }
@ -238,31 +831,113 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
if (pathname === "/support/bundle") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const fileName = getSupportBundleDefaultFileName();
const body = buildSupportBundle(manager, runtimeBaseDir);
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
fileName,
sizeBytes: body.length
});
binaryResponse(res, 200, body, "application/zip", fileName);
return;
}
if (pathname === "/diagnostics") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const lineCount = normalizeLinesParam(url.searchParams.get("lines"), 150);
const grep = url.searchParams.get("grep") || "";
const packageQuery = url.searchParams.get("package") || "";
const mainLogPath = getLogFilePath();
const sessionLogPath = getSessionLogPath();
const selectedPackage = packageQuery ? findPackage(snapshot, packageQuery) : null;
const packageLogPath = selectedPackage
? manager.getPackageLogPath(selectedPackage.id) || getPersistedPackageLogPath(selectedPackage.id)
: null;
jsonResponse(res, 200, {
meta: {
appVersion: APP_VERSION,
serverTime: new Date().toISOString(),
runtimeBaseDir,
debugServer: {
host: bindHost,
port: bindPort
},
setup: getDebugSetupCheck(runtimeBaseDir)
},
status: buildStatusPayload(snapshot),
settings: buildRedactedSettingsPayload(readSupportSettings()),
stats: buildStatsPayload(snapshot),
accounts: buildAccountSummary(readSupportSettings()),
history: {
total: readSupportHistory().length,
recent: readSupportHistory()
.sort((a, b) => Number(b.completedAt || 0) - Number(a.completedAt || 0))
.slice(0, 10)
.map((entry) => summarizeHistoryEntry(entry))
},
host: getWindowsHostDiagnostics(),
selectedPackage: selectedPackage ? summarizePackage(snapshot, selectedPackage, true) : undefined,
logs: {
main: {
path: mainLogPath,
lines: filterLines(readLogTailFromFile(mainLogPath, lineCount), grep)
},
audit: {
path: getAuditLogPath(),
lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep)
},
rename: {
path: getRenameLogPath(),
lines: filterLines(readLogTailFromFile(getRenameLogPath(), lineCount), grep)
},
trace: {
path: getTraceLogPath(),
config: getTraceConfig(),
lines: filterLines(readLogTailFromFile(getTraceLogPath(), lineCount), grep)
},
session: {
path: sessionLogPath,
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
},
package: selectedPackage ? {
path: packageLogPath,
lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep)
} : undefined
}
});
return;
}
jsonResponse(res, 404, { jsonResponse(res, 404, {
error: "Not found", error: "Not found",
endpoints: [ endpoints: getEndpointSummaries()
"GET /health",
"GET /log?lines=100&grep=keyword",
"GET /status",
"GET /items?status=downloading&package=Bloodline",
"GET /session?package=Criminal"
]
}); });
} }
export function startDebugServer(mgr: DownloadManager, baseDir: string): void { export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
runtimeBaseDir = baseDir;
authToken = loadToken(baseDir); authToken = loadToken(baseDir);
bindPort = getPort(baseDir);
bindHost = getHost(baseDir);
writeAiManifest(baseDir);
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");
return; return;
} }
manager = mgr; manager = mgr;
const port = getPort(baseDir);
server = http.createServer(handleRequest); server = http.createServer(handleRequest);
server.listen(port, "127.0.0.1", () => { server.listen(bindPort, bindHost, () => {
logger.info(`Debug-Server gestartet auf Port ${port}`); logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`);
}); });
server.on("error", (err) => { server.on("error", (err) => {
logger.warn(`Debug-Server Fehler: ${String(err)}`); logger.warn(`Debug-Server Fehler: ${String(err)}`);

435
src/main/debug-setup.ts Normal file
View File

@ -0,0 +1,435 @@
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { getSessionLogPath } from "./session-log";
import { createStoragePaths, loadSettings } from "./storage";
import type {
DebugSetupCheckResult,
SupportBundleEstimate,
SupportDirectorySizeInfo,
SupportDiskSpaceInfo,
SupportFileSizeInfo,
SupportTraceConfig
} from "../shared/types";
const DEFAULT_PORT = 9868;
const DEFAULT_HOST = "127.0.0.1";
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
const LOW_FREE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_BYTES || 20 * 1024 * 1024 * 1024);
const LOW_FREE_PERCENT_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT || 5);
const LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024);
const LARGE_LOG_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_LOG_BYTES || 250 * 1024 * 1024);
const LARGE_BUNDLE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_BUNDLE_BYTES || 150 * 1024 * 1024);
const BUNDLE_OVERVIEW_SLACK_BYTES = 256 * 1024;
function formatByteCount(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) {
return "0 B";
}
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function readToken(baseDir: string): string {
try {
return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim();
} catch {
return "";
}
}
function readPort(baseDir: string): number {
try {
const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim());
if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) {
return raw;
}
} catch {
}
return DEFAULT_PORT;
}
function readHost(baseDir: string): string {
try {
const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim();
if (!raw) {
return DEFAULT_HOST;
}
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
return raw;
}
if (/^[a-z0-9.-]+$/i.test(raw)) {
return raw;
}
} catch {
}
return DEFAULT_HOST;
}
function readTraceConfig(baseDir: string): SupportTraceConfig {
const fallback: SupportTraceConfig = {
enabled: false,
includeMainLog: true,
includeAudit: true,
logDebugRequests: true,
autoDisableAt: null,
updatedAt: new Date(0).toISOString()
};
try {
const filePath = path.join(baseDir, "trace_config.json");
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<SupportTraceConfig>;
return {
enabled: Boolean(parsed.enabled),
includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog),
includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit),
logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests),
autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null,
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt
};
} catch {
return fallback;
}
}
function getFileSizeInfo(filePath: string | null): SupportFileSizeInfo {
if (!filePath) {
return { path: null, exists: false, bytes: 0 };
}
try {
const stat = fs.statSync(filePath);
return {
path: filePath,
exists: true,
bytes: stat.size
};
} catch {
return {
path: filePath,
exists: false,
bytes: 0
};
}
}
function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): SupportDirectorySizeInfo {
if (!fs.existsSync(dirPath)) {
return {
path: dirPath,
exists: false,
fileCount: 0,
bytes: 0
};
}
let bytes = 0;
let fileCount = 0;
const queue = [dirPath];
while (queue.length > 0) {
const current = queue.pop();
if (!current) {
continue;
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
queue.push(fullPath);
continue;
}
if (skipPath && path.resolve(fullPath) === path.resolve(skipPath)) {
continue;
}
try {
bytes += fs.statSync(fullPath).size;
fileCount += 1;
} catch {
}
}
}
return {
path: dirPath,
exists: true,
fileCount,
bytes
};
}
function resolveExistingPath(targetPath: string): string {
let current = path.resolve(targetPath);
while (!fs.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return current;
}
function getWindowsDiskSpaceInfo(existingPath: string): SupportDiskSpaceInfo | null {
if (process.platform !== "win32") {
return null;
}
const root = path.parse(existingPath).root.replace(/[\\/]+$/g, "");
const driveName = root.replace(":", "");
if (!/^[A-Za-z]$/.test(driveName)) {
return null;
}
try {
const raw = execFileSync(
"powershell",
[
"-NoProfile",
"-Command",
`$drive = Get-PSDrive -Name '${driveName}'; if ($drive) { [pscustomobject]@{ FreeSpace = [int64]$drive.Free; Size = [int64]($drive.Used + $drive.Free) } | ConvertTo-Json -Compress }`
],
{
encoding: "utf8",
windowsHide: true,
stdio: ["ignore", "pipe", "ignore"],
timeout: 3000
}
).trim();
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as { FreeSpace?: number | string; Size?: number | string };
const totalBytes = Number(parsed.Size);
const freeBytes = Number(parsed.FreeSpace);
const freePercent = Number.isFinite(totalBytes) && totalBytes > 0
? Math.round((freeBytes / totalBytes) * 1000) / 10
: null;
return {
path: existingPath,
totalBytes: Number.isFinite(totalBytes) ? totalBytes : null,
freeBytes: Number.isFinite(freeBytes) ? freeBytes : null,
freePercent
};
} catch {
return null;
}
}
function getDiskSpaceInfo(targetPath: string): SupportDiskSpaceInfo {
const existingPath = resolveExistingPath(targetPath);
try {
const stat = fs.statfsSync(existingPath);
const totalBytes = Number(stat.blocks) * Number(stat.bsize);
const freeBytes = Number(stat.bavail) * Number(stat.bsize);
const freePercent = totalBytes > 0
? Math.round((freeBytes / totalBytes) * 1000) / 10
: null;
return {
path: existingPath,
totalBytes,
freeBytes,
freePercent
};
} catch {
const windowsFallback = getWindowsDiskSpaceInfo(existingPath);
if (windowsFallback) {
return windowsFallback;
}
return {
path: existingPath,
totalBytes: null,
freeBytes: null,
freePercent: null
};
}
}
function getSupportBundleEstimate(
baseDir: string,
logSummary: DebugSetupCheckResult["logSummary"]
): SupportBundleEstimate {
const storagePaths = createStoragePaths(baseDir);
const staticFiles = [
path.join(baseDir, AI_MANIFEST_FILE),
path.join(baseDir, "debug_host.txt"),
path.join(baseDir, "debug_port.txt"),
storagePaths.configFile,
storagePaths.sessionFile,
storagePaths.historyFile,
path.join(baseDir, "trace_config.json")
].map((filePath) => getFileSizeInfo(filePath));
const staticBytes = staticFiles.reduce((sum, entry) => sum + entry.bytes, 0);
const duplicatedLiveLogBytes = logSummary.session.bytes + logSummary.packageLogs.bytes + logSummary.itemLogs.bytes;
const estimatedEntries = 10
+ staticFiles.filter((entry) => entry.exists).length
+ Number(logSummary.main.exists)
+ Number(logSummary.mainBackup.exists)
+ Number(logSummary.audit.exists)
+ Number(logSummary.auditBackup.exists)
+ Number(logSummary.rename.exists)
+ Number(logSummary.renameBackup.exists)
+ Number(logSummary.session.exists)
+ Number(logSummary.trace.exists)
+ Number(logSummary.traceBackup.exists)
+ logSummary.sessionLogs.fileCount
+ logSummary.packageLogs.fileCount
+ logSummary.itemLogs.fileCount
+ logSummary.packageLogs.fileCount
+ logSummary.itemLogs.fileCount;
return {
estimatedBytes: staticBytes + logSummary.totalBytes + duplicatedLiveLogBytes + BUNDLE_OVERVIEW_SLACK_BYTES,
estimatedEntries,
duplicatedLiveLogBytes,
note: "Schätzwert vor ZIP-Komprimierung; aktueller Session-Log sowie Live-Paket-/Item-Logs werden im Bundle zusätzlich gespiegelt."
};
}
export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
const host = readHost(baseDir);
const port = readPort(baseDir);
const token = readToken(baseDir);
const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths);
const tokenPath = path.join(baseDir, "debug_token.txt");
const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE);
const traceConfigPath = path.join(baseDir, "trace_config.json");
const traceLogPath = path.join(baseDir, "trace.log");
const traceConfig = readTraceConfig(baseDir);
const sessionLogPath = getSessionLogPath();
const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host);
const warnings: string[] = [];
const notes: string[] = [];
const logSummary: DebugSetupCheckResult["logSummary"] = {
main: getFileSizeInfo(path.join(baseDir, "rd_downloader.log")),
mainBackup: getFileSizeInfo(path.join(baseDir, "rd_downloader.log.old")),
audit: getFileSizeInfo(path.join(baseDir, "audit.log")),
auditBackup: getFileSizeInfo(path.join(baseDir, "audit.log.old")),
rename: getFileSizeInfo(path.join(baseDir, "rename.log")),
renameBackup: getFileSizeInfo(path.join(baseDir, "rename.log.old")),
session: getFileSizeInfo(sessionLogPath),
trace: getFileSizeInfo(traceLogPath),
traceBackup: getFileSizeInfo(path.join(baseDir, "trace.log.old")),
sessionLogs: getDirectorySizeInfo(path.join(baseDir, "session-logs"), sessionLogPath),
packageLogs: getDirectorySizeInfo(path.join(baseDir, "package-logs")),
itemLogs: getDirectorySizeInfo(path.join(baseDir, "item-logs")),
totalBytes: 0
};
logSummary.totalBytes = [
logSummary.main.bytes,
logSummary.mainBackup.bytes,
logSummary.audit.bytes,
logSummary.auditBackup.bytes,
logSummary.rename.bytes,
logSummary.renameBackup.bytes,
logSummary.session.bytes,
logSummary.trace.bytes,
logSummary.traceBackup.bytes,
logSummary.sessionLogs.bytes,
logSummary.packageLogs.bytes,
logSummary.itemLogs.bytes
].reduce((sum, value) => sum + value, 0);
const diskSpace: DebugSetupCheckResult["diskSpace"] = {
runtime: getDiskSpaceInfo(baseDir),
output: getDiskSpaceInfo(settings.outputDir),
extract: getDiskSpaceInfo(settings.extractDir)
};
const supportBundle = getSupportBundleEstimate(baseDir, logSummary);
if (!token) {
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
}
if (localOnly) {
warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen.");
} else {
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
}
if (!fs.existsSync(aiManifestPath)) {
warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei neu geschrieben wird.");
}
if (!fs.existsSync(traceConfigPath)) {
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
}
if (traceConfig.enabled && !traceConfig.autoDisableAt) {
warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird.");
}
if (traceConfig.enabled && traceConfig.autoDisableAt) {
notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`);
}
for (const entry of [
{ label: "Runtime", info: diskSpace.runtime },
{ label: "Download-Ziel", info: diskSpace.output },
{ label: "Entpack-Ziel", info: diskSpace.extract }
]) {
if (entry.info.freeBytes === null || entry.info.totalBytes === null) {
warnings.push(`${entry.label}: Freier Speicherplatz konnte nicht gelesen werden (${entry.info.path}).`);
continue;
}
const lowByAbsolute = entry.info.freeBytes < LOW_FREE_BYTES_THRESHOLD;
const lowByPercent = entry.info.freePercent !== null
&& entry.info.freePercent < LOW_FREE_PERCENT_THRESHOLD
&& entry.info.freeBytes < LOW_FREE_PERCENT_BYTES_GUARD;
if (lowByAbsolute || lowByPercent) {
warnings.push(`${entry.label}: wenig freier Speicherplatz (${formatByteCount(entry.info.freeBytes)} frei auf ${entry.info.path}).`);
}
}
if (logSummary.totalBytes >= LARGE_LOG_BYTES_THRESHOLD) {
warnings.push(`Support-Logs sind bereits recht groß (${formatByteCount(logSummary.totalBytes)}). Rotation greift, aber ein Bundle wird entsprechend umfangreicher.`);
} else {
notes.push(`Aktuelle Support-Logmenge: ${formatByteCount(logSummary.totalBytes)}.`);
}
if (supportBundle.estimatedBytes >= LARGE_BUNDLE_BYTES_THRESHOLD) {
warnings.push(`Support-Bundle wird voraussichtlich groß (${formatByteCount(supportBundle.estimatedBytes)} vor ZIP-Komprimierung).`);
} else {
notes.push(`Support-Bundle-Schätzung: etwa ${formatByteCount(supportBundle.estimatedBytes)}.`);
}
notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen.");
return {
status: warnings.length > 0 ? "warn" : "ok",
enabled: Boolean(token),
runtimeBaseDir: baseDir,
host,
port,
localOnly,
tokenConfigured: Boolean(token),
tokenPath,
aiManifestPath,
aiManifestPresent: fs.existsSync(aiManifestPath),
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
traceEnabled: traceConfig.enabled,
traceAutoDisableAt: traceConfig.autoDisableAt,
diskSpace,
logSummary,
supportBundle,
warnings,
notes,
localUrls: {
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`,
meta: `http://127.0.0.1:${port}/meta?token=${token || "<TOKEN>"}`,
diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}`
},
remoteUrlTemplates: {
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`,
meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?token=${token || "<TOKEN>"}`,
diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}`
}
};
}

View File

@ -0,0 +1,252 @@
import fs from "node:fs";
import path from "node:path";
import { logTimestamp } from "./log-timestamp";
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
const FOLDER_NAME = "Downloader-Log";
let logDir: string | null = null;
let logFilePath: string | null = null;
let sessionHeader = "";
function fileTimestamp(date: Date = new Date()): string {
const pad = (value: number): string => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
}
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function ensureWritable(): boolean {
if (!logDir || !logFilePath) {
return false;
}
try {
fs.mkdirSync(logDir, { recursive: true });
if (!fs.existsSync(logFilePath)) {
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
}
return true;
} catch {
return false;
}
}
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
try {
const base = String(desktopDir || "").trim();
if (!base) {
logDir = null;
logFilePath = null;
return;
}
logDir = path.join(base, FOLDER_NAME);
logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`);
sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n`
+ "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\n"
+ "und verifiziert nach jedem Vorgang, ob die Datei wirklich unter dem Zielnamen auf der\n"
+ "Platte liegt (und die Quelle verschwunden ist). [INFO]=ok, [ERROR]=Verifikation gescheitert.\n\n";
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
} catch {
logDir = null;
logFilePath = null;
}
}
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
if (!ensureWritable() || !logFilePath) {
return;
}
try {
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
} catch {
}
}
export function getDesktopRenameLogPath(): string | null {
if (!logFilePath) {
return null;
}
try {
return fs.existsSync(logFilePath) ? logFilePath : null;
} catch {
return null;
}
}
export function shutdownDesktopRenameLog(): void {
if (ensureWritable() && logFilePath) {
try {
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
} catch {
}
}
logDir = null;
logFilePath = null;
}
export interface RenameVerification {
ok: boolean;
level: "INFO" | "WARN" | "ERROR";
targetExists: boolean;
onDiskName: string | null;
nameMatches: boolean;
sourceGone: boolean;
targetSize: number | null;
reason: string;
}
function toLongPath(filePath: string): string {
const absolute = path.resolve(String(filePath || ""));
if (process.platform !== "win32") {
return absolute;
}
if (!absolute || absolute.startsWith("\\\\?\\")) {
return absolute;
}
if (absolute.length < 248) {
return absolute;
}
if (absolute.startsWith("\\\\")) {
return `\\\\?\\UNC\\${absolute.slice(2)}`;
}
return `\\\\?\\${absolute}`;
}
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
if (entries === null) {
return null;
}
const requestedLower = requested.toLowerCase();
return entries.find((entry) => entry === requested)
|| entries.find((entry) => entry.toLowerCase() === requestedLower)
|| requested;
}
function buildVerification(
sourcePath: string,
targetPath: string,
facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean }
): RenameVerification {
const requested = path.basename(targetPath);
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
const sourceGone = samePath ? true : !facts.sourceExists;
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
const problems: string[] = [];
let level: "INFO" | "WARN" | "ERROR" = "INFO";
if (!facts.targetExists) {
problems.push("Zieldatei nach Rename NICHT gefunden");
level = "ERROR";
} else if (!dirReadFailed && !nameMatches) {
problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`);
level = "ERROR";
}
if (!samePath && facts.targetExists && !sourceGone) {
problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)");
level = "ERROR";
}
if (level === "INFO" && dirReadFailed) {
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
level = "WARN";
}
return {
ok: level === "INFO",
level,
targetExists: facts.targetExists,
onDiskName,
nameMatches,
sourceGone,
targetSize: facts.targetSize,
reason: problems.join("; ")
};
}
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
const longTarget = toLongPath(targetPath);
let targetExists = false;
let targetSize: number | null = null;
try {
const stat = fs.statSync(longTarget);
targetExists = true;
targetSize = stat.size;
} catch {
targetExists = false;
}
let dirEntries: string[] | null = null;
if (targetExists) {
try {
dirEntries = fs.readdirSync(path.dirname(longTarget));
} catch {
dirEntries = null;
}
}
let sourceExists = false;
try {
fs.statSync(toLongPath(sourcePath));
sourceExists = true;
} catch {
sourceExists = false;
}
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
}
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
const longTarget = toLongPath(targetPath);
let targetExists = false;
let targetSize: number | null = null;
try {
const stat = await fs.promises.stat(longTarget);
targetExists = true;
targetSize = stat.size;
} catch {
targetExists = false;
}
let dirEntries: string[] | null = null;
if (targetExists) {
try {
dirEntries = await fs.promises.readdir(path.dirname(longTarget));
} catch {
dirEntries = null;
}
}
let sourceExists = false;
try {
await fs.promises.stat(toLongPath(sourcePath));
sourceExists = true;
} catch {
sourceExists = false;
}
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
}

View File

@ -0,0 +1,150 @@
import { ALLOCATION_UNIT_SIZE } from "./constants";
export type DownloadCompletionSource =
| "content-range"
| "content-length"
| "provider-metadata"
| "stream-end";
export type DownloadCompletionPlan = {
expectedTotal: number | null;
source: DownloadCompletionSource;
canFinishEarly: boolean;
};
export function planDownloadCompletion(args: {
existingBytes: number;
responseStatus: number;
contentLength: number;
totalFromRange: number | null;
knownTotal: number | null;
correctedTotal: number | null;
}): DownloadCompletionPlan {
const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0));
const responseStatus = Math.floor(Number(args.responseStatus) || 0);
const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0));
const totalFromRange = Number.isFinite(args.totalFromRange || NaN)
? Math.max(0, Math.floor(args.totalFromRange || 0))
: 0;
const correctedTotal = Number.isFinite(args.correctedTotal || NaN)
? Math.max(0, Math.floor(args.correctedTotal || 0))
: 0;
const knownTotal = Number.isFinite(args.knownTotal || NaN)
? Math.max(0, Math.floor(args.knownTotal || 0))
: 0;
if (correctedTotal > 0) {
return {
expectedTotal: correctedTotal,
source: totalFromRange > 0 ? "content-range" : "content-length",
canFinishEarly: true
};
}
if (totalFromRange > 0) {
return {
expectedTotal: totalFromRange,
source: "content-range",
canFinishEarly: true
};
}
if (contentLength > 0) {
return {
expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength,
source: "content-length",
canFinishEarly: true
};
}
if (knownTotal > 0) {
return {
expectedTotal: knownTotal,
source: "provider-metadata",
canFinishEarly: false
};
}
return {
expectedTotal: null,
source: "stream-end",
canFinishEarly: false
};
}
export function validateDownloadedFileCompletion(args: {
actualBytes: number;
plan: DownloadCompletionPlan;
toleranceBytes?: number;
}): {
ok: boolean;
totalBytes: number;
acceptedMetadataMismatch: boolean;
error?: string;
} {
const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0));
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
: 0;
const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0));
if (
expectedTotal > 0 &&
(args.plan.source === "content-range" || args.plan.source === "content-length") &&
actualBytes + toleranceBytes < expectedTotal
) {
return {
ok: false,
totalBytes: expectedTotal,
acceptedMetadataMismatch: false,
error: `download_underflow:${actualBytes}/${expectedTotal}`
};
}
if (actualBytes <= 0 && expectedTotal > 0) {
return {
ok: false,
totalBytes: expectedTotal,
acceptedMetadataMismatch: false,
error: `download_underflow:${actualBytes}/${expectedTotal}`
};
}
if (args.plan.source === "provider-metadata") {
if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) {
return {
ok: false,
totalBytes: expectedTotal,
acceptedMetadataMismatch: false,
error: `download_underflow:${actualBytes}/${expectedTotal}`
};
}
return {
ok: true,
totalBytes: actualBytes,
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes
};
}
if (args.plan.source === "stream-end") {
if (actualBytes <= 0) {
return {
ok: false,
totalBytes: 0,
acceptedMetadataMismatch: false,
error: "download_underflow:0/0"
};
}
return {
ok: true,
totalBytes: actualBytes,
acceptedMetadataMismatch: false
};
}
return {
ok: true,
totalBytes: Math.max(actualBytes, expectedTotal),
acceptedMetadataMismatch: false
};
}

File diff suppressed because it is too large Load Diff

45
src/main/error-ring.ts Normal file
View File

@ -0,0 +1,45 @@
export interface ErrorRingEntry {
ts: string;
level: string;
message: string;
}
export interface ErrorRing {
push: (entry: ErrorRingEntry) => void;
snapshot: () => ErrorRingEntry[];
clear: () => void;
size: () => number;
}
export function createErrorRing(capacity: number): ErrorRing {
const limit = Math.max(1, Math.floor(capacity));
const buffer: ErrorRingEntry[] = [];
return {
push(entry: ErrorRingEntry): void {
buffer.push(entry);
while (buffer.length > limit) {
buffer.shift();
}
},
snapshot(): ErrorRingEntry[] {
return buffer.slice();
},
clear(): void {
buffer.length = 0;
},
size(): number {
return buffer.length;
}
};
}
const RECENT_ERROR_CAPACITY = 200;
const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY);
export function recordRecentError(level: string, message: string, ts: string): void {
recentErrors.push({ level, message, ts });
}
export function getRecentErrors(): ErrorRingEntry[] {
return recentErrors.snapshot();
}

File diff suppressed because it is too large Load Diff

56
src/main/fs-error.ts Normal file
View File

@ -0,0 +1,56 @@
// Maps low-level filesystem/OS error codes to a human-readable cause so that a
// generic "write failed" or "timeout" can be reported as the specific root cause
// (disk full, permission denied, ...). Pure + side-effect-free for testing.
const DISK_ERROR_REASONS: Record<string, string> = {
ENOSPC: "Festplatte voll (ENOSPC)",
EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)",
EROFS: "Laufwerk schreibgeschützt (EROFS)",
EACCES: "Zugriff verweigert (EACCES)",
EPERM: "Operation nicht erlaubt (EPERM)",
EMFILE: "Zu viele offene Dateien (EMFILE)",
ENFILE: "System-Limit offener Dateien erreicht (ENFILE)",
EBUSY: "Datei/Laufwerk belegt (EBUSY)",
ENODEV: "Gerät nicht vorhanden (ENODEV)",
ENXIO: "Gerät getrennt (ENXIO)",
EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)"
};
export function classifyDiskError(err: unknown): string | null {
const code = extractErrorCode(err);
if (code && DISK_ERROR_REASONS[code]) {
return DISK_ERROR_REASONS[code];
}
// Some errors arrive as plain strings/messages without a `.code`; fall back to
// scanning the text for a known code token.
const text = errorText(err);
for (const knownCode of Object.keys(DISK_ERROR_REASONS)) {
if (text.includes(knownCode)) {
return DISK_ERROR_REASONS[knownCode];
}
}
return null;
}
function extractErrorCode(err: unknown): string {
if (err && typeof err === "object") {
const code = (err as { code?: unknown }).code;
if (typeof code === "string") {
return code.toUpperCase();
}
}
return "";
}
function errorText(err: unknown): string {
if (typeof err === "string") {
return err;
}
if (err && typeof err === "object") {
const message = (err as { message?: unknown }).message;
if (typeof message === "string") {
return message;
}
}
return String(err ?? "");
}

232
src/main/item-log.ts Normal file
View File

@ -0,0 +1,232 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
import crypto from "node:crypto";
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
const ITEM_LOG_RETENTION_DAYS = 30;
type ItemLogLevel = "INFO" | "WARN" | "ERROR";
export interface ItemLogMeta {
itemId: string;
packageId: string;
packageName: string;
fileName: string;
targetPath: string;
}
let itemLogsDir: string | null = null;
const knownLogPaths = new Map<string, string>();
const pendingLinesByItem = new Map<string, string[]>();
const initializedThisProcess = new Set<string>();
let flushTimer: NodeJS.Timeout | null = null;
function normalizeItemId(itemId: string): string {
const trimmed = String(itemId || "").trim();
if (!trimmed) {
return "";
}
const safePrefix = trimmed
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/_+/g, "_")
.slice(0, 64)
.replace(/^_+|_+$/g, "");
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
return `${safePrefix || "item"}_${hash}`;
}
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function getItemLogFilePathFromNormalized(normalized: string): string | null {
if (!normalized || !itemLogsDir) {
return null;
}
const existing = knownLogPaths.get(normalized);
if (existing) {
return existing;
}
const logPath = path.join(itemLogsDir, `item_${normalized}.txt`);
knownLogPaths.set(normalized, logPath);
return logPath;
}
function getItemLogFilePath(itemId: string): string | null {
return getItemLogFilePathFromNormalized(normalizeItemId(itemId));
}
function flushPending(): void {
for (const [itemId, lines] of pendingLinesByItem.entries()) {
if (lines.length === 0) {
continue;
}
const logPath = getItemLogFilePathFromNormalized(itemId);
if (!logPath) {
continue;
}
const chunk = lines.join("");
pendingLinesByItem.set(itemId, []);
try {
fs.appendFileSync(logPath, chunk, "utf8");
} catch {
}
}
}
function scheduleFlush(): void {
if (flushTimer) {
return;
}
flushTimer = setTimeout(() => {
flushTimer = null;
flushPending();
}, ITEM_LOG_FLUSH_INTERVAL_MS);
}
async function cleanupOldItemLogs(dir: string): Promise<void> {
try {
const files = await fs.promises.readdir(dir);
const cutoff = Date.now() - ITEM_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
for (const file of files) {
if (!file.startsWith("item_") || !file.endsWith(".txt")) {
continue;
}
const filePath = path.join(dir, file);
try {
const stat = await fs.promises.stat(filePath);
if (stat.mtimeMs < cutoff) {
await fs.promises.unlink(filePath);
}
} catch {
}
}
} catch {
}
}
function appendLine(itemId: string, line: string): void {
const normalized = normalizeItemId(itemId);
if (!normalized) {
return;
}
const lines = pendingLinesByItem.get(normalized) || [];
lines.push(line);
pendingLinesByItem.set(normalized, lines);
scheduleFlush();
}
export function initItemLogs(baseDir: string): void {
itemLogsDir = path.join(baseDir, "item-logs");
try {
fs.mkdirSync(itemLogsDir, { recursive: true });
} catch {
itemLogsDir = null;
return;
}
void cleanupOldItemLogs(itemLogsDir);
}
export function ensureItemLog(meta: ItemLogMeta): string | null {
const normalizedItemId = normalizeItemId(meta.itemId);
const logPath = getItemLogFilePath(meta.itemId);
if (!logPath) {
return null;
}
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
if (!fs.existsSync(logPath)) {
fs.writeFileSync(logPath, "", "utf8");
}
if (!initializedThisProcess.has(normalizedItemId)) {
initializedThisProcess.add(normalizedItemId);
const startedAt = logTimestamp();
fs.appendFileSync(
logPath,
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
"utf8"
);
fs.appendFileSync(
logPath,
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
packageId: meta.packageId,
packageName: meta.packageName,
fileName: meta.fileName,
targetPath: meta.targetPath
})}\n`,
"utf8"
);
}
} catch {
return null;
}
return logPath;
}
export function logItemEvent(
itemId: string,
level: ItemLogLevel,
message: string,
fields?: Record<string, unknown>
): void {
const logPath = getItemLogFilePath(itemId);
if (!logPath) {
return;
}
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
appendLine(itemId, line);
}
export function getItemLogPath(itemId: string): string | null {
const logPath = getItemLogFilePath(itemId);
if (!logPath) {
return null;
}
return fs.existsSync(logPath) ? logPath : null;
}
export function shutdownItemLogs(): void {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushPending();
for (const itemId of knownLogPaths.keys()) {
const logPath = getItemLogFilePathFromNormalized(itemId);
if (!logPath) {
continue;
}
try {
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch {
}
}
pendingLinesByItem.clear();
knownLogPaths.clear();
initializedThisProcess.clear();
itemLogsDir = null;
}

116
src/main/link-export.ts Normal file
View File

@ -0,0 +1,116 @@
import type { ParsedPackageInput, UiSnapshot } from "../shared/types";
import { sanitizeFilename } from "./utils";
export type LinkExportSelection = {
packages: ParsedPackageInput[];
packageCount: number;
linkCount: number;
defaultFileName: string;
};
function formatTimestampForFileName(date: Date): string {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
const s = String(date.getSeconds()).padStart(2, "0");
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
}
function buildDefaultFileName(packages: ParsedPackageInput[]): string {
if (packages.length === 1) {
const only = packages[0];
if (only.links.length === 1) {
const itemName = sanitizeFilename(only.fileNames?.[0] || only.name || "link-export");
return `${itemName}.txt`;
}
return `${sanitizeFilename(only.name || "paket-export")}.txt`;
}
return `rd-link-export-${formatTimestampForFileName(new Date())}.txt`;
}
export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: string[], itemIds: string[]): LinkExportSelection {
const selectedPackageIds = new Set(packageIds);
const selectedItemIds = new Set(itemIds);
const packages: ParsedPackageInput[] = [];
for (const packageId of snapshot.session.packageOrder) {
const pkg = snapshot.session.packages[packageId];
if (!pkg) {
continue;
}
const useWholePackage = selectedPackageIds.has(packageId);
const relevantItemIds = useWholePackage
? pkg.itemIds
: pkg.itemIds.filter((itemId) => selectedItemIds.has(itemId));
if (relevantItemIds.length === 0) {
continue;
}
const links: string[] = [];
const fileNames: string[] = [];
for (const itemId of relevantItemIds) {
const item = snapshot.session.items[itemId];
if (!item || !String(item.url || "").trim()) {
continue;
}
links.push(String(item.url).trim());
const rawFileName = String(item.fileName || "").trim();
fileNames.push(rawFileName ? sanitizeFilename(rawFileName) : "");
}
if (links.length === 0) {
continue;
}
const exportEntry: ParsedPackageInput = {
name: sanitizeFilename(pkg.name || "Paket"),
links
};
if (fileNames.some((fileName) => fileName.length > 0)) {
exportEntry.fileNames = fileNames;
}
packages.push(exportEntry);
}
const linkCount = packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
return {
packages,
packageCount: packages.length,
linkCount,
defaultFileName: buildDefaultFileName(packages)
};
}
export function serializeLinkExportText(packages: ParsedPackageInput[]): string {
const lines: string[] = [
"# rd-link-export: 1",
"# Re-import in Real-Debrid-Downloader keeps package names and optional file names.",
""
];
for (const pkg of packages) {
if (!pkg || !pkg.name || !Array.isArray(pkg.links) || pkg.links.length === 0) {
continue;
}
lines.push(`# package: ${sanitizeFilename(pkg.name)}`);
for (let index = 0; index < pkg.links.length; index += 1) {
const link = String(pkg.links[index] || "").trim();
if (!link) {
continue;
}
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
if (fileName) {
lines.push(`# file: ${fileName}`);
}
lines.push(link);
}
lines.push("");
}
return `${lines.join("\n").trim()}\n`;
}

View File

@ -2,19 +2,35 @@ import { ParsedPackageInput } from "../shared/types";
import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils"; import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils";
export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] { export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] {
const grouped = new Map<string, string[]>(); const grouped = new Map<string, { links: string[]; fileNameByLink: Map<string, string> }>();
for (const pkg of packages) { for (const pkg of packages) {
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links)); const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
const list = grouped.get(name) ?? []; const current = grouped.get(name) ?? { links: [], fileNameByLink: new Map<string, string>() };
for (const link of pkg.links) { for (let index = 0; index < pkg.links.length; index += 1) {
list.push(link); const link = String(pkg.links[index] || "").trim();
if (!link) {
continue;
}
if (!current.links.includes(link)) {
current.links.push(link);
}
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
if (fileName && !current.fileNameByLink.has(link)) {
current.fileNameByLink.set(link, fileName);
}
} }
grouped.set(name, list); grouped.set(name, current);
} }
return Array.from(grouped.entries()).map(([name, links]) => ({ return Array.from(grouped.entries()).map(([name, entry]) => {
name, const links = uniquePreserveOrder(entry.links);
links: uniquePreserveOrder(links) const fileNames = links.map((link) => entry.fileNameByLink.get(link) || "");
})); return {
name,
links,
...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {})
};
});
} }
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] { export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {

11
src/main/log-timestamp.ts Normal file
View File

@ -0,0 +1,11 @@
export function logTimestamp(date: Date = new Date()): string {
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absOffset = Math.abs(offsetMinutes);
const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offset}`
);
}

View File

@ -1,6 +1,24 @@
import fs from "node:fs"; import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import { recordRecentError } from "./error-ring";
import path from "node:path"; import path from "node:path";
export function isDebugFlagEnabled(value: string | undefined): boolean {
if (!value) {
return false;
}
return /^(1|true|yes|on)$/i.test(value.trim());
}
// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server
// is a deliberate support action that requires a restart — the runtime-toggleable
// channel is the trace log, not this.
const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG);
export function isDebugLoggingEnabled(): boolean {
return DEBUG_ENABLED;
}
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log"); let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
let fallbackLogFilePath: string | null = null; let fallbackLogFilePath: string | null = null;
const LOG_FLUSH_INTERVAL_MS = 120; const LOG_FLUSH_INTERVAL_MS = 120;
@ -9,7 +27,8 @@ const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
const rotateCheckAtByFile = new Map<string, number>(); const rotateCheckAtByFile = new Map<string, number>();
type LogListener = (line: string) => void; type LogListener = (line: string) => void;
let logListener: LogListener | null = null; const logListeners = new Set<LogListener>();
let legacyLogListener: LogListener | null = null;
let pendingLines: string[] = []; let pendingLines: string[] = [];
let pendingChars = 0; let pendingChars = 0;
@ -18,7 +37,24 @@ let flushInFlight = false;
let exitHookAttached = false; let exitHookAttached = false;
export function setLogListener(listener: LogListener | null): void { export function setLogListener(listener: LogListener | null): void {
logListener = listener; if (legacyLogListener) {
logListeners.delete(legacyLogListener);
}
legacyLogListener = listener;
if (listener) {
logListeners.add(listener);
}
}
export function addLogListener(listener: LogListener): void {
logListeners.add(listener);
}
export function removeLogListener(listener: LogListener): void {
logListeners.delete(listener);
if (legacyLogListener === listener) {
legacyLogListener = null;
}
} }
export function configureLogger(baseDir: string): void { export function configureLogger(baseDir: string): void {
@ -51,7 +87,6 @@ function writeStderr(text: string): void {
try { try {
process.stderr.write(text); process.stderr.write(text);
} catch { } catch {
// ignore stderr failures
} }
} }
@ -117,11 +152,9 @@ function rotateIfNeeded(filePath: string): void {
try { try {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} catch { } catch {
// ignore
} }
fs.renameSync(filePath, backup); fs.renameSync(filePath, backup);
} catch { } catch {
// ignore - file may not exist yet
} }
} }
@ -141,7 +174,6 @@ async function rotateIfNeededAsync(filePath: string): Promise<void> {
await fs.promises.rm(backup, { force: true }).catch(() => {}); await fs.promises.rm(backup, { force: true }).catch(() => {});
await fs.promises.rename(filePath, backup); await fs.promises.rename(filePath, backup);
} catch { } catch {
// ignore - file may not exist yet
} }
} }
@ -151,7 +183,14 @@ async function flushAsync(): Promise<void> {
} }
flushInFlight = true; flushInFlight = true;
const linesSnapshot = pendingLines.slice(); // Move (not copy) the pending lines out and take ownership. A concurrent write()
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
// here means await-time writes queue independently and nothing desyncs.
const linesSnapshot = pendingLines;
pendingLines = [];
pendingChars = 0;
const chunk = linesSnapshot.join(""); const chunk = linesSnapshot.join("");
try { try {
@ -168,9 +207,19 @@ async function flushAsync(): Promise<void> {
} else if (!primary.ok) { } else if (!primary.ok) {
writeStderr(`LOGGER write failed: ${primary.errorText}\n`); writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
} }
if (wroteAny) { if (!wroteAny) {
pendingLines = pendingLines.slice(linesSnapshot.length); // Write failed: requeue the unwritten lines AHEAD of anything that arrived
pendingChars = Math.max(0, pendingChars - chunk.length); // during the await (preserve order), then re-apply the buffer cap so a
// persistent write failure cannot grow the buffer without bound.
pendingLines = linesSnapshot.concat(pendingLines);
pendingChars += chunk.length;
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
const removed = pendingLines.shift();
if (!removed) {
break;
}
pendingChars = Math.max(0, pendingChars - removed.length);
}
} }
} finally { } finally {
flushInFlight = false; flushInFlight = false;
@ -189,14 +238,21 @@ function ensureExitHook(): void {
process.once("exit", flushSyncPending); process.once("exit", flushSyncPending);
} }
function write(level: "INFO" | "WARN" | "ERROR", message: string): void { function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
ensureExitHook(); ensureExitHook();
const line = `${new Date().toISOString()} [${level}] ${message}\n`; const ts = logTimestamp();
const line = `${ts} [${level}] ${message}\n`;
pendingLines.push(line); pendingLines.push(line);
pendingChars += line.length; pendingChars += line.length;
if (logListener) { // Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
try { logListener(line); } catch { /* ignore */ } // "what failed recently" is answerable even after the file rotates.
if (level === "ERROR" || level === "WARN") {
recordRecentError(level, message, ts);
}
for (const listener of logListeners) {
try { listener(line); } catch { }
} }
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) { while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
@ -215,6 +271,9 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
} }
export const logger = { export const logger = {
// Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing
// (no formatting, no allocation) in the normal/production path.
debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {},
info: (msg: string): void => write("INFO", msg), info: (msg: string): void => write("INFO", msg),
warn: (msg: string): void => write("WARN", msg), warn: (msg: string): void => write("WARN", msg),
error: (msg: string): void => write("ERROR", msg) error: (msg: string): void => write("ERROR", msg)

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, UpdateInstallProgress } from "../shared/types"; import { AddLinksPayload, AppSettings, DebridProvider, 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";
@ -9,7 +9,6 @@ import { APP_NAME } from "./constants";
import { extractHttpLinksFromText } from "./utils"; import { extractHttpLinksFromText } from "./utils";
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor"; import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
/* ── IPC validation helpers ────────────────────────────────────── */
function validateString(value: unknown, name: string): string { function validateString(value: unknown, name: string): string {
if (typeof value !== "string") { if (typeof value !== "string") {
throw new Error(`${name} muss ein String sein`); throw new Error(`${name} muss ein String sein`);
@ -26,6 +25,17 @@ function validatePlainObject(value: unknown, name: string): Record<string, unkno
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024; const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
const RENAME_PACKAGE_MAX_CHARS = 240; const RENAME_PACKAGE_MAX_CHARS = 240;
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
"realdebrid",
"megadebrid-api",
"megadebrid-web",
"bestdebrid",
"alldebrid",
"ddownload",
"onefichier",
"debridlink",
"linksnappy"
]);
function validateStringArray(value: unknown, name: string): string[] { function validateStringArray(value: unknown, name: string): string[] {
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) { if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
throw new Error(`${name} muss ein String-Array sein`); throw new Error(`${name} muss ein String-Array sein`);
@ -33,25 +43,30 @@ function validateStringArray(value: unknown, name: string): string[] {
return value as string[]; return value as string[];
} }
/* ── Single Instance Lock ───────────────────────────────────────── */
const gotLock = app.requestSingleInstanceLock(); const gotLock = app.requestSingleInstanceLock();
if (!gotLock) { if (!gotLock) {
app.exit(0); app.exit(0);
process.exit(0); process.exit(0);
} }
/* ── Unhandled error protection ─────────────────────────────────── */
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`); logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
}); });
process.on("unhandledRejection", (reason) => { process.on("unhandledRejection", (reason) => {
logger.error(`Unhandled Rejection: ${String(reason)}`); const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason);
logger.error(`Unhandled Rejection: ${detail}`);
});
// Node-Warnungen (z.B. MaxListenersExceeded, DeprecationWarning) sind ein
// Frühindikator für Leaks/Fehlnutzung in einem langlaufenden Server-Prozess.
process.on("warning", (warning) => {
logger.warn(`Node-Warnung: ${warning.name}: ${warning.message}${warning.stack ? ` | ${warning.stack.replace(/\s*\n\s*/g, " ⏎ ")}` : ""}`);
}); });
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null; let tray: Tray | null = null;
let clipboardTimer: ReturnType<typeof setInterval> | null = null; let clipboardTimer: ReturnType<typeof setInterval> | null = null;
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null; let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
let lastClipboardText = ""; let lastClipboardText = "";
const controller = new AppController(); const controller = new AppController();
const CLIPBOARD_MAX_TEXT_CHARS = 50_000; const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
@ -62,8 +77,8 @@ function isDevMode(): boolean {
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const window = new BrowserWindow({ const window = new BrowserWindow({
width: 1440, width: 1920,
height: 940, height: 1080,
minWidth: 1120, minWidth: 1120,
minHeight: 760, minHeight: 760,
backgroundColor: "#070b14", backgroundColor: "#070b14",
@ -82,7 +97,7 @@ function createWindow(): BrowserWindow {
responseHeaders: { responseHeaders: {
...details.responseHeaders, ...details.responseHeaders,
"Content-Security-Policy": [ "Content-Security-Policy": [
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://git.24-music.de https://ddownload.com https://ddl.to" "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://git.24-music.de https://ddownload.com https://ddl.to https://debrid-link.com"
] ]
} }
}); });
@ -101,6 +116,23 @@ function createWindow(): BrowserWindow {
return window; return window;
} }
let rendererReloadTimes: number[] = [];
const RENDERER_RELOAD_WINDOW_MS = 5 * 60 * 1000;
const RENDERER_RELOAD_MAX = 3;
// Circuit breaker: recover from a one-off renderer crash by reloading, but stop
// after a few crashes in a short window so a reproducible crash can't spin into a
// reload loop that pegs an unattended server.
function allowRendererReload(): boolean {
const now = Date.now();
rendererReloadTimes = rendererReloadTimes.filter((t) => now - t < RENDERER_RELOAD_WINDOW_MS);
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
return false;
}
rendererReloadTimes.push(now);
return true;
}
function bindMainWindowLifecycle(window: BrowserWindow): void { function bindMainWindowLifecycle(window: BrowserWindow): void {
window.on("close", (event) => { window.on("close", (event) => {
const settings = controller.getSettings(); const settings = controller.getSettings();
@ -115,6 +147,33 @@ function bindMainWindowLifecycle(window: BrowserWindow): void {
mainWindow = null; mainWindow = null;
} }
}); });
window.webContents.on("render-process-gone", (_event, details) => {
logger.error(`Renderer-Prozess beendet: reason=${details.reason} exitCode=${details.exitCode ?? "?"}`);
if (details.reason === "clean-exit" || window.isDestroyed()) {
return;
}
if (allowRendererReload()) {
logger.warn("Renderer wird automatisch neu geladen (Wiederherstellung nach Absturz)");
try {
window.webContents.reload();
} catch (error) {
logger.error(`Renderer-Reload fehlgeschlagen: ${String(error)}`);
}
} else {
logger.error(`Renderer-Absturz: Auto-Reload gestoppt (mehr als ${RENDERER_RELOAD_MAX} Abstürze in ${RENDERER_RELOAD_WINDOW_MS / 60000} Min) - manueller Neustart nötig`);
}
});
// Nur protokollieren, niemals killen/neu laden: "unresponsive" feuert auch
// während legitimer langer Sync-Arbeit (große JSON-Serialisierung) und erholt
// sich meist von selbst. Eingreifen würde einen Schluckauf zum Ausfall machen.
window.webContents.on("unresponsive", () => {
logger.warn("Renderer reagiert nicht (unresponsive) - evtl. langer Sync-Task, warte auf Erholung");
});
window.webContents.on("responsive", () => {
logger.info("Renderer wieder reaktionsfähig (responsive)");
});
} }
function createTray(): void { function createTray(): void {
@ -124,7 +183,8 @@ function createTray(): void {
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico"); const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
try { try {
tray = new Tray(iconPath); tray = new Tray(iconPath);
} catch { } catch (error) {
logger.warn(`Tray-Icon konnte nicht erstellt werden (Headless/RDP/Service?): ${String(error)} - Minimize-to-Tray steht nicht zur Verfuegung, Fenster bleibt sichtbar.`);
return; return;
} }
tray.setToolTip(APP_NAME); tray.setToolTip(APP_NAME);
@ -245,7 +305,7 @@ function registerIpcHandlers(): void {
if (result.started) { if (result.started) {
updateQuitTimer = setTimeout(() => { updateQuitTimer = setTimeout(() => {
app.quit(); app.quit();
}, 2500); }, 5000);
} }
return result; return result;
}); });
@ -266,8 +326,40 @@ function registerIpcHandlers(): void {
const result = controller.updateSettings(validated as Partial<AppSettings>); const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher(); updateClipboardWatcher();
updateTray(); updateTray();
if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer);
scheduledStartTimer = null;
}
const schedMs = result.scheduledStartEpochMs || 0;
if (schedMs > 0) {
const delay = schedMs - Date.now();
if (delay <= 0) {
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
controller.updateSettings({ scheduledStartEpochMs: 0 });
} else {
scheduledStartTimer = setTimeout(() => {
scheduledStartTimer = null;
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
controller.updateSettings({ scheduledStartEpochMs: 0 });
}, delay);
}
}
return result; return result;
}); });
ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
const validatedProvider = validateString(provider, "provider") as DebridProvider;
if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) {
throw new Error("provider ist ungültig");
}
return controller.resetProviderDailyUsage(validatedProvider);
});
ipcMain.handle(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => {
const validatedKeyId = validateString(keyId, "keyId").trim();
if (!validatedKeyId) {
throw new Error("keyId ist ungültig");
}
return controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId);
});
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
validatePlainObject(payload ?? {}, "payload"); validatePlainObject(payload ?? {}, "payload");
validateString(payload?.rawText, "rawText"); validateString(payload?.rawText, "rawText");
@ -294,7 +386,14 @@ function registerIpcHandlers(): void {
return controller.resolveStartConflict(packageId, policy); return controller.resolveStartConflict(packageId, policy);
}); });
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll()); ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
ipcMain.handle(IPC_CHANNELS.START, () => controller.start()); ipcMain.handle(IPC_CHANNELS.START, () => {
if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer);
scheduledStartTimer = null;
controller.updateSettings({ scheduledStartEpochMs: 0 });
}
return controller.start();
});
ipcMain.handle(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { ipcMain.handle(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => {
validateStringArray(packageIds ?? [], "packageIds"); validateStringArray(packageIds ?? [], "packageIds");
return controller.startPackages(packageIds ?? []); return controller.startPackages(packageIds ?? []);
@ -329,6 +428,40 @@ function registerIpcHandlers(): void {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
return controller.togglePackage(packageId); return controller.togglePackage(packageId);
}); });
ipcMain.handle(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => {
const validPackageIds = validateStringArray(packageIds ?? [], "packageIds");
const exported = controller.exportPackageSelection(validPackageIds);
if (exported.packageCount === 0 || exported.linkCount === 0) {
return { saved: false, packageCount: 0, linkCount: 0 };
}
const options = {
defaultPath: exported.defaultFileName,
filters: [{ name: "Link Export", extensions: ["txt"] }]
};
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) {
return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount };
}
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
});
ipcMain.handle(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => {
const validItemIds = validateStringArray(itemIds ?? [], "itemIds");
const exported = controller.exportItemSelection(validItemIds);
if (exported.packageCount === 0 || exported.linkCount === 0) {
return { saved: false, packageCount: 0, linkCount: 0 };
}
const options = {
defaultPath: exported.defaultFileName,
filters: [{ name: "Link Export", extensions: ["txt"] }]
};
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) {
return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount };
}
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
});
ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => { ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
return controller.retryExtraction(packageId); return controller.retryExtraction(packageId);
@ -410,6 +543,8 @@ function registerIpcHandlers(): void {
return result.canceled ? [] : result.filePaths; return result.canceled ? [] : result.filePaths;
}); });
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
ipcMain.handle(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats());
ipcMain.handle(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats());
ipcMain.handle(IPC_CHANNELS.RESTART, () => { ipcMain.handle(IPC_CHANNELS.RESTART, () => {
app.relaunch(); app.relaunch();
@ -422,23 +557,51 @@ 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)}.json`, defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`,
filters: [{ name: "Backup", extensions: ["json"] }] 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);
if (result.canceled || !result.filePath) { if (result.canceled || !result.filePath) {
return { saved: false }; return { saved: false };
} }
const json = controller.exportBackup(); const encrypted = controller.exportBackup();
await fs.promises.writeFile(result.filePath, json, "utf8"); await fs.promises.writeFile(result.filePath, encrypted);
return { saved: true }; return { saved: true };
}); });
ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
const options = {
defaultPath: controller.getSupportBundleDefaultFileName(),
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
};
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) {
return { saved: false };
}
const exported = controller.exportSupportBundle();
await fs.promises.writeFile(result.filePath, exported.buffer);
return { saved: true, filePath: result.filePath };
});
ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => { ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => {
const logPath = getLogFilePath(); const logPath = getLogFilePath();
await shell.openPath(logPath); await shell.openPath(logPath);
}); });
ipcMain.handle(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => {
const logPath = controller.getAuditLogPath();
if (logPath) {
await shell.openPath(logPath);
}
});
ipcMain.handle(IPC_CHANNELS.OPEN_RENAME_LOG, async () => {
const logPath = controller.getRenameLogPath();
if (logPath) {
await shell.openPath(logPath);
}
});
ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => { ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => {
const logPath = controller.getSessionLogPath(); const logPath = controller.getSessionLogPath();
if (logPath) { if (logPath) {
@ -446,6 +609,51 @@ function registerIpcHandlers(): void {
} }
}); });
ipcMain.handle(IPC_CHANNELS.OPEN_TRACE_LOG, async () => {
const logPath = controller.getTraceLogPath();
if (logPath) {
await shell.openPath(logPath);
}
});
ipcMain.handle(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => {
validateString(packageId, "packageId");
const logPath = controller.getPackageLogPath(packageId);
if (logPath) {
await shell.openPath(logPath);
}
});
ipcMain.handle(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck());
ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig());
ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => {
if (typeof enabled !== "boolean") {
throw new Error("enabled muss ein Boolean sein");
}
if (note !== undefined) {
validateString(note, "note");
}
if (durationMinutes !== undefined && (!Number.isFinite(durationMinutes) || durationMinutes <= 0)) {
throw new Error("durationMinutes muss eine positive Zahl sein");
}
return controller.setTraceEnabled(enabled, note, durationMinutes ? durationMinutes * 60 * 1000 : undefined);
});
ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => {
const rotated = controller.rotateDebugToken();
return { path: rotated.path };
});
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
validateString(itemId, "itemId");
const logPath = controller.getItemLogPath(itemId);
if (logPath) {
await shell.openPath(logPath);
}
});
ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => { ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => {
await controller.openRealDebridLoginWindow(); await controller.openRealDebridLoginWindow();
}); });
@ -473,11 +681,24 @@ function registerIpcHandlers(): void {
return controller.getAllDebridHostInfo(); return controller.getAllDebridHostInfo();
}); });
ipcMain.handle(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => {
return controller.getDebridLinkHostLimits();
});
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => {
return controller.checkDebridAccounts();
});
ipcMain.handle(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, async (_event, login: string, password: string) => {
return controller.checkSingleMegaDebridAccount(String(login || ""), String(password || ""));
});
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => { ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => {
const options = { const options = {
properties: ["openFile"] as Array<"openFile">, properties: ["openFile"] as Array<"openFile">,
filters: [ filters: [
{ name: "Backup", extensions: ["json"] }, { name: "MDD Backup", extensions: ["mdd"] },
{ name: "Legacy Backup (JSON)", extensions: ["json"] },
{ name: "Alle Dateien", extensions: ["*"] } { name: "Alle Dateien", extensions: ["*"] }
] ]
}; };
@ -491,8 +712,26 @@ function registerIpcHandlers(): void {
if (stat.size > BACKUP_MAX_BYTES) { if (stat.size > BACKUP_MAX_BYTES) {
return { restored: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` }; return { restored: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` };
} }
const json = await fs.promises.readFile(filePath, "utf8"); const data = await fs.promises.readFile(filePath);
return controller.importBackup(json); const importResult = controller.importBackup(data);
// Only a full restore (queue swapped) needs the auto-relaunch. A settings-
// only import applied live — relaunching would be pointless and would drop
// the running queue.
if (importResult.restored && importResult.relaunch) {
setTimeout(() => {
app.relaunch();
app.quit();
}, 1500);
}
return importResult;
});
ipcMain.on(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => {
try {
logger.error(formatRendererErrorReport(rawReport));
} catch (error) {
logger.error(`[Renderer] Fehlerbericht konnte nicht verarbeitet werden: ${String(error)}`);
}
}); });
controller.onState = (snapshot) => { controller.onState = (snapshot) => {
@ -503,6 +742,41 @@ function registerIpcHandlers(): void {
}; };
} }
function formatRendererErrorReport(rawReport: unknown): string {
const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record<string, unknown>;
const str = (value: unknown): string => (typeof value === "string" ? value : "");
const num = (value: unknown): string => (typeof value === "number" && Number.isFinite(value) ? String(value) : "");
const kind = str(report.kind) || "error";
const message = (str(report.message) || "(ohne Nachricht)").slice(0, 2000);
const source = str(report.source);
const line = num(report.line);
const column = num(report.column);
const stack = str(report.stack).slice(0, 4000);
const componentStack = str(report.componentStack).slice(0, 4000);
const parts: string[] = [`[Renderer:${kind}] ${message}`];
if (source) {
parts.push(`@ ${source}${line ? `:${line}${column ? `:${column}` : ""}` : ""}`);
}
if (stack) {
parts.push(`| stack: ${stack.replace(/\s*\n\s*/g, " ⏎ ")}`);
}
if (componentStack) {
parts.push(`| react: ${componentStack.replace(/\s*\n\s*/g, " ⏎ ")}`);
}
return parts.join(" ");
}
app.on("child-process-gone", (_event, details) => {
const killed = details.reason !== "clean-exit" && details.reason !== "killed";
const line = `Subprozess beendet: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? "?"}${details.name ? ` name=${details.name}` : ""}${details.serviceName ? ` service=${details.serviceName}` : ""}`;
if (killed) {
logger.error(line);
} else {
logger.warn(line);
}
});
app.on("second-instance", () => { app.on("second-instance", () => {
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) { if (mainWindow.isMinimized()) {

129
src/main/mega-public-api.ts Normal file
View File

@ -0,0 +1,129 @@
import crypto from "node:crypto";
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
const MEGA_API_TIMEOUT_MS = 12_000;
export interface MegaFileInfo {
name: string;
size: number;
}
const NEW_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/file\/([A-Za-z0-9_-]+)#([A-Za-z0-9_-]+)/i;
const LEGACY_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/#!([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)/i;
export function isMegaFileUrl(url: string): boolean {
const s = String(url || "").trim();
return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s);
}
function base64UrlDecode(s: string): Buffer | null {
let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4 !== 0) b64 += "=";
try {
return Buffer.from(b64, "base64");
} catch {
return null;
}
}
export interface ParsedMegaLink {
id: string;
rawKey: Buffer;
}
export function parseMegaUrl(url: string): ParsedMegaLink | null {
const s = String(url || "").trim();
const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s);
if (!m) return null;
const id = m[1];
const rawKey = base64UrlDecode(m[2]);
if (!rawKey || rawKey.length !== 32) return null;
return { id, rawKey };
}
export function decryptMegaAttributes(encrypted: Buffer, aesKey: Buffer): Record<string, unknown> | null {
if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length % 16 !== 0) return null;
if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null;
let plain: Buffer;
try {
const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
decipher.setAutoPadding(false);
plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
} catch {
return null;
}
const text = plain.toString("utf8").replace(/\0+$/, "").trim();
if (!text.startsWith("MEGA{")) return null;
try {
return JSON.parse(text.slice(4));
} catch {
return null;
}
}
function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs);
if (parent) {
if (parent.aborted) {
controller.abort(parent.reason);
} else {
parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
}
}
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
return controller.signal;
}
export async function resolveMegaFilename(
url: string,
signal?: AbortSignal
): Promise<MegaFileInfo | null> {
const parsed = parseMegaUrl(url);
if (!parsed) return null;
const aesKey = parsed.rawKey.subarray(0, 16);
const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`;
const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]);
let response: Response;
try {
response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS)
});
} catch {
return null;
}
if (!response.ok) return null;
let payload: unknown;
try {
payload = await response.json();
} catch {
return null;
}
if (typeof payload === "number") return null;
if (!Array.isArray(payload) || payload.length === 0) return null;
const first = payload[0];
if (typeof first === "number") return null;
if (!first || typeof first !== "object") return null;
const info = first as { s?: unknown; at?: unknown; e?: unknown };
if (typeof info.e === "number" && info.e !== 0) return null;
const size = typeof info.s === "number" && info.s > 0 ? info.s : 0;
if (typeof info.at !== "string" || !info.at.trim()) return null;
const encryptedAttrs = base64UrlDecode(info.at);
if (!encryptedAttrs) return null;
const attrs = decryptMegaAttributes(encryptedAttrs, aesKey);
if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null;
return { name: attrs.n.trim(), size };
}

View File

@ -16,6 +16,8 @@ const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid";
const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json"; const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json";
const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"; const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de";
export const MEGA_DEBRID_NO_SERVER_RE = /kein server f(?:ü|u)r diesen hoster|no server (?:is )?available for this host|aucun serveur disponible/i;
function normalizeLink(link: string): string { function normalizeLink(link: string): string {
return link.trim().toLowerCase(); return link.trim().toLowerCase();
} }
@ -219,43 +221,38 @@ export class MegaWebFallback {
private getCredentials: () => MegaCredentials; private getCredentials: () => MegaCredentials;
private cookie = ""; private sessions = new Map<string, { cookie: string; setAt: number }>();
private cookieSetAt = 0;
public constructor(getCredentials: () => MegaCredentials) { public constructor(getCredentials: () => MegaCredentials) {
this.getCredentials = getCredentials; this.getCredentials = getCredentials;
} }
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> { public async unrestrict(
link: string,
signal?: AbortSignal,
account?: { login: string; password: string }
): Promise<UnrestrictedLink | null> {
const overallSignal = withTimeoutSignal(signal, 180000); const overallSignal = withTimeoutSignal(signal, 180000);
return this.runExclusive(async () => { return this.runExclusive(async () => {
throwIfAborted(overallSignal); throwIfAborted(overallSignal);
const creds = this.getCredentials(); const creds = (account && account.login.trim() && account.password.trim())
? account
: this.getCredentials();
if (!creds.login.trim() || !creds.password.trim()) { if (!creds.login.trim() || !creds.password.trim()) {
return null; return null;
} }
const key = creds.login.trim().toLowerCase();
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
if (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) { let generated = await this.generate(link, cookie, overallSignal);
await this.login(creds.login, creds.password, overallSignal);
}
const generated = await this.generate(link, overallSignal);
if (!generated) { if (!generated) {
this.cookie = ""; this.sessions.delete(key);
await this.login(creds.login, creds.password, overallSignal); cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
const retry = await this.generate(link, overallSignal); generated = await this.generate(link, cookie, overallSignal);
if (!retry) { if (!generated) {
return null; return null;
} }
return {
directUrl: retry.directUrl,
fileName: retry.fileName || filenameFromUrl(link),
fileSize: null,
retriesUsed: 0
};
} }
return { return {
directUrl: generated.directUrl, directUrl: generated.directUrl,
fileName: generated.fileName || filenameFromUrl(link), fileName: generated.fileName || filenameFromUrl(link),
@ -265,9 +262,18 @@ export class MegaWebFallback {
}, overallSignal); }, overallSignal);
} }
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
const existing = this.sessions.get(key);
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
return existing.cookie;
}
const cookie = await this.login(login, password, signal);
this.sessions.set(key, { cookie, setAt: Date.now() });
return cookie;
}
public invalidateSession(): void { public invalidateSession(): void {
this.cookie = ""; this.sessions.clear();
this.cookieSetAt = 0;
} }
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> { private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
@ -286,7 +292,7 @@ export class MegaWebFallback {
return raceWithAbort(run, signal); return raceWithAbort(run, signal);
} }
private async login(login: string, password: string, signal?: AbortSignal): Promise<void> { private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
throwIfAborted(signal); throwIfAborted(signal);
const response = await fetch(LOGIN_URL, { const response = await fetch(LOGIN_URL, {
method: "POST", method: "POST",
@ -323,18 +329,17 @@ export class MegaWebFallback {
throw new Error("Mega-Web Login ungültig oder Session blockiert"); throw new Error("Mega-Web Login ungültig oder Session blockiert");
} }
this.cookie = cookie; return cookie;
this.cookieSetAt = Date.now();
} }
private async generate(link: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> { private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
throwIfAborted(signal); throwIfAborted(signal);
const page = await fetch(DEBRID_URL, { const page = await fetch(DEBRID_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0", "User-Agent": "Mozilla/5.0",
Cookie: this.cookie, Cookie: cookie,
Referer: DEBRID_REFERER Referer: DEBRID_REFERER
}, },
body: new URLSearchParams({ body: new URLSearchParams({
@ -347,13 +352,17 @@ export class MegaWebFallback {
const html = await page.text(); const html = await page.text();
// Check for permanent hoster errors before looking for debrid codes
const pageErrors = parsePageErrors(html); const pageErrors = parsePageErrors(html);
const permanentError = isPermanentHosterError(pageErrors); const permanentError = isPermanentHosterError(pageErrors);
if (permanentError) { if (permanentError) {
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`); throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
} }
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
if (noServerError) {
throw new Error(`Mega-Web: ${noServerError}`);
}
const code = pickCode(parseCodes(html), link); const code = pickCode(parseCodes(html), link);
if (!code) { if (!code) {
return null; return null;
@ -366,7 +375,7 @@ export class MegaWebFallback {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0", "User-Agent": "Mozilla/5.0",
Cookie: this.cookie, Cookie: cookie,
Referer: DEBRID_REFERER Referer: DEBRID_REFERER
}, },
body: new URLSearchParams({ body: new URLSearchParams({
@ -395,6 +404,10 @@ export class MegaWebFallback {
await sleepWithSignal(1200, signal); await sleepWithSignal(1200, signal);
continue; continue;
} }
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) {
throw new Error(`Mega-Web: ${serverMsg}`);
}
return null; return null;
} }
@ -415,7 +428,7 @@ export class MegaWebFallback {
} }
public dispose(): void { public dispose(): void {
this.cookie = ""; this.sessions.clear();
} }
} }

230
src/main/package-log.ts Normal file
View File

@ -0,0 +1,230 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
import crypto from "node:crypto";
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
const PACKAGE_LOG_RETENTION_DAYS = 30;
type PackageLogLevel = "INFO" | "WARN" | "ERROR";
export interface PackageLogMeta {
packageId: string;
name: string;
outputDir: string;
extractDir: string;
}
let packageLogsDir: string | null = null;
const knownLogPaths = new Map<string, string>();
const pendingLinesByPackage = new Map<string, string[]>();
const initializedThisProcess = new Set<string>();
let flushTimer: NodeJS.Timeout | null = null;
function normalizePackageId(packageId: string): string {
const trimmed = String(packageId || "").trim();
if (!trimmed) {
return "";
}
const safePrefix = trimmed
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/_+/g, "_")
.slice(0, 64)
.replace(/^_+|_+$/g, "");
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
return `${safePrefix || "pkg"}_${hash}`;
}
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function getPackageLogFilePathFromNormalized(normalized: string): string | null {
if (!normalized || !packageLogsDir) {
return null;
}
const existing = knownLogPaths.get(normalized);
if (existing) {
return existing;
}
const logPath = path.join(packageLogsDir, `package_${normalized}.txt`);
knownLogPaths.set(normalized, logPath);
return logPath;
}
function getPackageLogFilePath(packageId: string): string | null {
return getPackageLogFilePathFromNormalized(normalizePackageId(packageId));
}
function flushPending(): void {
for (const [packageId, lines] of pendingLinesByPackage.entries()) {
if (lines.length === 0) {
continue;
}
const logPath = getPackageLogFilePathFromNormalized(packageId);
if (!logPath) {
continue;
}
const chunk = lines.join("");
pendingLinesByPackage.set(packageId, []);
try {
fs.appendFileSync(logPath, chunk, "utf8");
} catch {
}
}
}
function scheduleFlush(): void {
if (flushTimer) {
return;
}
flushTimer = setTimeout(() => {
flushTimer = null;
flushPending();
}, PACKAGE_LOG_FLUSH_INTERVAL_MS);
}
async function cleanupOldPackageLogs(dir: string): Promise<void> {
try {
const files = await fs.promises.readdir(dir);
const cutoff = Date.now() - PACKAGE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
for (const file of files) {
if (!file.startsWith("package_") || !file.endsWith(".txt")) {
continue;
}
const filePath = path.join(dir, file);
try {
const stat = await fs.promises.stat(filePath);
if (stat.mtimeMs < cutoff) {
await fs.promises.unlink(filePath);
}
} catch {
}
}
} catch {
}
}
function appendLine(packageId: string, line: string): void {
const normalized = normalizePackageId(packageId);
if (!normalized) {
return;
}
const lines = pendingLinesByPackage.get(normalized) || [];
lines.push(line);
pendingLinesByPackage.set(normalized, lines);
scheduleFlush();
}
export function initPackageLogs(baseDir: string): void {
packageLogsDir = path.join(baseDir, "package-logs");
try {
fs.mkdirSync(packageLogsDir, { recursive: true });
} catch {
packageLogsDir = null;
return;
}
void cleanupOldPackageLogs(packageLogsDir);
}
export function ensurePackageLog(meta: PackageLogMeta): string | null {
const normalizedPackageId = normalizePackageId(meta.packageId);
const logPath = getPackageLogFilePath(meta.packageId);
if (!logPath) {
return null;
}
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
if (!fs.existsSync(logPath)) {
fs.writeFileSync(logPath, "", "utf8");
}
if (!initializedThisProcess.has(normalizedPackageId)) {
initializedThisProcess.add(normalizedPackageId);
const startedAt = logTimestamp();
fs.appendFileSync(
logPath,
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
"utf8"
);
fs.appendFileSync(
logPath,
`${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({
name: meta.name,
outputDir: meta.outputDir,
extractDir: meta.extractDir
})}\n`,
"utf8"
);
}
} catch {
return null;
}
return logPath;
}
export function logPackageEvent(
packageId: string,
level: PackageLogLevel,
message: string,
fields?: Record<string, unknown>
): void {
const logPath = getPackageLogFilePath(packageId);
if (!logPath) {
return;
}
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
appendLine(packageId, line);
}
export function getPackageLogPath(packageId: string): string | null {
const logPath = getPackageLogFilePath(packageId);
if (!logPath) {
return null;
}
return fs.existsSync(logPath) ? logPath : null;
}
export function shutdownPackageLogs(): void {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushPending();
for (const packageId of knownLogPaths.keys()) {
const logPath = getPackageLogFilePathFromNormalized(packageId);
if (!logPath) {
continue;
}
try {
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch {
}
}
pendingLinesByPackage.clear();
knownLogPaths.clear();
initializedThisProcess.clear();
packageLogsDir = null;
}

View File

@ -79,6 +79,31 @@ function looksLikeHtmlResponse(text: string): boolean {
return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML"); return trimmed.startsWith("<!") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
} }
export function extractPrivateTokenFromHtml(html: string): string | null {
const normalized = String(html || "");
if (!normalized.trim()) {
return null;
}
const patterns = [
/private_token['"]\]\[0\]\.value\s*=\s*['"]([^'"]+)['"]/i,
/getElementsByName\(\s*['"]private_token['"]\s*\)\s*\[\s*0\s*\]\.value\s*=\s*['"]([^'"]+)['"]/i,
/querySelector(?:All)?\(\s*['"][^'"]*private_token[^'"]*['"]\s*\)(?:\s*\[\s*0\s*\])?\.value\s*=\s*['"]([^'"]+)['"]/i,
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
];
for (const pattern of patterns) {
const match = normalized.match(pattern);
const token = match?.[1]?.trim();
if (token) {
return token;
}
}
return null;
}
export class RealDebridWebFallback { export class RealDebridWebFallback {
private queue: Promise<unknown> = Promise.resolve(); private queue: Promise<unknown> = Promise.resolve();
@ -119,6 +144,7 @@ export class RealDebridWebFallback {
} }
window.show(); window.show();
window.focus(); window.focus();
void this.primeTokenFromWindow(window);
} }
public async clearSessions(): Promise<void> { public async clearSessions(): Promise<void> {
@ -132,12 +158,10 @@ export class RealDebridWebFallback {
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
}); });
} catch { } catch {
// ignore
} }
try { try {
await currentSession.clearCache(); await currentSession.clearCache();
} catch { } catch {
// ignore
} }
} }
} }
@ -161,7 +185,7 @@ export class RealDebridWebFallback {
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> { private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now(); const queuedAt = Date.now();
const queueWaitTimeoutMs = 90_000; const queueWaitTimeoutMs = 10 * 60 * 1000 + 30_000;
const guardedJob = async (): Promise<T> => { const guardedJob = async (): Promise<T> => {
throwIfAborted(signal); throwIfAborted(signal);
const waited = Date.now() - queuedAt; const waited = Date.now() - queuedAt;
@ -201,6 +225,15 @@ export class RealDebridWebFallback {
}); });
window.setMenuBarVisibility(false); window.setMenuBarVisibility(false);
window.webContents.setUserAgent(RD_USER_AGENT); window.webContents.setUserAgent(RD_USER_AGENT);
const primeFromWindow = (): void => {
void this.primeTokenFromWindow(window);
};
window.webContents.on("did-finish-load", primeFromWindow);
window.webContents.on("did-navigate", primeFromWindow);
window.webContents.on("did-navigate-in-page", primeFromWindow);
window.on("close", () => {
void this.primeTokenFromWindow(window);
});
window.on("closed", () => { window.on("closed", () => {
if (this.loginWindow === window) { if (this.loginWindow === window) {
this.loginWindow = null; this.loginWindow = null;
@ -213,14 +246,105 @@ export class RealDebridWebFallback {
return window; return window;
} }
private rememberToken(token: string): string {
this.cachedToken = token;
this.cachedTokenAt = Date.now();
return token;
}
private getActiveLoginWindow(): BrowserWindow | null {
const window = this.loginWindow;
if (!window || window.isDestroyed()) {
return null;
}
if (this.loginWindowPartition !== this.getPartition()) {
return null;
}
return window;
}
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise<string | null> {
throwIfAborted(signal);
try {
const rawResult = await window.webContents.executeJavaScript(`
(async () => {
const readTokenFromHtml = (html) => {
const text = String(html || "");
const patterns = [
/private_token['"]\\]\\[0\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
/getElementsByName\\(\\s*['"]private_token['"]\\s*\\)\\s*\\[\\s*0\\s*\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
/querySelector(?:All)?\\(\\s*['"][^'"]*private_token[^'"]*['"]\\s*\\)(?:\\s*\\[\\s*0\\s*\\])?\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match && match[1]) {
return String(match[1]).trim();
}
}
return "";
};
const directInput = document.querySelector('input[name="private_token"]');
if (directInput instanceof HTMLInputElement && directInput.value.trim()) {
return directInput.value.trim();
}
const html = document.documentElement ? document.documentElement.outerHTML : "";
const directToken = readTokenFromHtml(html);
if (directToken) {
return directToken;
}
try {
const response = await fetch(${JSON.stringify(RD_APITOKEN_URL)}, {
credentials: "include",
cache: "no-store",
headers: {
"X-Requested-With": "XMLHttpRequest"
}
});
const tokenHtml = await response.text();
return readTokenFromHtml(tokenHtml);
} catch {
return "";
}
})();
`, true);
const token = String(rawResult || "").trim();
if (token) {
return this.rememberToken(token);
}
} catch {
}
return null;
}
private async primeTokenFromWindow(window: BrowserWindow): Promise<void> {
try {
await this.extractApiTokenFromWindow(window);
} catch {
}
}
private async extractApiToken(signal?: AbortSignal): Promise<string | null> { private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
throwIfAborted(signal); throwIfAborted(signal);
// Return cached token if fresh (max 30 min)
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) { if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
return this.cachedToken; return this.cachedToken;
} }
const activeLoginWindow = this.getActiveLoginWindow();
if (activeLoginWindow) {
const windowToken = await this.extractApiTokenFromWindow(activeLoginWindow, signal);
if (windowToken) {
return windowToken;
}
}
const currentSession = session.fromPartition(this.getPartition()); const currentSession = session.fromPartition(this.getPartition());
const response = await currentSession.fetch(RD_APITOKEN_URL, { const response = await currentSession.fetch(RD_APITOKEN_URL, {
headers: { headers: {
@ -236,21 +360,9 @@ export class RealDebridWebFallback {
return null; return null;
} }
// Real-Debrid sets the token via inline JS: const token = extractPrivateTokenFromHtml(html);
// document.querySelectorAll('input[name=private_token]')[0].value = 'TOKEN_HERE'; if (token) {
const tokenMatch = html.match(/private_token['"]\]\[0\]\.value\s*=\s*'([^']+)'/); return this.rememberToken(token);
if (tokenMatch && tokenMatch[1]) {
this.cachedToken = tokenMatch[1];
this.cachedTokenAt = Date.now();
return this.cachedToken;
}
// Fallback: look for the token in an input value attribute
const inputMatch = html.match(/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/);
if (inputMatch && inputMatch[1]) {
this.cachedToken = inputMatch[1];
this.cachedTokenAt = Date.now();
return this.cachedToken;
} }
return null; return null;
@ -282,7 +394,6 @@ export class RealDebridWebFallback {
const text = await response.text(); const text = await response.text();
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
// Token expired or revoked — invalidate cache
this.cachedToken = ""; this.cachedToken = "";
this.cachedTokenAt = 0; this.cachedTokenAt = 0;
return { kind: "login_required" }; return { kind: "login_required" };

View File

@ -10,6 +10,8 @@ export interface UnrestrictedLink {
retriesUsed: number; retriesUsed: number;
skipTlsVerify?: boolean; skipTlsVerify?: boolean;
sourceLabel?: string; sourceLabel?: string;
sourceAccountId?: string;
sourceAccountLabel?: string;
} }
function shouldRetryStatus(status: number): boolean { function shouldRetryStatus(status: number): boolean {
@ -80,8 +82,6 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
await sleep(ms); await sleep(ms);
return; return;
} }
// Check before entering the Promise constructor to avoid a race where the timer
// resolves before the aborted check runs (especially when ms=0).
if (signal.aborted) { if (signal.aborted) {
throw new Error("aborted"); throw new Error("aborted");
} }

119
src/main/rename-log.ts Normal file
View File

@ -0,0 +1,119 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
type RenameLogLevel = "INFO" | "WARN" | "ERROR";
const RENAME_LOG_MAX_FILE_BYTES = Number(process.env.RD_RENAME_LOG_MAX_BYTES || 10 * 1024 * 1024);
const RENAME_LOG_RETENTION_DAYS = Number(process.env.RD_RENAME_LOG_RETENTION_DAYS || 30);
let renameLogPath: string | null = null;
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function rotateIfNeeded(filePath: string): void {
try {
const stat = fs.statSync(filePath);
if (stat.size < RENAME_LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
}
fs.renameSync(filePath, backup);
} catch {
}
}
function cleanupOldBackup(filePath: string): void {
const backup = `${filePath}.old`;
try {
const stat = fs.statSync(backup);
const cutoff = Date.now() - RENAME_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (stat.mtimeMs < cutoff) {
fs.rmSync(backup, { force: true });
}
} catch {
}
}
export function initRenameLog(baseDir: string): void {
renameLogPath = path.join(baseDir, "rename.log");
try {
fs.mkdirSync(path.dirname(renameLogPath), { recursive: true });
cleanupOldBackup(renameLogPath);
if (!fs.existsSync(renameLogPath)) {
fs.writeFileSync(renameLogPath, "", "utf8");
}
rotateIfNeeded(renameLogPath);
if (!fs.existsSync(renameLogPath)) {
fs.writeFileSync(renameLogPath, "", "utf8");
}
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${logTimestamp()} ===\n`, "utf8");
} catch {
renameLogPath = null;
}
}
export function logRenameEvent(level: RenameLogLevel, message: string, fields?: Record<string, unknown>): void {
if (!renameLogPath) {
return;
}
try {
rotateIfNeeded(renameLogPath);
if (!fs.existsSync(renameLogPath)) {
fs.writeFileSync(renameLogPath, "", "utf8");
}
fs.appendFileSync(
renameLogPath,
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
"utf8"
);
} catch {
}
}
export function getRenameLogPath(): string | null {
if (!renameLogPath) {
return null;
}
return fs.existsSync(renameLogPath) ? renameLogPath : null;
}
export function shutdownRenameLog(): void {
if (!renameLogPath) {
return;
}
try {
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch {
}
renameLogPath = null;
}

View File

@ -1,4 +1,5 @@
import fs from "node:fs"; import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path"; import path from "node:path";
import { setLogListener } from "./logger"; import { setLogListener } from "./logger";
@ -29,7 +30,6 @@ function flushPending(): void {
try { try {
fs.appendFileSync(sessionLogPath, chunk, "utf8"); fs.appendFileSync(sessionLogPath, chunk, "utf8");
} catch { } catch {
// ignore write errors
} }
} }
@ -66,11 +66,9 @@ async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<v
await fs.promises.unlink(filePath); await fs.promises.unlink(filePath);
} }
} catch { } catch {
// ignore - file may be locked
} }
} }
} catch { } catch {
// ignore - dir may not exist
} }
} }
@ -86,7 +84,7 @@ export function initSessionLog(baseDir: string): void {
const timestamp = formatTimestamp(); const timestamp = formatTimestamp();
sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`); sessionLogPath = path.join(sessionLogsDir, `session_${timestamp}.txt`);
const isoTimestamp = new Date().toISOString(); const isoTimestamp = logTimestamp();
try { try {
fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8"); fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8");
} catch { } catch {
@ -108,19 +106,16 @@ export function shutdownSessionLog(): void {
return; return;
} }
// Flush any pending lines
if (flushTimer) { if (flushTimer) {
clearTimeout(flushTimer); clearTimeout(flushTimer);
flushTimer = null; flushTimer = null;
} }
flushPending(); flushPending();
// Write closing line const isoTimestamp = logTimestamp();
const isoTimestamp = new Date().toISOString();
try { try {
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8"); fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
setLogListener(null); setLogListener(null);

View File

@ -0,0 +1,195 @@
import fs from "node:fs";
import path from "node:path";
import { AppSettings } from "../shared/types";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { StoragePaths } from "./storage";
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
export interface HealthCheckFinding {
severity: HealthCheckSeverity;
code: string;
message: string;
hint?: string;
}
export interface HealthCheckReport {
findings: HealthCheckFinding[];
errorCount: number;
warnCount: number;
infoCount: number;
}
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
function safeExists(p: string): boolean {
try {
return fs.existsSync(p);
} catch {
return false;
}
}
function getFileSizeBytes(p: string): number {
try {
const stat = fs.statSync(p);
return stat.size;
} catch {
return 0;
}
}
function isWritable(dir: string): boolean {
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
try {
fs.writeFileSync(probe, "x", { encoding: "utf8" });
fs.rmSync(probe, { force: true });
return true;
} catch {
return false;
}
}
function getFreeDiskSpaceBytes(target: string): number | null {
try {
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
if (typeof statfs !== "function") {
return null;
}
const result = statfs(target);
const bavail = BigInt(result.bavail);
const bsize = BigInt(result.bsize);
const free = bavail * bsize;
if (free > BigInt(Number.MAX_SAFE_INTEGER)) {
return Number.MAX_SAFE_INTEGER;
}
return Number(free);
} catch {
return null;
}
}
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
const providers: string[] = [];
if (settings.token?.trim() || settings.realDebridUseWebLogin) {
providers.push("Real-Debrid");
}
if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) {
providers.push("AllDebrid");
}
if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) {
providers.push("BestDebrid");
}
if (settings.oneFichierApiKey?.trim()) {
providers.push("1Fichier");
}
if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) {
providers.push("DDownload");
}
if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) {
providers.push("LinkSnappy");
}
const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
if (dlKeys.length > 0) {
providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`);
}
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim());
if (megaAccounts.length > 0) {
providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`);
} else if (legacyMegaConfigured) {
providers.push("Mega-Debrid");
}
return { count: providers.length, providers };
}
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
const findings: HealthCheckFinding[] = [];
const outputDir = String(settings.outputDir || "").trim();
if (!outputDir) {
findings.push({
severity: "WARN",
code: "outputDir_missing",
message: "Kein Download-Ziel-Verzeichnis konfiguriert",
hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten."
});
} else if (!safeExists(outputDir)) {
findings.push({
severity: "WARN",
code: "outputDir_not_found",
message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`,
hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist."
});
} else if (!isWritable(outputDir)) {
findings.push({
severity: "ERROR",
code: "outputDir_not_writable",
message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`,
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
});
} else {
const freeBytes = getFreeDiskSpaceBytes(outputDir);
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
const freeMb = Math.round(freeBytes / (1024 * 1024));
findings.push({
severity: "WARN",
code: "low_disk_space",
message: `Wenig freier Speicher im Download-Ordner: ~${freeMb} MB verfuegbar (Schwelle ${LOW_DISK_SPACE_BYTES / (1024 * 1024 * 1024)} GB)`,
hint: "Groessere Downloads koennen auf halbem Weg fehlschlagen. Vorher Platz schaffen oder anderen Ordner waehlen."
});
}
}
const { count, providers } = countConfiguredProviders(settings);
if (count === 0) {
findings.push({
severity: "WARN",
code: "no_provider_configured",
message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren",
hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten."
});
} else {
findings.push({
severity: "INFO",
code: "providers_configured",
message: `Konfigurierte Provider: ${providers.join(", ")}`
});
}
if (safeExists(storagePaths.sessionFile)) {
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
const sizeMb = Math.round(sizeBytes / (1024 * 1024));
findings.push({
severity: "WARN",
code: "large_state_file",
message: `State-Datei ist sehr gross: ${sizeMb} MB (${path.basename(storagePaths.sessionFile)})`,
hint: "Alte abgeschlossene Pakete aus der Queue entfernen, damit Startup + Save schneller werden."
});
}
}
if (!safeExists(storagePaths.baseDir)) {
findings.push({
severity: "ERROR",
code: "baseDir_missing",
message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`,
hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden."
});
} else if (!isWritable(storagePaths.baseDir)) {
findings.push({
severity: "ERROR",
code: "baseDir_not_writable",
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)."
});
}
const errorCount = findings.filter((f) => f.severity === "ERROR").length;
const warnCount = findings.filter((f) => f.severity === "WARN").length;
const infoCount = findings.filter((f) => f.severity === "INFO").length;
return { findings, errorCount, warnCount, infoCount };
}

View File

@ -1,29 +1,65 @@
import fs from "node:fs"; import fs from "node:fs";
import fsp from "node:fs/promises"; import fsp from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, PackageEntry, PackagePriority, SessionState } from "../shared/types"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts";
import { AppSettings, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import { defaultSettings } from "./constants"; import { defaultSettings } from "./constants";
import { logger } from "./logger"; import { logger } from "./logger";
const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier"]); const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier"]); const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"]);
const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]); const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]); const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]); const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
const VALID_SPEED_MODES = new Set(["global", "per_download"]); const VALID_SPEED_MODES = new Set(["global", "per_download"]);
const VALID_THEMES = new Set(["dark", "light"]); const VALID_THEMES = new Set(["dark", "light"]);
const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]); const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]);
const VALID_HISTORY_RETENTION_MODES = new Set<HistoryRetentionMode>(["never", "session", "permanent"]);
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]); const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([ const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
]); ]);
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload", "onefichier"]); const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
function asText(value: unknown): string { function asText(value: unknown): string {
return String(value ?? "").trim(); return String(value ?? "").trim();
} }
function normalizeSessionId(value: unknown): string {
const text = asText(value);
if (!text || !SAFE_SESSION_ID_RE.test(text)) {
return "";
}
return text;
}
function isPathInsideDir(filePath: string, dirPath: string): boolean {
try {
const resolvedFile = path.resolve(filePath);
const resolvedDir = path.resolve(dirPath);
const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile;
const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir;
return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`);
} catch {
return false;
}
}
function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string {
const targetPath = asText(value);
if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) {
return "";
}
if (!isPathInsideDir(targetPath, packageOutputDir)) {
return "";
}
return path.resolve(targetPath);
}
function clampNumber(value: unknown, fallback: number, min: number, max: number): number { function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
const num = Number(value); const num = Number(value);
if (!Number.isFinite(num)) { if (!Number.isFinite(num)) {
@ -84,13 +120,214 @@ function normalizeColumnOrder(raw: unknown): string[] {
result.push(col); result.push(col);
} }
} }
// "name" is mandatory — ensure it's always present
if (!seen.has("name")) { if (!seen.has("name")) {
result.unshift("name"); result.unshift("name");
} }
return result; return result;
} }
function getPreferredMegaDebridProvider(megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridProvider {
if (megaDebridApiEnabled && !megaDebridWebEnabled) {
return "megadebrid-api";
}
if (megaDebridWebEnabled && !megaDebridApiEnabled) {
return "megadebrid-web";
}
return megaDebridPreferApi ? "megadebrid-api" : "megadebrid-web";
}
function normalizeConfiguredProvider(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridProvider | null {
const provider = String(raw ?? "").trim();
if (!provider) {
return null;
}
if (provider === "megadebrid") {
return getPreferredMegaDebridProvider(megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
}
return VALID_PRIMARY_PROVIDERS.has(provider) ? provider as DebridProvider : null;
}
function normalizeFallbackProvider(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridFallbackProvider {
const provider = String(raw ?? "").trim();
if (!provider || provider === "none") {
return "none";
}
const normalized = normalizeConfiguredProvider(provider, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
return normalized || "none";
}
function normalizeDisabledProviders(raw: unknown): DebridProvider[] {
if (!Array.isArray(raw)) {
return [];
}
const seen = new Set<DebridProvider>();
const result: DebridProvider[] = [];
for (const entry of raw) {
const provider = String(entry ?? "").trim();
const candidates: DebridProvider[] = provider === "megadebrid"
? ["megadebrid-api", "megadebrid-web"]
: (VALID_PRIMARY_PROVIDERS.has(provider) ? [provider as DebridProvider] : []);
for (const candidate of candidates) {
if (seen.has(candidate)) {
continue;
}
seen.add(candidate);
result.push(candidate);
}
}
return result;
}
function normalizeProviderByteMap(
raw: unknown,
megaDebridPreferApi: boolean,
megaDebridApiEnabled: boolean,
megaDebridWebEnabled: boolean,
mergeMode: "max" | "sum"
): Partial<Record<DebridProvider, number>> {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const result: Partial<Record<DebridProvider, number>> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
const provider = normalizeConfiguredProvider(key, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
if (!provider) {
continue;
}
const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER);
if (bytes <= 0) {
continue;
}
if (mergeMode === "sum") {
result[provider] = (result[provider] || 0) + bytes;
} else {
result[provider] = Math.max(result[provider] || 0, bytes);
}
}
return result;
}
function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Record<string, number> {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const allowed = new Set(allowedKeys);
const result: Record<string, number> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
const normalizedKey = String(key || "").trim();
if (!normalizedKey || !allowed.has(normalizedKey)) {
continue;
}
const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER);
if (bytes <= 0) {
continue;
}
result[normalizedKey] = bytes;
}
return result;
}
function normalizeDebridAccountStatuses(
value: unknown,
megaIds: string[],
debridLinkIds: string[]
): Record<string, DebridAccountStatus> {
const allowed = new Set([...megaIds, ...debridLinkIds]);
const result: Record<string, DebridAccountStatus> = {};
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
if (!allowed.has(key) || !raw || typeof raw !== "object") {
continue;
}
const entry = raw as Partial<DebridAccountStatus>;
if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") {
continue;
}
result[key] = {
accountId: entry.accountId,
provider: entry.provider === "debridlink" ? "debridlink" : "megadebrid",
label: String(entry.label || ""),
maskedLogin: String(entry.maskedLogin || ""),
valid: Boolean(entry.valid),
isPremium: Boolean(entry.isPremium),
premiumUntilMs: typeof entry.premiumUntilMs === "number" ? entry.premiumUntilMs : null,
email: typeof entry.email === "string" ? entry.email : undefined,
message: String(entry.message || ""),
checkedAt: entry.checkedAt
};
}
}
return result;
}
function normalizeStringList(raw: unknown, allowedKeys: readonly string[]): string[] {
if (!Array.isArray(raw)) {
return [];
}
const allowed = new Set(allowedKeys);
const seen = new Set<string>();
const result: string[] = [];
for (const entry of raw) {
const normalized = String(entry || "").trim();
if (!normalized || !allowed.has(normalized) || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
function normalizeHosterRouting(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): Record<string, DebridProvider> {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const result: Record<string, DebridProvider> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
const hoster = String(key).trim().toLowerCase();
const provider = normalizeConfiguredProvider(value, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
if (hoster && provider) {
result[hoster] = provider;
}
}
return result;
}
function normalizeProviderOrder(
raw: unknown,
megaDebridPreferApi: boolean,
megaDebridApiEnabled: boolean,
megaDebridWebEnabled: boolean,
legacyPrimary: unknown,
legacySecondary: unknown,
legacyTertiary: unknown
): DebridProvider[] {
let list: unknown[] = [];
if (Array.isArray(raw) && raw.length > 0) {
list = raw;
} else {
const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter(
(v) => v && String(v).trim() && String(v).trim() !== "none"
);
if (candidates.length > 0) {
list = candidates;
}
}
const seen = new Set<DebridProvider>();
const result: DebridProvider[] = [];
for (const entry of list) {
const provider = normalizeConfiguredProvider(entry, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
if (provider && !seen.has(provider)) {
seen.add(provider);
result.push(provider);
}
}
return result;
}
const DEPRECATED_UPDATE_REPOS = new Set([ const DEPRECATED_UPDATE_REPOS = new Set([
"sucukdeluxe/real-debrid-downloader" "sucukdeluxe/real-debrid-downloader"
]); ]);
@ -105,12 +342,59 @@ function migrateUpdateRepo(raw: string, fallback: string): string {
export function normalizeSettings(settings: AppSettings): AppSettings { export function normalizeSettings(settings: AppSettings): AppSettings {
const defaults = defaultSettings(); const defaults = defaultSettings();
const currentUsageDay = getProviderUsageDayKey();
const megaLogin = asText(settings.megaLogin);
const megaPassword = asText(settings.megaPassword);
let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
if (!megaCredentials && megaLogin && megaPassword) {
megaCredentials = `${megaLogin}:${megaPassword}`;
}
const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials);
const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true;
const hasMegaCreds = Boolean(megaLogin && megaPassword);
const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined
? Boolean(settings.megaDebridApiEnabled)
: (hasMegaCreds ? megaDebridPreferApi : defaults.megaDebridApiEnabled);
const megaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined
? Boolean(settings.megaDebridWebEnabled)
: (hasMegaCreds ? !megaDebridPreferApi : defaults.megaDebridWebEnabled);
const providerDailyUsageDayRaw = asText(settings.providerDailyUsageDay);
const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw)
? providerDailyUsageDayRaw
: currentUsageDay;
const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? ""));
const providerDailyUsageBytes = normalizeProviderByteMap(
settings.providerDailyUsageBytes,
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
"sum"
);
const providerTotalUsageBytes = normalizeProviderByteMap(
settings.providerTotalUsageBytes,
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
"sum"
);
const debridLinkApiKeyDailyLimitBytes = normalizeNamedByteMap(
settings.debridLinkApiKeyDailyLimitBytes,
debridLinkApiKeyIds
);
const debridLinkApiKeyDailyUsageBytes = normalizeNamedByteMap(
settings.debridLinkApiKeyDailyUsageBytes,
debridLinkApiKeyIds
);
const debridLinkApiKeyTotalUsageBytes = normalizeNamedByteMap(
settings.debridLinkApiKeyTotalUsageBytes,
debridLinkApiKeyIds
);
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
const normalized: AppSettings = { const normalized: AppSettings = {
token: asText(settings.token), token: asText(settings.token),
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
megaLogin: asText(settings.megaLogin), megaLogin,
megaPassword: asText(settings.megaPassword), megaPassword,
megaDebridPreferApi: settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true, megaCredentials,
megaDebridApiEnabled,
megaDebridWebEnabled,
megaDebridPreferApi,
bestToken: asText(settings.bestToken), bestToken: asText(settings.bestToken),
bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin), bestDebridUseWebLogin: Boolean(settings.bestDebridUseWebLogin),
allDebridToken: asText(settings.allDebridToken), allDebridToken: asText(settings.allDebridToken),
@ -118,16 +402,27 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
ddownloadLogin: asText(settings.ddownloadLogin), ddownloadLogin: asText(settings.ddownloadLogin),
ddownloadPassword: asText(settings.ddownloadPassword), ddownloadPassword: asText(settings.ddownloadPassword),
oneFichierApiKey: asText(settings.oneFichierApiKey), oneFichierApiKey: asText(settings.oneFichierApiKey),
debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(),
debridLinkDisabledKeyIds,
linkSnappyLogin: asText(settings.linkSnappyLogin),
linkSnappyPassword: asText(settings.linkSnappyPassword),
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"), archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"),
rememberToken: Boolean(settings.rememberToken), rememberToken: Boolean(settings.rememberToken),
providerPrimary: settings.providerPrimary, providerOrder: normalizeProviderOrder(
providerSecondary: settings.providerSecondary, settings.providerOrder,
providerTertiary: settings.providerTertiary, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
settings.providerPrimary, settings.providerSecondary, settings.providerTertiary
),
providerPrimary: normalizeConfiguredProvider(settings.providerPrimary, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled) || defaults.providerPrimary,
providerSecondary: normalizeFallbackProvider(settings.providerSecondary, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled),
providerTertiary: normalizeFallbackProvider(settings.providerTertiary, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled),
autoProviderFallback: Boolean(settings.autoProviderFallback), autoProviderFallback: Boolean(settings.autoProviderFallback),
outputDir: normalizeAbsoluteDir(settings.outputDir, defaults.outputDir), outputDir: normalizeAbsoluteDir(settings.outputDir, defaults.outputDir),
packageName: asText(settings.packageName), packageName: asText(settings.packageName),
autoExtract: Boolean(settings.autoExtract), autoExtract: Boolean(settings.autoExtract),
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj), autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
keepGermanAudioOnly: Boolean(settings.keepGermanAudioOnly),
germanAudioMode: settings.germanAudioMode === "first" ? "first" : "tag",
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir), extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary), collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir), mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir),
@ -153,14 +448,46 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
clipboardWatch: Boolean(settings.clipboardWatch), clipboardWatch: Boolean(settings.clipboardWatch),
minimizeToTray: Boolean(settings.minimizeToTray), minimizeToTray: Boolean(settings.minimizeToTray),
collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages, collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages,
historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode)
? settings.historyRetentionMode
: defaults.historyRetentionMode,
accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined
? Boolean(settings.accountListShowDetailedDebridLinkKeys)
: defaults.accountListShowDetailedDebridLinkKeys,
autoSortPackagesByProgress: settings.autoSortPackagesByProgress !== undefined ? Boolean(settings.autoSortPackagesByProgress) : defaults.autoSortPackagesByProgress,
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted, autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
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,
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme, theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules), bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
columnOrder: normalizeColumnOrder(settings.columnOrder), columnOrder: normalizeColumnOrder(settings.columnOrder),
extractCpuPriority: settings.extractCpuPriority, extractCpuPriority: settings.extractCpuPriority,
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
hosterRouting: normalizeHosterRouting(settings.hosterRouting, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled),
providerDailyLimitBytes: normalizeProviderByteMap(
settings.providerDailyLimitBytes,
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
"max"
),
providerDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? providerDailyUsageBytes : {},
providerTotalUsageBytes,
debridLinkApiKeyDailyLimitBytes,
debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {},
debridLinkApiKeyTotalUsageBytes,
megaDebridDisabledAccountIds: normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds),
megaDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.megaDebridAccountDailyLimitBytes, megaDebridAccountIds),
megaDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
: {},
megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds),
debridAccountStatuses: normalizeDebridAccountStatuses(settings.debridAccountStatuses, megaDebridAccountIds, debridLinkApiKeyIds),
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
}; };
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) { if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
@ -207,12 +534,16 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings {
realDebridUseWebLogin: settings.realDebridUseWebLogin, realDebridUseWebLogin: settings.realDebridUseWebLogin,
megaLogin: "", megaLogin: "",
megaPassword: "", megaPassword: "",
megaCredentials: "",
bestToken: "", bestToken: "",
bestDebridUseWebLogin: settings.bestDebridUseWebLogin, bestDebridUseWebLogin: settings.bestDebridUseWebLogin,
allDebridToken: "", allDebridToken: "",
ddownloadLogin: "", ddownloadLogin: "",
ddownloadPassword: "", ddownloadPassword: "",
oneFichierApiKey: "" oneFichierApiKey: "",
debridLinkApiKeys: "",
linkSnappyLogin: "",
linkSnappyPassword: ""
}; };
} }
@ -233,7 +564,22 @@ export function createStoragePaths(baseDir: string): StoragePaths {
} }
function ensureBaseDir(baseDir: string): void { function ensureBaseDir(baseDir: string): void {
fs.mkdirSync(baseDir, { recursive: true }); try {
fs.mkdirSync(baseDir, { recursive: true });
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || "";
if (code === "EACCES" || code === "EPERM") {
logger.error(`AppData-Ordner kann nicht erstellt werden (${code}): ${baseDir} - pruefe Schreibrechte fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
}
throw error;
}
}
function safeJsonReplacer(_key: string, value: unknown): unknown {
if (typeof value === "number" && !Number.isFinite(value)) {
return null;
}
return value;
} }
function asRecord(value: unknown): Record<string, unknown> | null { function asRecord(value: unknown): Record<string, unknown> | null {
@ -251,7 +597,14 @@ function readSettingsFile(filePath: string): AppSettings | null {
...parsed ...parsed
}); });
return sanitizeCredentialPersistence(merged); return sanitizeCredentialPersistence(merged);
} catch { } catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || "";
if (code === "ENOENT") {
} else if (code === "EACCES" || code === "EPERM") {
logger.error(`Settings-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
} else {
logger.warn(`Settings-Datei nicht lesbar: ${filePath}: ${String(error)}`);
}
return null; return null;
} }
} }
@ -271,8 +624,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
if (!item) { if (!item) {
continue; continue;
} }
const id = asText(item.id) || entryId; const id = normalizeSessionId(item.id) || normalizeSessionId(entryId);
const packageId = asText(item.packageId); const packageId = normalizeSessionId(item.packageId);
const url = asText(item.url); const url = asText(item.url);
if (!id || !packageId || !url) { if (!id || !packageId || !url) {
continue; continue;
@ -289,6 +642,9 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
packageId, packageId,
url, url,
provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null, provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null,
providerLabel: asText(item.providerLabel) || undefined,
providerAccountId: asText(item.providerAccountId) || undefined,
providerAccountLabel: asText(item.providerAccountLabel) || undefined,
status, status,
retries: clampNumber(item.retries, 0, 0, 1_000_000), retries: clampNumber(item.retries, 0, 0, 1_000_000),
speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000), speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000),
@ -314,7 +670,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
if (!pkg) { if (!pkg) {
continue; continue;
} }
const id = asText(pkg.id) || entryId; const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId);
if (!id) { if (!id) {
continue; continue;
} }
@ -328,21 +684,44 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
extractDir: asText(pkg.extractDir), extractDir: asText(pkg.extractDir),
status, status,
itemIds: rawItemIds itemIds: rawItemIds
.map((value) => asText(value)) .map((value) => normalizeSessionId(value))
.filter((value) => value.length > 0), .filter((value) => value.length > 0),
cancelled: Boolean(pkg.cancelled), cancelled: Boolean(pkg.cancelled),
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal",
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
}; };
} }
let orphanedItemCount = 0;
for (const [itemId, item] of Object.entries(itemsById)) { for (const [itemId, item] of Object.entries(itemsById)) {
if (!packagesById[item.packageId]) { if (!packagesById[item.packageId]) {
orphanedItemCount += 1;
delete itemsById[itemId]; delete itemsById[itemId];
} }
} }
if (orphanedItemCount > 0) {
logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`);
}
let droppedUnsafeTargetPathCount = 0;
for (const item of Object.values(itemsById)) {
const pkg = packagesById[item.packageId];
if (!pkg) {
continue;
}
const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir);
if (!safeTargetPath && asText(item.targetPath)) {
droppedUnsafeTargetPathCount += 1;
}
item.targetPath = safeTargetPath;
}
if (droppedUnsafeTargetPathCount > 0) {
logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`);
}
for (const pkg of Object.values(packagesById)) { for (const pkg of Object.values(packagesById)) {
pkg.itemIds = pkg.itemIds.filter((itemId) => { pkg.itemIds = pkg.itemIds.filter((itemId) => {
@ -354,7 +733,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
const seenOrder = new Set<string>(); const seenOrder = new Set<string>();
const packageOrder = rawOrder const packageOrder = rawOrder
.map((entry) => asText(entry)) .map((entry) => normalizeSessionId(entry))
.filter((id) => { .filter((id) => {
if (!(id in packagesById) || seenOrder.has(id)) { if (!(id in packagesById) || seenOrder.has(id)) {
return false; return false;
@ -401,12 +780,11 @@ export function loadSettings(paths: StoragePaths): AppSettings {
if (backupLoaded) { if (backupLoaded) {
logger.warn("Konfiguration defekt, Backup-Datei wird verwendet"); logger.warn("Konfiguration defekt, Backup-Datei wird verwendet");
try { try {
const payload = JSON.stringify(backupLoaded, null, 2); const payload = JSON.stringify(backupLoaded, safeJsonReplacer, 2);
const tempPath = `${paths.configFile}.tmp`; const tempPath = `${paths.configFile}.tmp`;
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.configFile); syncRenameWithExdevFallback(tempPath, paths.configFile);
} catch { } catch {
// ignore restore write failure
} }
return backupLoaded; return backupLoaded;
} }
@ -437,18 +815,15 @@ function sessionBackupPath(sessionFile: string): string {
} }
export function normalizeLoadedSessionTransientFields(session: SessionState): SessionState { export function normalizeLoadedSessionTransientFields(session: SessionState): SessionState {
// Reset transient fields that may be stale from a previous crash
const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]); const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
for (const item of Object.values(session.items)) { for (const item of Object.values(session.items)) {
if (ACTIVE_STATUSES.has(item.status)) { if (ACTIVE_STATUSES.has(item.status)) {
item.status = "queued"; item.status = "queued";
item.lastError = ""; item.lastError = "";
} }
// Always clear stale speed values
item.speedBps = 0; item.speedBps = 0;
} }
// Reset package-level active statuses to queued (mirrors item reset above)
const ACTIVE_PKG_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]); const ACTIVE_PKG_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
for (const pkg of Object.values(session.packages)) { for (const pkg of Object.values(session.packages)) {
if (ACTIVE_PKG_STATUSES.has(pkg.status)) { if (ACTIVE_PKG_STATUSES.has(pkg.status)) {
@ -457,7 +832,6 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
pkg.postProcessLabel = undefined; pkg.postProcessLabel = undefined;
} }
// Clear stale session-level running/paused flags
session.running = false; session.running = false;
session.paused = false; session.paused = false;
@ -467,30 +841,38 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
function readSessionFile(filePath: string): SessionState | null { function readSessionFile(filePath: string): SessionState | null {
try { try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
return normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed)); const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
} catch { const pkgCount = Object.keys(session.packages).length;
const itemCount = Object.keys(session.items).length;
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
return session;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || "";
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;
} }
} }
export function saveSettings(paths: StoragePaths, settings: AppSettings): void { export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
// Create a backup of the existing config before overwriting
if (fs.existsSync(paths.configFile)) { if (fs.existsSync(paths.configFile)) {
try { try {
fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`); fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`);
} catch { } catch {
// Best-effort backup; proceed even if it fails
} }
} }
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, null, 2); const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
const tempPath = `${paths.configFile}.tmp`; const tempPath = `${paths.configFile}.tmp`;
try { try {
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.configFile); syncRenameWithExdevFallback(tempPath, paths.configFile);
} catch (error) { } catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ } try { fs.rmSync(tempPath, { force: true }); } catch { }
throw error; throw error;
} }
} }
@ -518,7 +900,7 @@ async function writeSettingsPayload(paths: StoragePaths, payload: string): Promi
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> { export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
const payload = JSON.stringify(persisted, null, 2); const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
if (asyncSettingsSaveRunning) { if (asyncSettingsSaveRunning) {
asyncSettingsSaveQueued = { paths, settings }; asyncSettingsSaveQueued = { paths, settings };
return; return;
@ -557,31 +939,73 @@ export function emptySession(): SessionState {
export function loadSession(paths: StoragePaths): SessionState { export function loadSession(paths: StoragePaths): SessionState {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
if (!fs.existsSync(paths.sessionFile)) { const backupFile = sessionBackupPath(paths.sessionFile);
return emptySession(); const primaryExists = fs.existsSync(paths.sessionFile);
if (!primaryExists) {
const hasRecoverable = fs.existsSync(backupFile)
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
|| fs.existsSync(sessionTempPath(paths.sessionFile, "async"));
if (!hasRecoverable) {
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
return emptySession();
}
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
} }
const primary = readSessionFile(paths.sessionFile); const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
if (primary) { if (primary) {
const primaryPkgCount = Object.keys(primary.packages).length;
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
const backup = readSessionFile(backupFile);
if (backup) {
const backupPkgCount = Object.keys(backup.packages).length;
if (backupPkgCount > 0) {
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch {
}
return backup;
}
}
}
return primary; return primary;
} }
const backupFile = sessionBackupPath(paths.sessionFile);
const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null; const backup = fs.existsSync(backupFile) ? 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() }); const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync"); const tempPath = sessionTempPath(paths.sessionFile, "sync");
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch { } catch {
// ignore restore write failure
} }
return backup; return backup;
} }
logger.error("Session konnte nicht geladen werden (auch Backup fehlgeschlagen)"); for (const kind of ["sync", "async"] as const) {
const tmpPath = sessionTempPath(paths.sessionFile, kind);
if (fs.existsSync(tmpPath)) {
const tmpSession = readSessionFile(tmpPath);
if (tmpSession && Object.keys(tmpSession.packages).length > 0) {
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`);
try {
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(paths.sessionFile, payload, "utf8");
} catch {
}
return tmpSession;
}
}
}
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)");
return emptySession(); return emptySession();
} }
@ -592,22 +1016,21 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
try { try {
fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile)); fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile));
} catch { } catch {
// Best-effort backup; proceed even if it fails
} }
} }
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); 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"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch (error) { } catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ } try { fs.rmSync(tempPath, { force: true }); } catch { }
throw error; throw error;
} }
} }
let asyncSaveRunning = false; let asyncSaveRunning = false;
let asyncSaveQueued: { paths: StoragePaths; payload: string } | null = null; let asyncSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
let syncSaveGeneration = 0; let syncSaveGeneration = 0;
async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> { async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
@ -615,7 +1038,6 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
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"); await fsp.writeFile(tempPath, payload, "utf8");
// If a synchronous save occurred after this async save started, discard the stale write
if (generation < syncSaveGeneration) { if (generation < syncSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {}); await fsp.rm(tempPath, { force: true }).catch(() => {});
return; return;
@ -637,15 +1059,14 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
} }
} }
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string): Promise<void> { async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
if (asyncSaveRunning) { if (asyncSaveRunning) {
asyncSaveQueued = { paths, payload }; asyncSaveQueued = { paths, payload, generation };
return; return;
} }
asyncSaveRunning = true; asyncSaveRunning = true;
const gen = syncSaveGeneration;
try { try {
await writeSessionPayload(paths, payload, gen); await writeSessionPayload(paths, payload, generation);
} catch (error) { } catch (error) {
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`); logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
} finally { } finally {
@ -653,7 +1074,7 @@ async function saveSessionPayloadAsync(paths: StoragePaths, payload: string): Pr
if (asyncSaveQueued) { if (asyncSaveQueued) {
const queued = asyncSaveQueued; const queued = asyncSaveQueued;
asyncSaveQueued = null; asyncSaveQueued = null;
void saveSessionPayloadAsync(queued.paths, queued.payload); void saveSessionPayloadAsync(queued.paths, queued.payload, queued.generation);
} }
} }
} }
@ -665,13 +1086,14 @@ export function cancelPendingAsyncSaves(): void {
} }
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> { export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); const generation = syncSaveGeneration;
await saveSessionPayloadAsync(paths, payload); const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
await saveSessionPayloadAsync(paths, payload, generation);
} }
const MAX_HISTORY_ENTRIES = 500; const MAX_HISTORY_ENTRIES = 500;
function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry | null { export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry | null {
const entry = asRecord(raw); const entry = asRecord(raw);
if (!entry) return null; if (!entry) return null;
@ -718,13 +1140,13 @@ export function loadHistory(paths: StoragePaths): HistoryEntry[] {
export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void { export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
const trimmed = entries.slice(0, MAX_HISTORY_ENTRIES); const trimmed = entries.slice(0, MAX_HISTORY_ENTRIES);
const payload = JSON.stringify(trimmed, null, 2); const payload = JSON.stringify(trimmed, safeJsonReplacer, 2);
const tempPath = `${paths.historyFile}.tmp`; const tempPath = `${paths.historyFile}.tmp`;
try { try {
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.historyFile); syncRenameWithExdevFallback(tempPath, paths.historyFile);
} catch (error) { } catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ } try { fs.rmSync(tempPath, { force: true }); } catch { }
throw error; throw error;
} }
} }
@ -736,6 +1158,24 @@ export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry): Histo
return updated; return updated;
} }
export function loadHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): HistoryEntry[] {
return retentionMode === "never" ? [] : loadHistory(paths);
}
export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, entry: HistoryEntry): HistoryEntry[] {
if (retentionMode === "never") {
return [];
}
return addHistoryEntry(paths, entry);
}
export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void {
if (retentionMode === "permanent") {
return;
}
clearHistory(paths);
}
export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] { export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] {
const existing = loadHistory(paths); const existing = loadHistory(paths);
const updated = existing.filter(e => e.id !== entryId); const updated = existing.filter(e => e.id !== entryId);
@ -749,7 +1189,6 @@ export function clearHistory(paths: StoragePaths): void {
try { try {
fs.unlinkSync(paths.historyFile); fs.unlinkSync(paths.historyFile);
} catch { } catch {
// ignore
} }
} }
} }

210
src/main/support-bundle.ts Normal file
View File

@ -0,0 +1,210 @@
import fs from "node:fs";
import path from "node:path";
import AdmZip from "adm-zip";
import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getPackageLogPath } from "./package-log";
import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-rename-log";
import { getSessionLogPath } from "./session-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager";
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
function safeReadJson(filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
} catch {
return null;
}
}
function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
}
function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): void {
if (!sourcePath || !fs.existsSync(sourcePath)) {
return;
}
zip.addLocalFile(sourcePath, path.posix.dirname(zipPath), path.posix.basename(zipPath));
}
function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): void {
if (!fs.existsSync(dirPath)) {
return;
}
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const zipPath = path.posix.join(zipRoot, entry.name);
if (entry.isDirectory()) {
addDirectoryIfExists(zip, fullPath, zipPath);
continue;
}
zip.addLocalFile(fullPath, path.posix.dirname(zipPath), path.posix.basename(zipPath));
}
}
function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number {
if (!fs.existsSync(dirPath)) {
return 0;
}
const cutoff = Date.now() - maxAgeMs;
let added = 0;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
const fullPath = path.join(dirPath, entry.name);
try {
if (fs.statSync(fullPath).mtimeMs >= cutoff) {
zip.addLocalFile(fullPath, zipRoot, entry.name);
added += 1;
}
} catch { }
}
return added;
}
function formatTimestampForFileName(date: Date): string {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
const s = String(date.getSeconds()).padStart(2, "0");
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
}
export function getSupportBundleDefaultFileName(): string {
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
}
type HostDiagnosticsMode = "full" | "cached" | "none";
interface BuildSupportBundleOptions {
hostDiagnosticsMode?: HostDiagnosticsMode;
}
function createDeferredHostDiagnostics(reason: string): unknown {
return {
collectedAt: new Date().toISOString(),
supported: process.platform === "win32",
platform: process.platform,
crashControl: null,
recentKernelPower: [],
recentWerKernel: [],
recentKernelDump: [],
recentAppCrashes: [],
recentMinidumps: [],
assessmentHints: [
reason
],
errors: []
};
}
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
if (mode === "none") {
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
}
if (mode === "cached") {
const cached = getCachedWindowsHostDiagnostics();
if (cached) {
return cached;
}
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
}
return getWindowsHostDiagnostics();
}
export function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Buffer {
const zip = new AdmZip();
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths);
const history = loadHistory(storagePaths);
const snapshot = manager.getSnapshot();
const packageIds = Object.keys(snapshot.session.packages);
const itemIds = Object.keys(snapshot.session.items);
const debugSetup = getDebugSetupCheck(baseDir);
addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION,
generatedAt: new Date().toISOString(),
runtimeBaseDir: baseDir,
packageCount: packageIds.length,
itemCount: itemIds.length
});
addJson(zip, "overview/status.json", snapshot.session);
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
addJson(zip, "overview/stats.json", {
...buildStatsPayload(snapshot),
allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
}
});
addJson(zip, "overview/debug-setup.json", debugSetup);
addJson(zip, "overview/self-check.json", debugSetup);
addJson(zip, "overview/history.json", {
total: history.length,
entries: history.map((entry) => summarizeHistoryEntry(entry))
});
addJson(zip, "overview/packages.json", {
count: packageIds.length,
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean)
});
addJson(zip, "overview/items.json", {
count: itemIds.length,
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
});
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
addJson(zip, "overview/trace-config.json", getTraceConfig());
const recentErrors = getRecentErrors();
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
for (const packageId of packageIds) {
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
}
for (const itemId of itemIds) {
addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
}
const aiManifest = safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
if (aiManifest) {
addJson(zip, "overview/ai-manifest.json", aiManifest);
}
return zip.toBuffer();
}

179
src/main/support-data.ts Normal file
View File

@ -0,0 +1,179 @@
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
function hasText(value: unknown): boolean {
return String(value || "").trim().length > 0;
}
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
return {
realDebrid: {
configured: hasText(settings.token) || settings.realDebridUseWebLogin,
tokenConfigured: hasText(settings.token),
webLoginEnabled: settings.realDebridUseWebLogin,
rememberToken: settings.rememberToken
},
megaDebrid: {
configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword))
|| settings.megaDebridApiEnabled
|| settings.megaDebridWebEnabled,
loginConfigured: hasText(settings.megaLogin) && hasText(settings.megaPassword),
apiEnabled: settings.megaDebridApiEnabled,
webEnabled: settings.megaDebridWebEnabled,
preferApi: settings.megaDebridPreferApi
},
bestDebrid: {
configured: hasText(settings.bestToken) || settings.bestDebridUseWebLogin,
tokenConfigured: hasText(settings.bestToken),
webLoginEnabled: settings.bestDebridUseWebLogin
},
allDebrid: {
configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin,
tokenConfigured: hasText(settings.allDebridToken),
webLoginEnabled: settings.allDebridUseWebLogin
},
ddownload: {
configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword)
},
oneFichier: {
configured: hasText(settings.oneFichierApiKey)
},
debridLink: {
configured: debridLinkKeyIds.length > 0,
keyCount: debridLinkKeyIds.length,
enabledKeyCount: debridLinkKeyIds.filter((id) => !disabledDebridLinkIds.has(id)).length,
disabledKeyCount: debridLinkKeyIds.filter((id) => disabledDebridLinkIds.has(id)).length
},
linkSnappy: {
configured: hasText(settings.linkSnappyLogin) && hasText(settings.linkSnappyPassword)
},
disabledProviders: [...(settings.disabledProviders || [])]
};
}
export function diffAccountSummary(previous: AppSettings, next: AppSettings): Record<string, unknown> {
const before = buildAccountSummary(previous);
const after = buildAccountSummary(next);
const changes: Record<string, unknown> = {};
for (const key of Object.keys(after)) {
const beforeJson = JSON.stringify(before[key]);
const afterJson = JSON.stringify(after[key]);
if (beforeJson !== afterJson) {
changes[key] = after[key];
}
}
return changes;
}
export function buildRedactedSettingsPayload(settings: AppSettings): Record<string, unknown> {
return {
paths: {
outputDir: settings.outputDir,
extractDir: settings.extractDir,
mkvLibraryDir: settings.mkvLibraryDir
},
providers: {
providerOrder: settings.providerOrder,
providerPrimary: settings.providerPrimary,
providerSecondary: settings.providerSecondary,
providerTertiary: settings.providerTertiary,
autoProviderFallback: settings.autoProviderFallback,
disabledProviders: settings.disabledProviders,
hosterRouting: settings.hosterRouting
},
extraction: {
autoExtract: settings.autoExtract,
autoExtractWhenStopped: settings.autoExtractWhenStopped,
hybridExtract: settings.hybridExtract,
createExtractSubfolder: settings.createExtractSubfolder,
cleanupMode: settings.cleanupMode,
extractConflictMode: settings.extractConflictMode,
removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract,
removeSamplesAfterExtract: settings.removeSamplesAfterExtract,
enableIntegrityCheck: settings.enableIntegrityCheck,
archivePasswordCount: String(settings.archivePasswordList || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.length,
extractCpuPriority: settings.extractCpuPriority,
maxParallelExtract: settings.maxParallelExtract
},
downloads: {
maxParallel: settings.maxParallel,
retryLimit: settings.retryLimit,
autoResumeOnStart: settings.autoResumeOnStart,
autoReconnect: settings.autoReconnect,
reconnectWaitSeconds: settings.reconnectWaitSeconds,
autoSkipExtracted: settings.autoSkipExtracted,
completedCleanupPolicy: settings.completedCleanupPolicy
},
ui: {
packageName: settings.packageName,
theme: settings.theme,
collapseNewPackages: settings.collapseNewPackages,
hideExtractedItems: settings.hideExtractedItems,
confirmDeleteSelection: settings.confirmDeleteSelection,
clipboardWatch: settings.clipboardWatch,
minimizeToTray: settings.minimizeToTray,
columnOrder: settings.columnOrder
},
bandwidth: {
speedLimitEnabled: settings.speedLimitEnabled,
speedLimitKbps: settings.speedLimitKbps,
speedLimitMode: settings.speedLimitMode,
bandwidthSchedules: settings.bandwidthSchedules
},
updates: {
updateRepo: settings.updateRepo,
autoUpdateCheck: settings.autoUpdateCheck
},
statistics: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
providerDailyLimitBytes: settings.providerDailyLimitBytes,
providerDailyUsageBytes: settings.providerDailyUsageBytes,
providerTotalUsageBytes: settings.providerTotalUsageBytes,
debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes,
debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes,
debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes,
providerDailyUsageDay: settings.providerDailyUsageDay
},
accounts: buildAccountSummary(settings)
};
}
export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> {
return {
session: snapshot.stats,
totals: {
totalPackages: Object.keys(snapshot.session.packages).length,
totalItems: Object.keys(snapshot.session.items).length,
speedText: snapshot.speedText,
etaText: snapshot.etaText,
canStart: snapshot.canStart,
canStop: snapshot.canStop,
canPause: snapshot.canPause
}
};
}
export function summarizeHistoryEntry(entry: HistoryEntry): Record<string, unknown> {
return {
id: entry.id,
name: entry.name,
status: entry.status,
provider: entry.provider,
fileCount: entry.fileCount,
totalBytes: entry.totalBytes,
downloadedBytes: entry.downloadedBytes,
durationSeconds: entry.durationSeconds,
completedAt: entry.completedAt,
outputDir: entry.outputDir,
urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0
};
}

312
src/main/trace-log.ts Normal file
View File

@ -0,0 +1,312 @@
import fs from "node:fs";
import { logTimestamp } from "./log-timestamp";
import path from "node:path";
import { addLogListener, removeLogListener } from "./logger";
import type { SupportTraceConfig } from "../shared/types";
type TraceLevel = "INFO" | "WARN" | "ERROR";
const TRACE_LOG_FLUSH_INTERVAL_MS = 200;
const TRACE_CONFIG_FILE = "trace_config.json";
const TRACE_LOG_MAX_FILE_BYTES = Number(process.env.RD_TRACE_LOG_MAX_BYTES || 10 * 1024 * 1024);
const TRACE_LOG_RETENTION_DAYS = Number(process.env.RD_TRACE_LOG_RETENTION_DAYS || 30);
const TRACE_DEFAULT_AUTO_DISABLE_MS = Number(process.env.RD_TRACE_AUTO_DISABLE_MS || 2 * 60 * 60 * 1000);
const DEFAULT_TRACE_CONFIG: SupportTraceConfig = {
enabled: false,
includeMainLog: true,
includeAudit: true,
logDebugRequests: true,
autoDisableAt: null,
updatedAt: new Date(0).toISOString()
};
let traceLogPath: string | null = null;
let traceConfigPath: string | null = null;
let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG };
let pendingLines: string[] = [];
let flushTimer: NodeJS.Timeout | null = null;
let autoDisableTimer: NodeJS.Timeout | null = null;
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
function flushPending(): void {
if (!traceLogPath || pendingLines.length === 0) {
return;
}
const chunk = pendingLines.join("");
pendingLines = [];
try {
fs.appendFileSync(traceLogPath, chunk, "utf8");
} catch {
}
}
function rotateIfNeeded(filePath: string): void {
try {
const stat = fs.statSync(filePath);
if (stat.size < TRACE_LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
}
fs.renameSync(filePath, backup);
} catch {
}
}
function cleanupOldBackup(filePath: string): void {
const backup = `${filePath}.old`;
try {
const stat = fs.statSync(backup);
const cutoff = Date.now() - TRACE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (stat.mtimeMs < cutoff) {
fs.rmSync(backup, { force: true });
}
} catch {
}
}
function scheduleFlush(): void {
if (flushTimer) {
return;
}
flushTimer = setTimeout(() => {
flushTimer = null;
flushPending();
}, TRACE_LOG_FLUSH_INTERVAL_MS);
}
function appendTraceLine(line: string): void {
if (!traceLogPath) {
return;
}
rotateIfNeeded(traceLogPath);
if (!fs.existsSync(traceLogPath)) {
try {
fs.writeFileSync(traceLogPath, "", "utf8");
} catch {
return;
}
}
pendingLines.push(line);
scheduleFlush();
}
function normalizeTraceConfig(raw: unknown): SupportTraceConfig {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { ...DEFAULT_TRACE_CONFIG };
}
const value = raw as Partial<SupportTraceConfig>;
return {
enabled: Boolean(value.enabled),
includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog),
includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit),
logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests),
autoDisableAt: typeof value.autoDisableAt === "string" && value.autoDisableAt.trim()
? value.autoDisableAt
: null,
updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim()
? value.updatedAt
: DEFAULT_TRACE_CONFIG.updatedAt
};
}
function loadTraceConfig(): SupportTraceConfig {
if (!traceConfigPath) {
return { ...DEFAULT_TRACE_CONFIG };
}
try {
const parsed = JSON.parse(fs.readFileSync(traceConfigPath, "utf8")) as unknown;
return normalizeTraceConfig(parsed);
} catch {
return { ...DEFAULT_TRACE_CONFIG };
}
}
function persistTraceConfig(): void {
if (!traceConfigPath) {
return;
}
try {
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
} catch {
}
}
const mainLogListener = (line: string): void => {
if (!traceConfig.enabled || !traceConfig.includeMainLog) {
return;
}
appendTraceLine(line);
};
function clearAutoDisableTimer(): void {
if (autoDisableTimer) {
clearTimeout(autoDisableTimer);
autoDisableTimer = null;
}
}
function disableTraceDueToExpiry(): void {
clearAutoDisableTimer();
if (!traceConfig.enabled) {
return;
}
traceConfig = normalizeTraceConfig({
...traceConfig,
enabled: false,
autoDisableAt: null,
updatedAt: logTimestamp()
});
persistTraceConfig();
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
}
function scheduleAutoDisable(): void {
clearAutoDisableTimer();
if (!traceConfig.enabled || !traceConfig.autoDisableAt) {
return;
}
const until = Date.parse(traceConfig.autoDisableAt);
if (!Number.isFinite(until)) {
return;
}
const remainingMs = until - Date.now();
if (remainingMs <= 0) {
disableTraceDueToExpiry();
return;
}
autoDisableTimer = setTimeout(() => {
autoDisableTimer = null;
disableTraceDueToExpiry();
}, Math.min(remainingMs, 2_147_483_647));
}
export function initTraceLog(baseDir: string): void {
traceLogPath = path.join(baseDir, "trace.log");
traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE);
try {
fs.mkdirSync(baseDir, { recursive: true });
cleanupOldBackup(traceLogPath);
if (!fs.existsSync(traceLogPath)) {
fs.writeFileSync(traceLogPath, "", "utf8");
}
rotateIfNeeded(traceLogPath);
if (!fs.existsSync(traceLogPath)) {
fs.writeFileSync(traceLogPath, "", "utf8");
}
traceConfig = loadTraceConfig();
persistTraceConfig();
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8");
} catch {
traceLogPath = null;
traceConfigPath = null;
traceConfig = { ...DEFAULT_TRACE_CONFIG };
return;
}
addLogListener(mainLogListener);
scheduleAutoDisable();
}
export function getTraceLogPath(): string | null {
if (!traceLogPath) {
return null;
}
return fs.existsSync(traceLogPath) ? traceLogPath : null;
}
export function getTraceConfigPath(): string | null {
if (!traceConfigPath) {
return null;
}
return fs.existsSync(traceConfigPath) ? traceConfigPath : null;
}
export function getTraceConfig(): SupportTraceConfig {
return { ...traceConfig };
}
export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTraceConfig {
traceConfig = normalizeTraceConfig({
...traceConfig,
...patch,
updatedAt: logTimestamp()
});
persistTraceConfig();
scheduleAutoDisable();
appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
return getTraceConfig();
}
export function setTraceEnabled(enabled: boolean, note = "", durationMs: number = TRACE_DEFAULT_AUTO_DISABLE_MS): SupportTraceConfig {
const autoDisableAt = enabled && durationMs > 0
? new Date(Date.now() + durationMs).toISOString()
: null;
const next = updateTraceConfig({ enabled, autoDisableAt });
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
return next;
}
export function logTraceEvent(
level: TraceLevel,
category: string,
message: string,
fields?: Record<string, unknown>
): void {
if (!traceConfig.enabled) {
return;
}
if (category === "audit" && !traceConfig.includeAudit) {
return;
}
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
}
export function shutdownTraceLog(): void {
removeLogListener(mainLogListener);
clearAutoDisableTimer();
if (!traceLogPath) {
return;
}
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
flushPending();
try {
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch {
}
traceLogPath = null;
traceConfigPath = null;
traceConfig = { ...DEFAULT_TRACE_CONFIG };
}

File diff suppressed because it is too large Load Diff

View File

@ -201,19 +201,31 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
const packages: ParsedPackageInput[] = []; const packages: ParsedPackageInput[] = [];
let currentName = String(defaultPackageName || "").trim(); let currentName = String(defaultPackageName || "").trim();
let currentLinks: string[] = []; let currentLinks: string[] = [];
let currentFileNames: string[] = [];
let pendingFileName = "";
const flush = (): void => { const flush = (): void => {
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line))); const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
if (links.length > 0) { if (links.length > 0) {
const normalizedCurrentName = String(currentName || "").trim(); const normalizedCurrentName = String(currentName || "").trim();
packages.push({ const fileNames = links.map((link) => {
const firstIndex = currentLinks.findIndex((currentLink) => currentLink === link);
return firstIndex >= 0 ? currentFileNames[firstIndex] || "" : "";
});
const nextPackage: ParsedPackageInput = {
name: normalizedCurrentName name: normalizedCurrentName
? sanitizeFilename(normalizedCurrentName) ? sanitizeFilename(normalizedCurrentName)
: inferPackageNameFromLinks(links), : inferPackageNameFromLinks(links),
links links
}); };
if (fileNames.some((fileName) => fileName.trim().length > 0)) {
nextPackage.fileNames = fileNames;
}
packages.push(nextPackage);
} }
currentLinks = []; currentLinks = [];
currentFileNames = [];
pendingFileName = "";
}; };
for (const line of lines) { for (const line of lines) {
@ -225,9 +237,20 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
if (marker) { if (marker) {
flush(); flush();
currentName = String(marker[1] || "").trim(); currentName = String(marker[1] || "").trim();
pendingFileName = "";
continue;
}
const fileMarker = text.match(/^#\s*file\s*:\s*(.+)$/i);
if (fileMarker) {
pendingFileName = sanitizeFilename(String(fileMarker[1] || "").trim());
continue;
}
if (!isHttpLink(text)) {
continue; continue;
} }
currentLinks.push(text); currentLinks.push(text);
currentFileNames.push(pendingFileName);
pendingFileName = "";
} }
flush(); flush();
@ -248,8 +271,26 @@ export function nowMs(): number {
return Date.now(); return Date.now();
} }
export function sleep(ms: number): Promise<void> { export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error(String(signal.reason || "aborted")));
return;
}
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = (): void => {
clearTimeout(timer);
cleanup();
reject(new Error(String(signal?.reason || "aborted")));
};
const cleanup = (): void => {
signal?.removeEventListener("abort", onAbort);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
} }
export function formatEta(seconds: number): string { export function formatEta(seconds: number): string {

510
src/main/video-processor.ts Normal file
View File

@ -0,0 +1,510 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
// Removes only-German audio handling for "Dual Language" (.DL.) scene releases.
// Mirrors the user's ffmpeg script but adds: language-tag detection (with safe
// fallbacks), disk-space pre-check, atomic temp->replace, mtime preservation,
// abort-into-child, and "never destroy the only usable audio" safety.
//
// The ffmpeg/ffprobe-specific logic lives here so it is mockable in isolation;
// the per-package iteration + filename/.DL. rename + logging stays in
// download-manager.ts (its existing domain).
export type GermanAudioMode = "tag" | "first";
export interface ProbedAudioStream {
language: string;
title: string;
}
export type AudioTrackDecision =
| { action: "remux"; audioRelIndex: number; reason: string }
| { action: "single"; audioRelIndex: 0; reason: string }
| { action: "skip"; reason: string };
export type VideoProcessAction =
| "remuxed"
| "kept-single"
| "skipped-no-german"
| "skipped-no-audio"
| "skipped-no-space"
| "skipped-no-tool"
| "error"
| "aborted";
export interface VideoProcessResult {
action: VideoProcessAction;
reason: string;
keptTrackIndex?: number;
totalAudioTracks?: number;
audioLanguages?: string[];
error?: string;
}
export interface ProcessVideoOptions {
mode: GermanAudioMode;
cpuPriority?: string;
signal?: AbortSignal;
}
// Injection seam so the irreversible file-mutating body (temp -> replace ->
// utimes -> rm-on-failure) can be exercised in tests with a fake ffmpeg/ffprobe
// runner, without spawning real processes. Production passes nothing.
export interface ProcessVideoDeps {
resolveTooling?: () => Promise<{ ffmpeg: string; ffprobe: string } | null>;
runProcess?: typeof runVideoProcess;
// Seam for the atomic-replace rename so its failure/recovery path is testable
// without provoking a real OS file lock. Production uses renameWithRetry.
rename?: (from: string, to: string) => Promise<void>;
}
const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]);
const PROBE_TIMEOUT_MS = 60_000;
const STDOUT_CAP = 2 * 1024 * 1024;
const STDERR_CAP = 64 * 1024;
// ---------------------------------------------------------------------------
// Pure helpers (no fs / no process) — unit-tested in isolation.
// ---------------------------------------------------------------------------
// "X.German.DL.720p.mkv" -> "X.German.720p.mkv"; "X.DL.mkv" -> "X.mkv".
export function stripDualLangMarker(fileName: string): string {
const ext = path.extname(fileName);
const base = ext ? fileName.slice(0, -ext.length) : fileName;
const stripped = base.replace(/\.DL\./gi, ".").replace(/\.DL$/i, "");
return stripped + ext;
}
export function hasDualLangMarker(fileName: string): boolean {
return stripDualLangMarker(fileName) !== fileName;
}
export function isRemuxableVideoFile(fileName: string): boolean {
return VIDEO_REMUX_EXTENSIONS.has(path.extname(fileName).toLowerCase());
}
// True when the release name explicitly marks it as a German release. Used in
// tag mode to fall back to the first audio track (German-first scene convention)
// when the audio language tags are wrong (a German dub mislabeled "eng"), instead
// of skipping. Deliberately requires an explicit german/deutsch token — the
// ".DL." marker alone (present on every processed file) is not enough, and a bare
// "dubbed" can mean an Italian/French dub, so it must NOT flag a German release.
export function looksLikeGermanRelease(fileName: string): boolean {
return /(^|[._\s-])(german|deutsch)([._\s-]|$)/i.test(fileName);
}
function isGermanStream(stream: ProbedAudioStream): boolean {
const lang = (stream.language || "").toLowerCase().trim();
if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) {
return true;
}
// Free-text title fallback (used when the language tag is missing). Full words
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
// pick the wrong track to keep (which then deletes the real German one).
const title = (stream.title || "").toLowerCase();
return /\b(german|deutsch)\b/.test(title);
}
// Decide which audio track to keep. Safety invariant: only ever choose to remux
// (which destroys the original) when we are confident; otherwise skip untouched.
export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode, germanRelease = false): AudioTrackDecision {
const total = streams.length;
if (total === 0) {
return { action: "skip", reason: "no-audio" };
}
if (mode === "first") {
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-audio" }
: { action: "remux", audioRelIndex: 0, reason: "first-audio" };
}
// tag mode
const germanPos = streams.findIndex(isGermanStream);
if (germanPos >= 0) {
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-german" }
: { action: "remux", audioRelIndex: germanPos, reason: "german-tag" };
}
const anyTagged = streams.some((s) => (s.language || "").trim().length > 0);
if (!anyTagged) {
// No language metadata at all -> fall back to the script's behavior.
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-untagged" }
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" };
}
if (germanRelease) {
// Tagged, no German track found, but the release name explicitly says German
// -> the dub is mislabeled (German audio tagged "eng"). Trust the German-first
// scene convention rather than skipping.
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-german-mislabeled" }
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" };
}
// Tagged, no German track, and nothing says German -> never guess-delete.
return { action: "skip", reason: "no-german-track" };
}
export function parseFfprobeAudioStreams(jsonText: string): ProbedAudioStream[] {
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return [];
}
const streams = (parsed as { streams?: unknown }).streams;
if (!Array.isArray(streams)) {
return [];
}
return streams.map((raw) => {
const tags = (raw && typeof raw === "object" ? (raw as { tags?: unknown }).tags : undefined) as
| { language?: unknown; title?: unknown }
| undefined;
return {
language: typeof tags?.language === "string" ? tags.language : "",
title: typeof tags?.title === "string" ? tags.title : ""
};
});
}
export function buildFfprobeArgs(input: string): string[] {
return [
"-v", "error",
"-select_streams", "a",
"-show_entries", "stream=index:stream_tags=language,title",
"-of", "json",
input
];
}
export function buildFfmpegRemuxArgs(opts: { input: string; output: string; audioRelIndex: number; keepSubs?: boolean }): string[] {
const args = ["-i", opts.input, "-map", "0:v:0", "-map", `0:a:${opts.audioRelIndex}`];
if (opts.keepSubs) {
// Optional (not enabled by current settings): keep German subtitle tracks only.
args.push("-map", "0:s:m:language:ger?", "-map", "0:s:m:language:deu?");
}
// Stream-copy and keep metadata (so the kept track's language tag survives;
// unlike the original script's -map_metadata -1 which dropped it).
args.push("-c", "copy", "-disposition:a:0", "default", "-y", opts.output);
return args;
}
// Stream-copy remux is disk-bound; generous budget scaled by size, clamped.
export function computeRemuxTimeoutMs(bytes: number): number {
const perBytes = Math.ceil((Number(bytes) || 0) / (10 * 1024 * 1024)) * 1000;
return Math.max(120_000, Math.min(60 * 60 * 1000, 120_000 + perBytes));
}
// ---------------------------------------------------------------------------
// Tooling discovery (system PATH + RD_FFMPEG_BIN/RD_FFPROBE_BIN env override).
// Lazy probe + cache, mirroring the extractor's 7z/Java resolution convention.
// ---------------------------------------------------------------------------
interface VideoTooling {
ffmpeg: string;
ffprobe: string;
}
let cachedTooling: VideoTooling | null | undefined;
let cachedToolingNullSince = 0;
const TOOLING_NULL_TTL_MS = 5 * 60 * 1000;
function ffmpegCandidate(): string {
return String(process.env.RD_FFMPEG_BIN || "").trim() || "ffmpeg";
}
function ffprobeCandidate(): string {
return String(process.env.RD_FFPROBE_BIN || "").trim() || "ffprobe";
}
async function probeVersion(command: string): Promise<boolean> {
const result = await runVideoProcess(command, ["-version"], { timeoutMs: 10_000 });
return result.ok && !result.missing;
}
export async function resolveVideoTooling(): Promise<VideoTooling | null> {
if (cachedTooling) {
return cachedTooling;
}
if (cachedTooling === null && Date.now() - cachedToolingNullSince < TOOLING_NULL_TTL_MS) {
return null;
}
const ffmpeg = ffmpegCandidate();
const ffprobe = ffprobeCandidate();
const [ffmpegOk, ffprobeOk] = await Promise.all([probeVersion(ffmpeg), probeVersion(ffprobe)]);
if (ffmpegOk && ffprobeOk) {
cachedTooling = { ffmpeg, ffprobe };
return cachedTooling;
}
cachedTooling = null;
cachedToolingNullSince = Date.now();
return null;
}
export function resetVideoToolingCache(): void {
cachedTooling = undefined;
cachedToolingNullSince = 0;
}
// ---------------------------------------------------------------------------
// Process spawning (ffmpeg/ffprobe). ffmpeg/ffprobe exit conventions: 0 = ok,
// anything else = real failure (NOT 7-Zip's "exit 1 = warning" semantics).
// ---------------------------------------------------------------------------
export interface VideoSpawnResult {
ok: boolean;
aborted: boolean;
timedOut: boolean;
missing: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
}
function appendCapped(buffer: string, text: string, cap: number): string {
const next = buffer + text;
return next.length > cap ? next.slice(next.length - cap) : next;
}
function applyChildPriority(pid: number | undefined, cpuPriority?: string): void {
if (process.platform !== "win32") {
return;
}
const numeric = Number(pid || 0);
if (!Number.isFinite(numeric) || numeric <= 0) {
return;
}
try {
const level = cpuPriority === "high" ? os.constants.priority.PRIORITY_NORMAL : os.constants.priority.PRIORITY_BELOW_NORMAL;
os.setPriority(numeric, level);
} catch {
}
}
function killChildTree(child: { pid?: number; kill: () => void }): void {
const pid = Number(child.pid || 0);
if (process.platform === "win32" && Number.isFinite(pid) && pid > 0) {
try {
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
killer.on("error", () => { try { child.kill(); } catch {} });
return;
} catch {
}
}
try {
child.kill();
} catch {
}
}
export function runVideoProcess(
command: string,
args: string[],
opts: { signal?: AbortSignal; timeoutMs?: number; cpuPriority?: string } = {}
): Promise<VideoSpawnResult> {
const { signal, timeoutMs, cpuPriority } = opts;
if (signal?.aborted) {
return Promise.resolve({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: null, stdout: "", stderr: "" });
}
return new Promise((resolve) => {
let settled = false;
let stdout = "";
let stderr = "";
let timedOut = false;
let aborted = false;
let timeoutId: NodeJS.Timeout | null = null;
const child = spawn(command, args, { windowsHide: true });
applyChildPriority(child.pid, cpuPriority);
const onAbort = (): void => {
aborted = true;
killChildTree(child);
};
const finish = (result: VideoSpawnResult): void => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve(result);
};
if (timeoutMs && timeoutMs > 0) {
timeoutId = setTimeout(() => {
timedOut = true;
killChildTree(child);
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: null, stdout, stderr });
}, timeoutMs);
}
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
child.stdout?.on("data", (chunk) => { stdout = appendCapped(stdout, String(chunk || ""), STDOUT_CAP); });
child.stderr?.on("data", (chunk) => { stderr = appendCapped(stderr, String(chunk || ""), STDERR_CAP); });
child.on("error", (error) => {
const text = String(error || "");
finish({ ok: false, aborted: false, timedOut: false, missing: text.toLowerCase().includes("enoent"), exitCode: null, stdout, stderr: stderr || text });
});
child.on("close", (code) => {
if (aborted) {
finish({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: code, stdout, stderr });
return;
}
if (timedOut) {
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: code, stdout, stderr });
return;
}
finish({ ok: code === 0, aborted: false, timedOut: false, missing: false, exitCode: code, stdout, stderr });
});
});
}
// ---------------------------------------------------------------------------
// Per-file orchestration: probe -> decide -> (disk check) -> remux -> atomic
// replace -> preserve mtime. Operates IN PLACE (same filename); the .DL. rename
// + companion handling + logging is done by the caller (download-manager).
// ---------------------------------------------------------------------------
async function getFreeSpaceBytes(dir: string): Promise<number | null> {
try {
const stat = await fs.promises.statfs(dir);
return Number(stat.bavail) * Number(stat.bsize);
} catch {
return null;
}
}
const RENAME_RETRY_DELAYS_MS = [200, 500, 1000];
const RENAME_RETRYABLE_CODES = new Set(["EBUSY", "EACCES", "EPERM", "EEXIST"]);
function delayMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Windows file locks from antivirus, the search indexer, or a media scanner are
// transient: a rename that hits EBUSY/EACCES/EPERM/EEXIST often succeeds a moment
// later. Retry with backoff before giving up so a momentary lock doesn't abort
// the atomic replace and leave the file unprocessed.
export async function renameWithRetry(from: string, to: string): Promise<void> {
for (let attempt = 0; ; attempt += 1) {
try {
await fs.promises.rename(from, to);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (!code || !RENAME_RETRYABLE_CODES.has(code) || attempt >= RENAME_RETRY_DELAYS_MS.length) {
throw error;
}
await delayMs(RENAME_RETRY_DELAYS_MS[attempt]);
}
}
}
// Short, unique, same-directory sidecar name (never longer than the original file
// name) so concurrent packages / retries never collide on a fixed temp name and a
// long scene filename + suffix cannot push the path past Windows MAX_PATH.
function uniqueTempPath(filePath: string): string {
const ext = path.extname(filePath);
const token = `${process.pid.toString(36)}${crypto.randomBytes(3).toString("hex")}`;
return path.join(path.dirname(filePath), `~rd${token}${ext}`);
}
export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise<VideoProcessResult> {
const resolveTool = deps.resolveTooling || resolveVideoTooling;
const run = deps.runProcess || runVideoProcess;
if (opts.signal?.aborted) {
return { action: "aborted", reason: "aborted" };
}
const tooling = await resolveTool();
if (!tooling) {
return { action: "skipped-no-tool", reason: "ffmpeg/ffprobe nicht gefunden (PATH oder RD_FFMPEG_BIN)" };
}
const probe = await run(tooling.ffprobe, buildFfprobeArgs(filePath), { signal: opts.signal, timeoutMs: PROBE_TIMEOUT_MS });
if (probe.aborted) {
return { action: "aborted", reason: "aborted" };
}
if (!probe.ok) {
return { action: "error", reason: "ffprobe fehlgeschlagen", error: probe.stderr || `exit ${String(probe.exitCode)}` };
}
const streams = parseFfprobeAudioStreams(probe.stdout);
const audioLanguages = streams.map((s) => (s.language || "").trim() || "und");
const decision = pickAudioTrack(streams, opts.mode, looksLikeGermanRelease(path.basename(filePath)));
if (decision.action === "skip") {
return {
action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio",
reason: decision.reason,
totalAudioTracks: streams.length,
audioLanguages
};
}
if (decision.action === "single") {
return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: 0 };
}
// remux path
let originalStat: fs.Stats;
try {
originalStat = await fs.promises.stat(filePath);
} catch (error) {
return { action: "error", reason: "stat fehlgeschlagen", error: String(error), audioLanguages };
}
const free = await getFreeSpaceBytes(path.dirname(filePath));
if (free !== null && free < Math.ceil(originalStat.size * 1.05)) {
return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length, audioLanguages };
}
const tempPath = uniqueTempPath(filePath);
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
const remux = await run(
tooling.ffmpeg,
buildFfmpegRemuxArgs({ input: filePath, output: tempPath, audioRelIndex: decision.audioRelIndex, keepSubs: false }),
{ signal: opts.signal, timeoutMs: computeRemuxTimeoutMs(originalStat.size), cpuPriority: opts.cpuPriority }
);
if (remux.aborted) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "aborted", reason: "aborted" };
}
if (!remux.ok) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "error", reason: "ffmpeg remux fehlgeschlagen", error: remux.stderr || `exit ${String(remux.exitCode)}`, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: decision.audioRelIndex };
}
const tempStat = await fs.promises.stat(tempPath).catch(() => null);
if (!tempStat || tempStat.size <= 0) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length, audioLanguages };
}
const renameOp = deps.rename || renameWithRetry;
try {
// Atomic replace-over: libuv maps fs.rename to MoveFileEx(REPLACE_EXISTING) on
// Windows and rename(2) on POSIX, both atomic on the same volume, so filePath
// holds either the full original or the full remux at every instant. Retried
// for transient locks. We must NEVER rm the original first (the old fallback
// did): an rm-then-failed-rename left zero copies of the file on disk.
await renameOp(tempPath, filePath);
// Preserve original mtime so freshness gates (hybrid collect) don't skip it.
await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {});
} catch (error) {
// Replace failed -> the original is untouched at filePath. Drop the temp only.
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "error", reason: "Ersetzen der Datei fehlgeschlagen", error: String(error), totalAudioTracks: streams.length, audioLanguages };
}
return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length, audioLanguages };
}

View File

@ -0,0 +1,325 @@
import fs from "node:fs";
import { spawnSync } from "node:child_process";
export interface WindowsHostEvent {
timeCreated: string;
id: number;
providerName: string;
levelDisplayName: string;
message: string;
bugcheckCode?: string;
bugcheckCodeHex?: string;
reportId?: string;
}
export interface WindowsHostDumpFile {
name: string;
fullName: string;
length: number;
lastWriteTime: string;
}
export interface WindowsCrashControlInfo {
crashDumpEnabled: number | null;
minidumpDir: string;
dumpFile: string;
overwrite: number | null;
logEvent: number | null;
autoReboot: number | null;
}
export interface WindowsHostDiagnostics {
collectedAt: string;
supported: boolean;
platform: string;
crashControl: WindowsCrashControlInfo | null;
recentKernelPower: WindowsHostEvent[];
recentWerKernel: WindowsHostEvent[];
recentKernelDump: WindowsHostEvent[];
recentAppCrashes: WindowsHostEvent[];
recentMinidumps: WindowsHostDumpFile[];
assessmentHints: string[];
errors: string[];
}
const CACHE_TTL_MS = 15_000;
let cachedAt = 0;
let cachedValue: WindowsHostDiagnostics | null = null;
function createEmptyDiagnostics(): WindowsHostDiagnostics {
return {
collectedAt: new Date().toISOString(),
supported: process.platform === "win32",
platform: process.platform,
crashControl: null,
recentKernelPower: [],
recentWerKernel: [],
recentKernelDump: [],
recentAppCrashes: [],
recentMinidumps: [],
assessmentHints: [],
errors: []
};
}
function runPowerShellJson(script: string): unknown {
const result = spawnSync(
process.env.ComSpec && process.env.ComSpec.toLowerCase().includes("pwsh") ? process.env.ComSpec : "powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
{
encoding: "utf8",
timeout: 20_000,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"]
}
);
if (result.status !== 0) {
const errorText = String(result.stderr || result.stdout || "").trim() || `PowerShell exited with code ${result.status}`;
throw new Error(errorText);
}
const text = String(result.stdout || "").trim();
if (!text) {
return null;
}
return JSON.parse(text) as unknown;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function asNumber(value: unknown): number | null {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeEvent(value: unknown): WindowsHostEvent | null {
const record = asRecord(value);
if (!record) {
return null;
}
return {
timeCreated: asString(record.TimeCreated),
id: asNumber(record.Id) || 0,
providerName: asString(record.ProviderName),
levelDisplayName: asString(record.LevelDisplayName),
message: asString(record.Message),
bugcheckCode: asString(record.BugcheckCode),
bugcheckCodeHex: asString(record.BugcheckCodeHex),
reportId: asString(record.ReportId)
};
}
function normalizeDumpFile(value: unknown): WindowsHostDumpFile | null {
const record = asRecord(value);
if (!record) {
return null;
}
return {
name: asString(record.Name),
fullName: asString(record.FullName),
length: asNumber(record.Length) || 0,
lastWriteTime: asString(record.LastWriteTime)
};
}
function normalizeCrashControl(value: unknown): WindowsCrashControlInfo | null {
const record = asRecord(value);
if (!record) {
return null;
}
return {
crashDumpEnabled: asNumber(record.CrashDumpEnabled),
minidumpDir: asString(record.MinidumpDir),
dumpFile: asString(record.DumpFile),
overwrite: asNumber(record.Overwrite),
logEvent: asNumber(record.LogEvent),
autoReboot: asNumber(record.AutoReboot)
};
}
function pushHints(diagnostics: WindowsHostDiagnostics): void {
if (diagnostics.recentKernelPower.some((entry) => String(entry.bugcheckCode || "").trim() === "0")) {
diagnostics.assessmentHints.push("Kernel-Power 41 mit BugcheckCode 0 deutet eher auf Freeze, Watchdog oder harten Reset als auf einen sauber erfassten klassischen BSOD hin.");
}
if (diagnostics.recentWerKernel.some((entry) => /watchdog/i.test(entry.message))) {
diagnostics.assessmentHints.push("WER-Kernel meldet WATCHDOG-Live-Dumps. Das spricht eher fuer Kernel-, Treiber- oder Hardware-Stalls als fuer einen normalen User-Mode-App-Crash.");
}
if (diagnostics.recentAppCrashes.length === 0) {
diagnostics.assessmentHints.push("Keine passenden Application-Error- oder Windows-Error-Reporting-Eintraege fuer den Downloader/Electron in den letzten Tagen gefunden.");
}
if (diagnostics.recentMinidumps.length === 0) {
diagnostics.assessmentHints.push("Keine aktuellen Minidumps gefunden. Falls der Server erneut abstuerzt, sollte geprueft werden, ob Windows den Dump wirklich schreiben darf.");
}
}
function loadFromPowerShell(): WindowsHostDiagnostics {
const script = String.raw`
$ErrorActionPreference = "SilentlyContinue"
function Convert-EventRecord($eventRecord) {
$map = @{}
try {
[xml]$xml = $eventRecord.ToXml()
foreach ($node in $xml.Event.EventData.Data) {
if ($node.Name) {
$map[$node.Name] = [string]$node.'#text'
}
}
} catch {
}
$reportId = ""
if ([string]$eventRecord.Message -match "ReportId\s+([^,\r\n]+)") {
$reportId = $Matches[1]
}
[PSCustomObject]@{
TimeCreated = if ($eventRecord.TimeCreated) { $eventRecord.TimeCreated.ToUniversalTime().ToString("o") } else { "" }
Id = [int]$eventRecord.Id
ProviderName = [string]$eventRecord.ProviderName
LevelDisplayName = [string]$eventRecord.LevelDisplayName
Message = [string]$eventRecord.Message
BugcheckCode = if ($map.ContainsKey("BugcheckCode")) { [string]$map["BugcheckCode"] } else { "" }
BugcheckCodeHex = if ($map.ContainsKey("BugcheckCode") -and [int64]$map["BugcheckCode"] -gt 0) { ("0x{0:X}" -f [int64]$map["BugcheckCode"]) } else { "" }
ReportId = $reportId
}
}
$startTime = (Get-Date).AddDays(-7)
$crashControl = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl"
$kernelPower = @(
Get-WinEvent -FilterHashtable @{ LogName = "System"; Id = 41; StartTime = $startTime } -MaxEvents 5 |
ForEach-Object { Convert-EventRecord $_ }
)
$werKernel = @(
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-WerKernel/Operational"; StartTime = $startTime } -MaxEvents 30 |
Where-Object { $_.Message -match "WATCHDOG|dump|bugcheck|blue|memory" } |
Select-Object -First 10 |
ForEach-Object { Convert-EventRecord $_ }
)
$kernelDump = @(
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-Kernel-Dump/Operational"; StartTime = $startTime } -MaxEvents 20 |
Select-Object -First 10 |
ForEach-Object { Convert-EventRecord $_ }
)
$appCrashes = @(
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 |
Where-Object {
($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and
($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js")
} |
Select-Object -First 10 |
ForEach-Object { Convert-EventRecord $_ }
)
$dumpFiles = @()
foreach ($dir in @("C:\Windows\Minidump", "C:\Windows\Minidumps")) {
if (Test-Path $dir) {
$dumpFiles += Get-ChildItem -Path $dir -File |
Sort-Object LastWriteTime -Descending |
Select-Object -First 10 |
ForEach-Object {
[PSCustomObject]@{
Name = $_.Name
FullName = $_.FullName
Length = [int64]$_.Length
LastWriteTime = $_.LastWriteTimeUtc.ToString("o")
}
}
}
}
[PSCustomObject]@{
CrashControl = [PSCustomObject]@{
CrashDumpEnabled = if ($null -ne $crashControl.CrashDumpEnabled) { [int]$crashControl.CrashDumpEnabled } else { $null }
MinidumpDir = [string]$crashControl.MinidumpDir
DumpFile = [string]$crashControl.DumpFile
Overwrite = if ($null -ne $crashControl.Overwrite) { [int]$crashControl.Overwrite } else { $null }
LogEvent = if ($null -ne $crashControl.LogEvent) { [int]$crashControl.LogEvent } else { $null }
AutoReboot = if ($null -ne $crashControl.AutoReboot) { [int]$crashControl.AutoReboot } else { $null }
}
RecentKernelPower = @($kernelPower)
RecentWerKernel = @($werKernel)
RecentKernelDump = @($kernelDump)
RecentAppCrashes = @($appCrashes)
RecentMinidumps = @($dumpFiles)
} | ConvertTo-Json -Depth 6 -Compress
`;
const raw = runPowerShellJson(script);
const parsed = asRecord(raw);
const diagnostics = createEmptyDiagnostics();
diagnostics.crashControl = normalizeCrashControl(parsed?.CrashControl ?? null);
diagnostics.recentKernelPower = Array.isArray(parsed?.RecentKernelPower) ? parsed!.RecentKernelPower.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
diagnostics.recentWerKernel = Array.isArray(parsed?.RecentWerKernel) ? parsed!.RecentWerKernel.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
diagnostics.recentKernelDump = Array.isArray(parsed?.RecentKernelDump) ? parsed!.RecentKernelDump.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
diagnostics.recentAppCrashes = Array.isArray(parsed?.RecentAppCrashes) ? parsed!.RecentAppCrashes.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
diagnostics.recentMinidumps = Array.isArray(parsed?.RecentMinidumps) ? parsed!.RecentMinidumps.map(normalizeDumpFile).filter(Boolean) as WindowsHostDumpFile[] : [];
diagnostics.collectedAt = new Date().toISOString();
pushHints(diagnostics);
return diagnostics;
}
export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiagnostics {
if (!forceRefresh && cachedValue && Date.now() - cachedAt < CACHE_TTL_MS) {
return cachedValue;
}
const diagnostics = createEmptyDiagnostics();
if (process.platform !== "win32") {
diagnostics.assessmentHints.push("Windows-Host-Diagnose ist nur unter Windows verfuegbar.");
cachedAt = Date.now();
cachedValue = diagnostics;
return diagnostics;
}
try {
const loaded = loadFromPowerShell();
cachedAt = Date.now();
cachedValue = loaded;
return loaded;
} catch (error) {
diagnostics.errors.push(String(error instanceof Error ? error.message : error));
diagnostics.assessmentHints.push("Host-Diagnose konnte nicht vollstaendig geladen werden.");
cachedAt = Date.now();
cachedValue = diagnostics;
return diagnostics;
}
}
export function getCachedWindowsHostDiagnostics(): WindowsHostDiagnostics | null {
return cachedValue;
}
export function resetWindowsHostDiagnosticsCache(): void {
cachedAt = 0;
cachedValue = null;
}
export function hasRecentWindowsMinidumps(): boolean {
for (const dir of ["C:\\Windows\\Minidump", "C:\\Windows\\Minidumps"]) {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
if (entries.some((entry) => entry.isFile())) {
return true;
}
} catch {
}
}
return false;
}

View File

@ -3,9 +3,13 @@ import {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
DebridAccountStatus,
DebridLinkHostLimitInfo,
DebridProvider,
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
RendererErrorReport,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
@ -23,6 +27,8 @@ const api: ElectronApi = {
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE), installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url), openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> => addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
@ -40,22 +46,39 @@ const api: ElectronApi = {
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds), reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId), removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId), togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId),
exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds),
exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds),
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE), exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json), importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS), getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART), restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT), quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId),
getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK),
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),
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_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),
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password),
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
@ -66,6 +89,7 @@ const api: ElectronApi = {
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds), resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds), startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report),
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,94 @@
import React from "react";
interface ErrorBoundaryProps {
children: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
message: string;
}
// Catches render-time errors in the component tree so a crash shows a minimal
// recovery surface instead of a silent white screen, and forwards the error to
// the main process log. Kept deliberately dead-simple and state-independent: an
// error inside the error path is how you get a second white screen or a loop.
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, message: "" };
}
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { hasError: true, message: error instanceof Error ? error.message : String(error) };
}
componentDidCatch(error: unknown, info: React.ErrorInfo): void {
try {
window.rd?.reportRendererError({
kind: "react",
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
componentStack: info?.componentStack || undefined
});
} catch {
}
}
private handleReload = (): void => {
window.location.reload();
};
render(): React.ReactNode {
if (!this.state.hasError) {
return this.props.children;
}
const overlay: React.CSSProperties = {
position: "fixed",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: 32,
background: "#070b14",
color: "#e6edf6",
fontFamily: "Segoe UI, system-ui, sans-serif",
textAlign: "center"
};
const pre: React.CSSProperties = {
maxWidth: 640,
maxHeight: 200,
overflow: "auto",
padding: 12,
background: "#0d1422",
border: "1px solid #243049",
borderRadius: 6,
color: "#ff9a8c",
fontSize: 12,
whiteSpace: "pre-wrap",
textAlign: "left"
};
const button: React.CSSProperties = {
padding: "8px 20px",
background: "#2d5cff",
color: "#fff",
border: "none",
borderRadius: 6,
cursor: "pointer",
fontSize: 14
};
return (
<div style={overlay}>
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
</p>
<pre style={pre}>{this.state.message}</pre>
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
</div>
);
}
}

View File

@ -1,8 +1,39 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { ErrorBoundary } from "./error-boundary";
import "./styles.css"; import "./styles.css";
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
// rejections) to the main process log. Without this, a renderer crash leaves no
// trace anywhere on an unattended server.
function reportRendererError(report: Parameters<typeof window.rd.reportRendererError>[0]): void {
try {
window.rd?.reportRendererError(report);
} catch {
}
}
window.addEventListener("error", (event) => {
reportRendererError({
kind: "error",
message: event.message || String(event.error || "Unbekannter Fehler"),
stack: event.error instanceof Error ? event.error.stack : undefined,
source: event.filename || undefined,
line: typeof event.lineno === "number" ? event.lineno : undefined,
column: typeof event.colno === "number" ? event.colno : undefined
});
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
reportRendererError({
kind: "unhandledrejection",
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : undefined
});
});
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
if (!rootElement) { if (!rootElement) {
throw new Error("Root element fehlt"); throw new Error("Root element fehlt");
@ -10,6 +41,8 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -1,4 +1,6 @@
import type { PackageEntry } from "../shared/types"; import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types";
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] { export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
const fromIndex = order.indexOf(draggedPackageId); const fromIndex = order.indexOf(draggedPackageId);
@ -23,3 +25,37 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
}); });
return sorted; return sorted;
} }
export function sortPackagesForDisplay(
packages: PackageEntry[],
itemsById: Record<string, DownloadItem>,
running: boolean,
autoSortPackagesByProgress: boolean
): PackageEntry[] {
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
return packages;
}
const active: PackageEntry[] = [];
const rest: PackageEntry[] = [];
// Float packages that have an active item to the top, but keep BOTH groups in
// their original (queue) order. Earlier this sorted the active group by live
// completedRatio/downloadedBytes — which change on every progress tick (every
// 150-700ms), so active packages visibly reshuffled the whole time. A package
// entering/leaving the active bucket is a real, discrete event (start/finish);
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
for (const pkg of packages) {
const hasActive = pkg.itemIds.some((id) => {
const item = itemsById[id];
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
});
(hasActive ? active : rest).push(pkg);
}
if (active.length === 0 || active.length === packages.length) {
return packages;
}
return [...active, ...rest];
}

27
src/renderer/selection.ts Normal file
View File

@ -0,0 +1,27 @@
import type { SessionState } from "../shared/types";
/**
* Drop selected ids whose package OR item no longer exists in the session.
* The selection set mixes package and item ids; when entries vanish (delta
* removal, backup-driven session swap, completed-cleanup) a stale id would
* otherwise inflate the selection count and the "(N)" action labels and keep
* "multi" styling alive for ghosts.
*
* Returns the SAME set instance when nothing changed, so callers can use it
* directly as a React state updater without forcing a re-render.
*/
export function pruneSelection(
selected: ReadonlySet<string>,
session: Pick<SessionState, "packages" | "items">
): Set<string> {
if (selected.size === 0) {
return selected as Set<string>;
}
const next = new Set<string>();
for (const id of selected) {
if (session.packages[id] || session.items[id]) {
next.add(id);
}
}
return next.size === selected.size ? (selected as Set<string>) : next;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,3 @@
/// <reference types="vite/client" />
import type { ElectronApi } from "../shared/preload-api"; import type { ElectronApi } from "../shared/preload-api";
declare global { declare global {

View File

@ -0,0 +1,66 @@
export interface DebridLinkApiKeyEntry {
id: string;
token: string;
index: number;
label: string;
masked: string;
}
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
const FNV64_PRIME = 0x100000001b3n;
const FNV64_MASK = 0xffffffffffffffffn;
function fnv1a64(text: string): string {
let hash = FNV64_OFFSET_BASIS;
for (const char of text) {
hash ^= BigInt(char.codePointAt(0) || 0);
hash = (hash * FNV64_PRIME) & FNV64_MASK;
}
return hash.toString(36);
}
export function maskDebridLinkApiKey(token: string): string {
const trimmed = token.trim();
if (!trimmed) {
return "Nicht hinterlegt";
}
if (trimmed.length <= 6) {
return "*".repeat(trimmed.length);
}
return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`;
}
export function getDebridLinkApiKeyId(token: string): string {
return `dlk_${fnv1a64(token.trim())}`;
}
export function getDebridLinkApiKeyLabel(index: number): string {
return `Key ${index + 1}`;
}
export function parseDebridLinkApiKeys(raw: string): DebridLinkApiKeyEntry[] {
const seen = new Set<string>();
const tokens = String(raw || "")
.split(/[\n,]+/)
.map((entry) => entry.trim())
.filter(Boolean)
.filter((token) => {
if (seen.has(token)) {
return false;
}
seen.add(token);
return true;
});
return tokens.map((token, index) => ({
id: getDebridLinkApiKeyId(token),
token,
index,
label: getDebridLinkApiKeyLabel(index),
masked: maskDebridLinkApiKey(token)
}));
}
export function getDebridLinkApiKeyIds(raw: string): string[] {
return parseDebridLinkApiKeys(raw).map((entry) => entry.id);
}

View File

@ -6,6 +6,8 @@ export const IPC_CHANNELS = {
UPDATE_INSTALL_PROGRESS: "app:update-install-progress", UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
OPEN_EXTERNAL: "app:open-external", OPEN_EXTERNAL: "app:open-external",
UPDATE_SETTINGS: "app:update-settings", UPDATE_SETTINGS: "app:update-settings",
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
ADD_LINKS: "queue:add-links", ADD_LINKS: "queue:add-links",
ADD_CONTAINERS: "queue:add-containers", ADD_CONTAINERS: "queue:add-containers",
GET_START_CONFLICTS: "queue:get-start-conflicts", GET_START_CONFLICTS: "queue:get-start-conflicts",
@ -20,6 +22,8 @@ export const IPC_CHANNELS = {
REORDER_PACKAGES: "queue:reorder-packages", REORDER_PACKAGES: "queue:reorder-packages",
REMOVE_ITEM: "queue:remove-item", REMOVE_ITEM: "queue:remove-item",
TOGGLE_PACKAGE: "queue:toggle-package", TOGGLE_PACKAGE: "queue:toggle-package",
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
EXPORT_QUEUE: "queue:export", EXPORT_QUEUE: "queue:export",
IMPORT_QUEUE: "queue:import", IMPORT_QUEUE: "queue:import",
PICK_FOLDER: "dialog:pick-folder", PICK_FOLDER: "dialog:pick-folder",
@ -28,16 +32,31 @@ export const IPC_CHANNELS = {
CLIPBOARD_DETECTED: "clipboard:detected", CLIPBOARD_DETECTED: "clipboard:detected",
TOGGLE_CLIPBOARD: "clipboard:toggle", TOGGLE_CLIPBOARD: "clipboard:toggle",
GET_SESSION_STATS: "stats:get-session-stats", GET_SESSION_STATS: "stats:get-session-stats",
RESET_SESSION_STATS: "stats:reset-session",
RESET_DOWNLOAD_STATS: "stats:reset-download",
RESTART: "app:restart", RESTART: "app:restart",
QUIT: "app:quit", QUIT: "app:quit",
EXPORT_BACKUP: "app:export-backup", EXPORT_BACKUP: "app:export-backup",
IMPORT_BACKUP: "app:import-backup", IMPORT_BACKUP: "app:import-backup",
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
OPEN_LOG: "app:open-log", OPEN_LOG: "app:open-log",
OPEN_AUDIT_LOG: "app:open-audit-log",
OPEN_RENAME_LOG: "app:open-rename-log",
OPEN_SESSION_LOG: "app:open-session-log", OPEN_SESSION_LOG: "app:open-session-log",
OPEN_TRACE_LOG: "app:open-trace-log",
OPEN_PACKAGE_LOG: "app:open-package-log",
OPEN_ITEM_LOG: "app:open-item-log",
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
GET_TRACE_CONFIG: "app:get-trace-config",
SET_TRACE_ENABLED: "app:set-trace-enabled",
ROTATE_DEBUG_TOKEN: "app:rotate-debug-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",
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info", GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account",
RETRY_EXTRACTION: "queue:retry-extraction", RETRY_EXTRACTION: "queue:retry-extraction",
EXTRACT_NOW: "queue:extract-now", EXTRACT_NOW: "queue:extract-now",
RESET_PACKAGE: "queue:reset-package", RESET_PACKAGE: "queue:reset-package",
@ -47,5 +66,6 @@ export const IPC_CHANNELS = {
SET_PACKAGE_PRIORITY: "queue:set-package-priority", SET_PACKAGE_PRIORITY: "queue:set-package-priority",
SKIP_ITEMS: "queue:skip-items", SKIP_ITEMS: "queue:skip-items",
RESET_ITEMS: "queue:reset-items", RESET_ITEMS: "queue:reset-items",
START_ITEMS: "queue:start-items" START_ITEMS: "queue:start-items",
LOG_RENDERER_ERROR: "log:renderer-error"
} as const; } as const;

View File

@ -0,0 +1,90 @@
export interface MegaDebridAccountEntry {
id: string;
login: string;
password: string;
index: number;
label: string;
maskedLogin: string;
}
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
const FNV64_PRIME = 0x100000001b3n;
const FNV64_MASK = 0xffffffffffffffffn;
function fnv1a64(text: string): string {
let hash = FNV64_OFFSET_BASIS;
for (const char of text) {
hash ^= BigInt(char.codePointAt(0) || 0);
hash = (hash * FNV64_PRIME) & FNV64_MASK;
}
return hash.toString(36);
}
export function getMegaDebridAccountId(login: string): string {
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
}
export function maskMegaDebridLogin(login: string): string {
const trimmed = login.trim();
if (!trimmed) {
return "Nicht hinterlegt";
}
if (trimmed.length <= 4) {
return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`;
}
return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`;
}
export function getMegaDebridAccountLabel(index: number): string {
return `Account ${index + 1}`;
}
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
const seen = new Set<string>();
const lines = String(raw || "")
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean);
const entries: MegaDebridAccountEntry[] = [];
for (const line of lines) {
const colonIdx = line.indexOf(":");
let login: string;
let password: string;
if (colonIdx >= 0) {
login = line.slice(0, colonIdx).trim();
password = line.slice(colonIdx + 1).trim();
} else {
login = line;
password = legacyPassword;
}
if (!login || !password) {
continue;
}
const key = login.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
entries.push({
id: getMegaDebridAccountId(login),
login,
password,
index: entries.length,
label: getMegaDebridAccountLabel(entries.length),
maskedLogin: maskMegaDebridLogin(login)
});
}
return entries;
}
export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string {
return accounts
.filter((a) => a.login.trim() && a.password.trim())
.map((a) => `${a.login.trim()}:${a.password.trim()}`)
.join("\n");
}
export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] {
return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id);
}

View File

@ -2,12 +2,18 @@ import type {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
DebridAccountStatus,
DebugSetupCheckResult,
DebridLinkHostLimitInfo,
DebridProvider,
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
RendererErrorReport,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
SupportTraceConfig,
UiSnapshot, UiSnapshot,
UpdateCheckResult, UpdateCheckResult,
UpdateInstallProgress, UpdateInstallProgress,
@ -21,6 +27,8 @@ export interface ElectronApi {
installUpdate: () => Promise<UpdateInstallResult>; installUpdate: () => Promise<UpdateInstallResult>;
openExternal: (url: string) => Promise<boolean>; openExternal: (url: string) => Promise<boolean>;
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>; updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
getStartConflicts: () => Promise<StartConflictEntry[]>; getStartConflicts: () => Promise<StartConflictEntry[]>;
@ -35,22 +43,39 @@ export interface ElectronApi {
reorderPackages: (packageIds: string[]) => Promise<void>; reorderPackages: (packageIds: string[]) => Promise<void>;
removeItem: (itemId: string) => Promise<void>; removeItem: (itemId: string) => Promise<void>;
togglePackage: (packageId: string) => Promise<void>; togglePackage: (packageId: string) => Promise<void>;
exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
exportItemSelection: (itemIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
exportQueue: () => Promise<{ saved: boolean }>; exportQueue: () => Promise<{ saved: boolean }>;
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>; importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
toggleClipboard: () => Promise<boolean>; toggleClipboard: () => Promise<boolean>;
pickFolder: () => Promise<string | null>; pickFolder: () => Promise<string | null>;
pickContainers: () => Promise<string[]>; pickContainers: () => Promise<string[]>;
getSessionStats: () => Promise<SessionStats>; getSessionStats: () => Promise<SessionStats>;
resetSessionStats: () => Promise<void>;
resetDownloadStats: () => Promise<void>;
restart: () => Promise<void>; restart: () => Promise<void>;
quit: () => Promise<void>; quit: () => Promise<void>;
exportBackup: () => Promise<{ saved: boolean }>; exportBackup: () => Promise<{ saved: boolean }>;
importBackup: () => Promise<{ restored: boolean; message: string }>; importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
openLog: () => Promise<void>; openLog: () => Promise<void>;
openAuditLog: () => Promise<void>;
openRenameLog: () => Promise<void>;
openSessionLog: () => Promise<void>; openSessionLog: () => Promise<void>;
openTraceLog: () => Promise<void>;
openPackageLog: (packageId: string) => Promise<void>;
openItemLog: (itemId: string) => Promise<void>;
getDebugSetupCheck: () => Promise<DebugSetupCheckResult>;
getTraceConfig: () => Promise<SupportTraceConfig>;
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
rotateDebugToken: () => Promise<{ path: string }>;
openRealDebridLogin: () => Promise<void>; openRealDebridLogin: () => Promise<void>;
openAllDebridLogin: () => Promise<void>; openAllDebridLogin: () => Promise<void>;
importBestDebridCookies: () => Promise<number>; importBestDebridCookies: () => Promise<number>;
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>; getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
checkMegaDebridAccount: (login: string, password: string) => Promise<DebridAccountStatus | null>;
retryExtraction: (packageId: string) => Promise<void>; retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>; extractNow: (packageId: string) => Promise<void>;
resetPackage: (packageId: string) => Promise<void>; resetPackage: (packageId: string) => Promise<void>;
@ -61,6 +86,7 @@ export interface ElectronApi {
skipItems: (itemIds: string[]) => Promise<void>; skipItems: (itemIds: string[]) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>; resetItems: (itemIds: string[]) => Promise<void>;
startItems: (itemIds: string[]) => Promise<void>; startItems: (itemIds: string[]) => Promise<void>;
reportRendererError: (report: RendererErrorReport) => void;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;

View File

@ -0,0 +1,329 @@
import type { AppSettings, DebridProvider } from "./types";
export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
export type DebridLinkKeyByteMap = Record<string, number>;
type ProviderDailySettings =
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
type ProviderUsageSettings =
ProviderDailySettings
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
function normalizePositiveBytes(value: unknown): number {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return 0;
}
return Math.floor(numeric);
}
export function getProviderUsageDayKey(epochMs = Date.now()): string {
const current = new Date(epochMs);
const year = current.getFullYear();
const month = String(current.getMonth() + 1).padStart(2, "0");
const day = String(current.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function getProviderDailyLimitBytes(settings: ProviderDailySettings, provider: DebridProvider): number {
return normalizePositiveBytes(settings.providerDailyLimitBytes?.[provider]);
}
export function getProviderDailyUsageBytes(
settings: ProviderDailySettings,
provider: DebridProvider,
epochMs = Date.now()
): number {
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
return 0;
}
return normalizePositiveBytes(settings.providerDailyUsageBytes?.[provider]);
}
export function getProviderDailyRemainingBytes(
settings: ProviderDailySettings,
provider: DebridProvider,
epochMs = Date.now()
): number | null {
const limit = getProviderDailyLimitBytes(settings, provider);
if (limit <= 0) {
return null;
}
return Math.max(0, limit - getProviderDailyUsageBytes(settings, provider, epochMs));
}
export function isProviderDailyLimitReached(
settings: ProviderDailySettings,
provider: DebridProvider,
epochMs = Date.now()
): boolean {
const limit = getProviderDailyLimitBytes(settings, provider);
return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit;
}
export function getProviderTotalUsageBytes(settings: ProviderUsageSettings, provider: DebridProvider): number {
return normalizePositiveBytes(settings.providerTotalUsageBytes?.[provider]);
}
export function resetProviderDailyUsage(
settings: ProviderDailySettings,
provider?: DebridProvider,
epochMs = Date.now()
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
const dayKey = getProviderUsageDayKey(epochMs);
if (!provider) {
return {
providerDailyUsageDay: dayKey,
providerDailyUsageBytes: {}
};
}
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
? { ...(settings.providerDailyUsageBytes || {}) }
: {};
delete nextUsageBytes[provider];
return {
providerDailyUsageDay: dayKey,
providerDailyUsageBytes: nextUsageBytes
};
}
export function addProviderDailyUsageBytes(
settings: ProviderDailySettings,
provider: DebridProvider,
byteDelta: number,
epochMs = Date.now()
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const dayKey = getProviderUsageDayKey(epochMs);
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
? { ...(settings.providerDailyUsageBytes || {}) }
: {};
if (increment <= 0) {
return {
providerDailyUsageDay: dayKey,
providerDailyUsageBytes: currentUsageBytes
};
}
const nextUsageBytes = currentUsageBytes;
nextUsageBytes[provider] = normalizePositiveBytes(nextUsageBytes[provider]) + increment;
return {
providerDailyUsageDay: dayKey,
providerDailyUsageBytes: nextUsageBytes
};
}
export function addProviderTotalUsageBytes(
settings: ProviderUsageSettings,
provider: DebridProvider,
byteDelta: number
): Pick<AppSettings, "providerTotalUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const currentUsageBytes = { ...(settings.providerTotalUsageBytes || {}) };
if (increment <= 0) {
return {
providerTotalUsageBytes: currentUsageBytes
};
}
currentUsageBytes[provider] = normalizePositiveBytes(currentUsageBytes[provider]) + increment;
return {
providerTotalUsageBytes: currentUsageBytes
};
}
export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number {
return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]);
}
export function getDebridLinkApiKeyDailyUsageBytes(
settings: ProviderDailySettings,
keyId: string,
epochMs = Date.now()
): number {
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
return 0;
}
return normalizePositiveBytes(settings.debridLinkApiKeyDailyUsageBytes?.[keyId]);
}
export function getDebridLinkApiKeyDailyRemainingBytes(
settings: ProviderDailySettings,
keyId: string,
epochMs = Date.now()
): number | null {
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
if (limit <= 0) {
return null;
}
return Math.max(0, limit - getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs));
}
export function isDebridLinkApiKeyDailyLimitReached(
settings: ProviderDailySettings,
keyId: string,
epochMs = Date.now()
): boolean {
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit;
}
export function getDebridLinkApiKeyTotalUsageBytes(settings: ProviderUsageSettings, keyId: string): number {
return normalizePositiveBytes(settings.debridLinkApiKeyTotalUsageBytes?.[keyId]);
}
export function resetDebridLinkApiKeyDailyUsage(
settings: ProviderDailySettings,
keyId?: string,
epochMs = Date.now()
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
const dayKey = getProviderUsageDayKey(epochMs);
if (!keyId) {
return {
providerDailyUsageDay: dayKey,
debridLinkApiKeyDailyUsageBytes: {}
};
}
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
: {};
delete nextUsageBytes[keyId];
return {
providerDailyUsageDay: dayKey,
debridLinkApiKeyDailyUsageBytes: nextUsageBytes
};
}
export function addDebridLinkApiKeyDailyUsageBytes(
settings: ProviderDailySettings,
keyId: string,
byteDelta: number,
epochMs = Date.now()
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const dayKey = getProviderUsageDayKey(epochMs);
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
: {};
if (increment <= 0) {
return {
providerDailyUsageDay: dayKey,
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
};
}
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
return {
providerDailyUsageDay: dayKey,
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
};
}
export function addDebridLinkApiKeyTotalUsageBytes(
settings: ProviderUsageSettings,
keyId: string,
byteDelta: number
): Pick<AppSettings, "debridLinkApiKeyTotalUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const currentUsageBytes = { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) };
if (increment <= 0) {
return {
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
};
}
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
return {
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
};
}
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
}
export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number {
return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]);
}
export function getMegaDebridAccountDailyUsageBytes(
settings: ProviderDailySettings,
accountId: string,
epochMs = Date.now()
): number {
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
return 0;
}
return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]);
}
export function isMegaDebridAccountDailyLimitReached(
settings: ProviderDailySettings,
accountId: string,
epochMs = Date.now()
): boolean {
const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId);
return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit;
}
export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number {
return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]);
}
export function addMegaDebridAccountDailyUsageBytes(
settings: ProviderDailySettings,
accountId: string,
byteDelta: number,
epochMs = Date.now()
): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const dayKey = getProviderUsageDayKey(epochMs);
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
? { ...(settings.megaDebridAccountDailyUsageBytes || {}) }
: {};
if (increment <= 0) {
return {
providerDailyUsageDay: dayKey,
megaDebridAccountDailyUsageBytes: currentUsageBytes
};
}
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
return {
providerDailyUsageDay: dayKey,
megaDebridAccountDailyUsageBytes: currentUsageBytes
};
}
export function addMegaDebridAccountTotalUsageBytes(
settings: ProviderUsageSettings,
accountId: string,
byteDelta: number
): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> {
const increment = normalizePositiveBytes(byteDelta);
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
if (increment <= 0) {
return {
megaDebridAccountTotalUsageBytes: currentUsageBytes
};
}
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
return {
megaDebridAccountTotalUsageBytes: currentUsageBytes
};
}

View File

@ -14,11 +14,22 @@ export type CleanupMode = "none" | "trash" | "delete";
export type ConflictMode = "overwrite" | "skip" | "rename" | "ask"; export type ConflictMode = "overwrite" | "skip" | "rename" | "ask";
export type SpeedMode = "global" | "per_download"; export type SpeedMode = "global" | "per_download";
export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done"; export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done";
export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier"; export type DebridProvider =
| "realdebrid"
| "megadebrid"
| "megadebrid-api"
| "megadebrid-web"
| "bestdebrid"
| "alldebrid"
| "ddownload"
| "onefichier"
| "debridlink"
| "linksnappy";
export type DebridFallbackProvider = DebridProvider | "none"; export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light"; export type AppTheme = "dark" | "light";
export type PackagePriority = "high" | "normal" | "low"; export type PackagePriority = "high" | "normal" | "low";
export type ExtractCpuPriority = "high" | "middle" | "low"; export type ExtractCpuPriority = "high" | "middle" | "low";
export type HistoryRetentionMode = "never" | "session" | "permanent";
export interface BandwidthScheduleEntry { export interface BandwidthScheduleEntry {
id: string; id: string;
@ -31,9 +42,28 @@ export interface BandwidthScheduleEntry {
export interface DownloadStats { export interface DownloadStats {
totalDownloaded: number; totalDownloaded: number;
totalDownloadedAllTime: number; totalDownloadedAllTime: number;
totalFiles: number; totalFiles?: number;
totalFilesSession: number;
totalFilesAllTime: number;
totalPackages: number; totalPackages: number;
sessionStartedAt: number; sessionStartedAt: number;
appSessionStartedAt: number;
sessionRuntimeMs: number;
totalRuntimeMs: number;
runtimeMeasuredAt: number;
}
export interface DebridAccountStatus {
accountId: string;
provider: "megadebrid" | "debridlink";
label: string;
maskedLogin: string;
valid: boolean;
isPremium: boolean;
premiumUntilMs: number | null;
email?: string;
message: string;
checkedAt: number;
} }
export interface AppSettings { export interface AppSettings {
@ -41,6 +71,9 @@ export interface AppSettings {
realDebridUseWebLogin: boolean; realDebridUseWebLogin: boolean;
megaLogin: string; megaLogin: string;
megaPassword: string; megaPassword: string;
megaCredentials: string;
megaDebridApiEnabled: boolean;
megaDebridWebEnabled: boolean;
megaDebridPreferApi: boolean; megaDebridPreferApi: boolean;
bestToken: string; bestToken: string;
bestDebridUseWebLogin: boolean; bestDebridUseWebLogin: boolean;
@ -49,8 +82,13 @@ export interface AppSettings {
ddownloadLogin: string; ddownloadLogin: string;
ddownloadPassword: string; ddownloadPassword: string;
oneFichierApiKey: string; oneFichierApiKey: string;
debridLinkApiKeys: string;
debridLinkDisabledKeyIds: string[];
linkSnappyLogin: string;
linkSnappyPassword: string;
archivePasswordList: string; archivePasswordList: string;
rememberToken: boolean; rememberToken: boolean;
providerOrder: readonly DebridProvider[];
providerPrimary: DebridProvider; providerPrimary: DebridProvider;
providerSecondary: DebridFallbackProvider; providerSecondary: DebridFallbackProvider;
providerTertiary: DebridFallbackProvider; providerTertiary: DebridFallbackProvider;
@ -59,6 +97,8 @@ export interface AppSettings {
packageName: string; packageName: string;
autoExtract: boolean; autoExtract: boolean;
autoRename4sf4sj: boolean; autoRename4sf4sj: boolean;
keepGermanAudioOnly: boolean;
germanAudioMode: "tag" | "first";
extractDir: string; extractDir: string;
collectMkvToLibrary: boolean; collectMkvToLibrary: boolean;
mkvLibraryDir: string; mkvLibraryDir: string;
@ -85,13 +125,35 @@ export interface AppSettings {
minimizeToTray: boolean; minimizeToTray: boolean;
theme: AppTheme; theme: AppTheme;
collapseNewPackages: boolean; collapseNewPackages: boolean;
historyRetentionMode: HistoryRetentionMode;
accountListShowDetailedDebridLinkKeys: boolean;
autoSortPackagesByProgress: boolean;
autoSkipExtracted: boolean; autoSkipExtracted: boolean;
hideExtractedItems: boolean;
confirmDeleteSelection: boolean; confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean;
totalDownloadedAllTime: number; totalDownloadedAllTime: number;
totalCompletedFilesAllTime: number;
totalRuntimeAllTimeMs: number;
bandwidthSchedules: BandwidthScheduleEntry[]; bandwidthSchedules: BandwidthScheduleEntry[];
columnOrder: string[]; columnOrder: string[];
extractCpuPriority: ExtractCpuPriority; extractCpuPriority: ExtractCpuPriority;
autoExtractWhenStopped: boolean; autoExtractWhenStopped: boolean;
disabledProviders: DebridProvider[];
hosterRouting: Record<string, DebridProvider>;
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
megaDebridDisabledAccountIds: string[];
megaDebridAccountDailyLimitBytes: Record<string, number>;
megaDebridAccountDailyUsageBytes: Record<string, number>;
megaDebridAccountTotalUsageBytes: Record<string, number>;
debridAccountStatuses: Record<string, DebridAccountStatus>;
providerDailyUsageDay: string;
scheduledStartEpochMs: number;
} }
export interface DownloadItem { export interface DownloadItem {
@ -99,6 +161,9 @@ export interface DownloadItem {
packageId: string; packageId: string;
url: string; url: string;
provider: DebridProvider | null; provider: DebridProvider | null;
providerLabel?: string;
providerAccountId?: string;
providerAccountLabel?: string;
status: DownloadStatus; status: DownloadStatus;
retries: number; retries: number;
speedBps: number; speedBps: number;
@ -125,8 +190,10 @@ export interface PackageEntry {
itemIds: string[]; itemIds: string[];
cancelled: boolean; cancelled: boolean;
enabled: boolean; enabled: boolean;
priority: PackagePriority; priority?: PackagePriority;
postProcessLabel?: string; postProcessLabel?: string;
downloadStartedAt?: number;
downloadCompletedAt?: number;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
@ -167,6 +234,19 @@ export interface ContainerImportResult {
source: "dlc"; source: "dlc";
} }
export interface RotationEvent {
id: string;
at: number;
level: "INFO" | "WARN" | "ERROR";
provider: string;
accountLabel: string;
event: string;
reason?: string;
category?: string;
cooldownSec?: number;
next?: string;
}
export interface UiSnapshot { export interface UiSnapshot {
settings: AppSettings; settings: AppSettings;
session: SessionState; session: SessionState;
@ -180,6 +260,10 @@ export interface UiSnapshot {
clipboardActive: boolean; clipboardActive: boolean;
reconnectSeconds: number; reconnectSeconds: number;
packageSpeedBps: Record<string, number>; packageSpeedBps: Record<string, number>;
payloadKind?: "full" | "delta";
removedItemIds?: string[];
removedPackageIds?: string[];
rotationEvents?: RotationEvent[];
} }
export interface AddLinksPayload { export interface AddLinksPayload {
@ -246,6 +330,8 @@ export interface UpdateInstallProgress {
export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown"; export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown";
export type AllDebridHostInfoSource = "api" | "web"; export type AllDebridHostInfoSource = "api" | "web";
export type DebridLinkHostState = "up" | "down" | "unknown";
export type DebridLinkKeyState = "ready" | "cooldown" | "invalid" | "quota" | "rate_limit" | "error" | "unknown";
export interface AllDebridHostInfo { export interface AllDebridHostInfo {
host: string; host: string;
@ -261,6 +347,27 @@ export interface AllDebridHostInfo {
note: string; note: string;
} }
export interface DebridLinkHostLimitInfo {
keyId: string;
keyLabel: string;
host: string;
fetchedAt: number;
trafficCurrentBytes: number | null;
trafficMaxBytes: number | null;
linksCurrent: number | null;
linksMax: number | null;
note: string;
state: DebridLinkKeyState;
stateLabel: string;
stateDetail: string;
cooldownUntil: number | null;
cooldownRemainingMs: number;
lastCheckedAt: number | null;
hostState: DebridLinkHostState;
hostStateLabel: string;
hostNote: string;
}
export interface ParsedHashEntry { export interface ParsedHashEntry {
fileName: string; fileName: string;
algorithm: "crc32" | "md5" | "sha1"; algorithm: "crc32" | "md5" | "sha1";
@ -290,6 +397,92 @@ export interface SessionStats {
queuedDownloads: number; queuedDownloads: number;
} }
export interface SupportTraceConfig {
enabled: boolean;
includeMainLog: boolean;
includeAudit: boolean;
logDebugRequests: boolean;
autoDisableAt: string | null;
updatedAt: string;
}
export interface SupportFileSizeInfo {
path: string | null;
exists: boolean;
bytes: number;
}
export interface SupportDirectorySizeInfo {
path: string;
exists: boolean;
fileCount: number;
bytes: number;
}
export interface SupportDiskSpaceInfo {
path: string;
totalBytes: number | null;
freeBytes: number | null;
freePercent: number | null;
}
export interface SupportBundleEstimate {
estimatedBytes: number;
estimatedEntries: number;
duplicatedLiveLogBytes: number;
note: string;
}
export interface DebugSetupCheckResult {
status: "ok" | "warn";
enabled: boolean;
runtimeBaseDir: string;
host: string;
port: number;
localOnly: boolean;
tokenConfigured: boolean;
tokenPath: string;
aiManifestPath: string;
aiManifestPresent: boolean;
traceConfigPath: string | null;
traceLogPath: string | null;
traceEnabled: boolean;
traceAutoDisableAt: string | null;
diskSpace: {
runtime: SupportDiskSpaceInfo;
output: SupportDiskSpaceInfo;
extract: SupportDiskSpaceInfo;
};
logSummary: {
totalBytes: number;
main: SupportFileSizeInfo;
mainBackup: SupportFileSizeInfo;
audit: SupportFileSizeInfo;
auditBackup: SupportFileSizeInfo;
rename: SupportFileSizeInfo;
renameBackup: SupportFileSizeInfo;
session: SupportFileSizeInfo;
trace: SupportFileSizeInfo;
traceBackup: SupportFileSizeInfo;
sessionLogs: SupportDirectorySizeInfo;
packageLogs: SupportDirectorySizeInfo;
itemLogs: SupportDirectorySizeInfo;
};
supportBundle: SupportBundleEstimate;
warnings: string[];
notes: string[];
localUrls: {
health: string;
meta: string;
diagnostics: string;
};
remoteUrlTemplates: {
health: string;
meta: string;
diagnostics: string;
};
}
export interface HistoryEntry { export interface HistoryEntry {
id: string; id: string;
name: string; name: string;
@ -308,3 +501,13 @@ export interface HistoryState {
entries: HistoryEntry[]; entries: HistoryEntry[];
maxEntries: number; maxEntries: number;
} }
export interface RendererErrorReport {
kind: "error" | "unhandledrejection" | "react";
message: string;
stack?: string;
source?: string;
line?: number;
column?: number;
componentStack?: string;
}

335
tasks/lessons.md Normal file
View File

@ -0,0 +1,335 @@
# Lessons
## 2026-05-31 — Fix-Diagnose EMPIRISCH bestätigen, bevor man released (Timeout ≠ Account-Hänger)
**Muster:** "acc2/acc3 nie versucht" wurde als "acc1 hängt → Per-Account-Timeout +
Rotation" diagnostiziert und als v1.7.168 released. Falsch: Mega-Debrid-**Web** ist eine
180s-Polling-Schleife (`mega-web-fallback.ts`) — acc1 *pollte* legitim, der 60s-Global-
Timeout (nicht "Hängen") schnitt es ab. Mein 25s-Per-Account-Cap machte es SCHLIMMER
(endlose 25s-Rotation, Datei nie aufgelöst). Erst der User-Log + Lesen der Provider-
Impl deckte es auf. Revert v1.7.169.
**Regel:**
- Ein Timeout bei einem langsam-pollenden Provider ist KEIN Account-Fehler → darf keine
Rotation/kein Skippen auslösen. Vor "Account hängt"-Annahmen die Provider-Impl lesen
(Polling? internes Ceiling? wie lange dauert ein Erfolg legitim?).
- Bei zwei gegensätzlichen Diagnosen (hier: Timeout-zu-kurz vs. IP-Block — stand in der
EIGENEN Memory!) NICHT die bequeme wählen + releasen. Erst empirisch diskriminieren
(Env-Var auf Server, Beobachtung, oder gezielte User-Frage). Ein Symptom, das BEIDE
Hypothesen gleich gut erklärt ("Timeout nach Xs"), beweist keine.
- NICHT lokal "verifizieren" wenn das Problem umgebungsspezifisch ist (geblockte
Server-IP) — lokaler Erfolg ist falsch-positiv.
## 2026-05-30 — Abgestürzten/„aufgehängten" Chat fortsetzen: zuerst reflog lesen
**Muster:** User bat, einen anderen, aufgehängten Chat-Strang „zu Ende zu bringen".
Der Working Tree sah harmlos aus (nur untracked), aber der eigentliche Fortschritt lag
in einem per `reset --hard HEAD~1` weggesetzten Commit, der nur noch im **reflog**
(dangling) lebte.
**Regel:** Bei „mach weiter wo es hing":
1. `git reflog` + `git log --oneline -20` zuerst — Ground Truth, NICHT der
(evtl. stale) gitStatus-Snapshot oder Konversations-interne Annahmen.
2. Reset-weggesetzte/dangling Commits (`git fsck --lost-found`, reflog) inspizieren
(`git show <sha>`) — dort steckt oft die unfertige Arbeit.
3. **Verstehen WARUM weggesetzt**, bevor man blind cherry-picked: hier brach ein
bestehender Test (`.toBe(signal)`-Identitätscheck), den der Fix zwingend ändert.
Der Reset war die Reaktion darauf, nicht „Fix war falsch". Erst die Reset-Ursache
beheben (Test auf Verhalten umstellen), dann den Fix recovern.
4. Eigene Memory (`project_*`) lesen — sie dokumentierte Bug + intendierten Fix exakt.
## 2026-05-30 — Release verifizieren BEVOR "fertig" gesagt wird; curl -F mit Leerzeichen im Pfad
**Muster A (Edit ins Leere + trotzdem released):** Ein Edit schlug fehl ("String not
found"), ich habe es übersehen, committet und v1.7.165 released — die Datei enthielt
das Feature NICHT. Erst der nächste Blick zeigte es.
**Regel:** Nach jedem Feature-Edit VOR dem Release `git show HEAD:datei | grep <marker>`
— bestätigen dass der Code wirklich im Release-Commit ist, nicht nur dass `git commit`
durchlief.
**Muster B (Gitea UNIQUE constraint):** `npm run release:gitea` pusht erst den Tag,
dann erstellt es den Release. Gitea legt beim Tag-Push automatisch einen Tag-Release-
Eintrag an (name=null). `fetchExistingRelease` im Script matcht den nicht → POST create
`UNIQUE constraint failed: release.repo_id, release.tag_name`. Commit + Tag sind dann
schon gepusht, nur der Release+Assets fehlen.
**Recovery:** `GET /api/v1/repos/.../releases/tags/<tag>` → id holen → `PATCH releases/<id>`
mit name/body/draft:false → Assets per `POST releases/<id>/assets?name=<url-encoded>` hochladen.
**Muster C (curl -F Datei mit Leerzeichen):** `curl -F "attachment=@release/Datei mit
Leerzeichen.exe.blockmap"` lädt FALSCHEN Inhalt hoch (Server-Size != lokale Size).
**Regel:** Datei mit Leerzeichen im Namen erst nach `/tmp/leerzeichenfrei` kopieren,
DAS hochladen, Asset-Name über `?name=<url-encoded>` setzen. Danach Server-Size gegen
lokale Size prüfen.
## 2026-05-30 — Nicht in chaotische Parallel-Tool-Batches verfallen (User-Korrektur: "bist du in nem endless loop")
**Muster:** Bei einem großen Multi-File-Edit habe ich Dutzende Tool-Calls (Bash-Probes,
Reads, Edits, Python-Inline-Skripte, mehrfache tsc-Läufe) in EINEN Message-Block gepackt.
Resultat: Ein einzelner Fehler/Cancel hat die ganze parallele Kette abgebrochen, Edits
landeten halb, ich verlor den Überblick welche Änderung wirklich auf Disk war, und es
wirkte wie eine Endlosschleife. Dazu: wegwerf-`scripts/_*.py`/`_*.txt` als Workaround
gegen Output-Encoding statt der dedizierten Tools.
**Regel:**
- Edits über mehrere Dateien **sequenziell, einer nach dem anderen**, mit kurzer
Verifikation dazwischen — nicht 20 spekulative Calls auf einmal.
- Nach jedem Edit, der fehlschlagen kann (Anchor evtl. nicht eindeutig), das Ergebnis
lesen, bevor der nächste folgt. Edit/Write erroren laut — darauf vertrauen.
- KEINE Wegwerf-Python-Skripte ins Repo schreiben, um Shell-Output zu parsen. `Grep`/
`Read`/`Edit` nutzen. Wenn doch ein Temp nötig ist: nach `os.tmpdir()`, nie nach
`scripts/`, und sofort wieder löschen.
- Verifikation gebündelt am ENDE (1× tsc, 1× build, 1× vitest), nicht 10× zwischendrin.
## 2026-05-28 — Analyse-Befund gegen beobachtete Realität gaten (Advisor-Korrektur)
**Muster:** Meine Analyse sagte einen *häufigen* Bug voraus (jede letzte Datei im
Standard-Modus + jede Nested-Datei landet unbenannt), während der User nur "1-2 pro
Staffel" meldete. Ich habe die Diskrepanz bemerkt ("zu schwer um unbemerkt zu bleiben")
und sie mit weiterem Timing-Argument wegrationalisiert.
**Regel:** Wenn die eigene Analyse etwas vorhersagt, das der beobachteten Realität
widerspricht, NICHT die bequeme Lesart wählen — **mit einem Reproduktions-Test gaten**,
bevor man fixt. Failing Test gegen den Ist-Stand zuerst (TDD/systematic-debugging Phase 4):
- reproduziert → Bug bestätigt, mit Sicherheit fixen.
- reproduziert nicht → Analyse hat eine Mitigation übersehen, kein Fix für Nicht-Bug.
## 2026-05-28 — Crash-Debris im Working Tree: stashen, nicht verwerfen
**Muster:** Eine abgestürzte Session (API 400) hinterließ ein uncommittetes Working Tree,
das drei releaste Commits revertierte. Verlockung: `git checkout`/discard, um clean HEAD
zu bekommen.
**Regel:** Fremde/unverstandene uncommittete Änderungen **`git stash`** (non-destruktiv,
recoverable), nie blind verwerfen. Gibt clean HEAD, nichts geht verloren, kein Stall auf
User-Rückfrage. Danach dem User sagen WAS gestasht wurde und WARUM.
## Wiring-Lock vs. Mechanism-Test
Ein Test, der eine Hilfsfunktion mit dem richtigen Flag direkt aufruft, beweist nur, dass
das Flag funktioniert — NICHT, dass der Produktionspfad das Flag setzt. Für echte
Absicherung einen End-to-End-Test durch den realen Einstiegspunkt fahren und per
Negativ-Gate (Flag temporär entfernen → Test muss fallen) verifizieren.
## 2026-05-31 — Log-Symptom ≠ User-Wortlaut: greppen, bevor man auf eine Meldung triggert
**Muster:** User meldete Mega-Debrid-Tageslimit als „Kein Server für diesen Hoster". Ich
wollte den Fix an genau diese Meldung (`MEGA_DEBRID_NO_SERVER_RE`) hängen. Der Advisor
stoppte: der Screenshot zeigte als Cooldown-Grund **„Antwort leer"**, nicht „Kein Server".
**Beweis (Support-Bundle gegrept):** „Kein Server"/„Erreur"/„aucun serveur" = **0** Treffer
im ganzen Bundle, „Antwort leer" = **20.861** Treffer. Der limitierte Account liefert im
Web-Pfad NIE eine unterscheidbare Meldung — `generate()` findet ohne `processDebrid`-Code
keinen Code → `return null` → der Aufrufer macht daraus „Antwort leer". Ein Trigger auf
„Kein Server" wäre toter Code gewesen (= die v1.7.172-Falle, zum 2. Mal fast getreten).
**Regel:** Bevor man einen Fix an einen bestimmten Meldungstext hängt, in den ECHTEN Logs
greppen, ob dieser Text dort überhaupt vorkommt (`count`-Mode, alt-Text vs. Ist-Text). Sind
zwei Fälle auf Message-Ebene nicht unterscheidbar (Tageslimit vs. transienter Blip → beide
„Antwort leer"), nicht raten — über ein **Verhaltens-Signal** klassifizieren: hier eine
Streak (3× hintereinander leer → geparkt), nicht der einmalige Wortlaut.
**Wiring-Test nicht vergessen** (eigene Lesson): die Helfer-Unit-Tests beweisen nur den
Zähler. Ein E2E-Test muss eine ECHTE leere Antwort durch den realen Einstiegspunkt
(`unrestrictWithAccounts` → `classifyAccountFailure` → catch → Park) treiben, sonst bleibt
unbewiesen, dass der Produktionspfad das Signal überhaupt setzt.
## 2026-06-01 — Ein Verifizierer muss dieselbe Pfad-Normalisierung nutzen wie die verifizierte Operation
**Muster:** Neues Renaming-Logging sollte nach jedem Rename verifizieren, ob die Datei
wirklich unter dem Zielnamen liegt. `verifyRename` machte statSync/readdirSync auf den
ROHEN Pfaden — der echte Rename lief aber über `toWindowsLongPathIfNeeded` (\?\-Prefix
ab >=248 Zeichen). Bei langen Scene-Release-Pfaden (genau das, was die App routinemäßig
umbenennt) scheiterten die rohen fs-Calls → falsches „Ziel nicht gefunden" UND — schlimmer —
die Quell-Prüfung scheiterte ebenfalls → `sourceGone` fälschlich true → **falsches „OK"**,
das einen halb-fertigen Verschiebevorgang maskiert. Der Diagnose-Log hätte genau die
schwersten Fälle vergiftet. (Adversarialer Review-Workflow fand es, Confidence 0.8.)
**Regel:** Wenn Code eine Operation VERIFIZIERT, muss er exakt dieselbe Pfad-/Encoding-/
Normalisierung verwenden wie die Operation selbst (hier: \?\-Long-Path-Prefix). Sonst
mis-reportet der Verifizierer still — und am verlässlichsten bei den Edge-Cases, die man
eigentlich fangen wollte. Ein falsches OK in einem Diagnose-Log ist schlimmer als ein
falsches ERROR. Zusatz: readdir-Fehler darf nicht zu „Schreibweise ok" degradieren
(stilles False-OK) → eigenes WARN-Level „nicht verifizierbar".
**Meta:** Bei einem Feature, dessen ganzer Zweck Beobachtbarkeit/Verifikation ist, lohnt
ein adversarialer Review mit Fokus „würde die Verifikation auf der ECHTEN Last (lange
Pfade, case-insensitive FS, EXDEV) korrekt urteilen?" — nicht nur „kompiliert + Happy-Path-Test".
## 2026-06-03 — Renaming „nie 100%": entkoppelte Scans + Namens-Fabrikation aus token-losen Ordnern
**Symptom (aus dem Desktop-Rename-Log diagnostiziert):** 17 Dateien landeten ROH in der
Library ("tvarchiv...s07e12-720.mkv", "4sf-...s04e01.mkv"). KEINE [ERROR]-Zeile — alle [INFO],
weil die Verifikation nur „liegt die Datei am Zielnamen?" prüft, nicht „ist der Zielname
sinnvoll?". Das Logging hat den Bug sichtbar gemacht (genau sein Zweck).
**Root Cause 1 (entkoppelte Scans):** Auto-Rename (scannt nur extractDir, nur present-and-
stable Dateien, Freshness-Gate loggt nur via logger.info → keine Session-Spur) und
collectMkvFilesToLibrary (verschiebt JEDE .mkv, behielt den rohen Basename) sind getrennte
Scans. Eine von Auto-Rename verpasste Datei (verpasster Zyklus ODER lag in „Downloader
Unfertig" außerhalb extractDir) wurde von collect roh weggeschoben. **Fix:** collect leitet
den sauberen Namen SELBST ab — über dieselbe Funktion wie Auto-Rename (decideAutoRenameBaseName,
single source of truth) → Race wird egal, beide Pfade können nicht mehr divergieren.
**Root Cause 2 (latente Fabrikation, vom Advisor gefunden):** decideAutoRenameBaseName
fabrizierte „Mega-Direct-Pack.S01E01" für einen generischen Paketordner, weil
`hasSceneGroupSuffix("Mega-Direct-Pack")` auf „-Pack" falsch-positiv matcht und Guard B dann
die Quell-Episode an einen token-losen Ordner anhängt. Das hätte AUTO-RENAME genauso getroffen
(nur dormant, weil echte Releases saubere Ordner haben). **Fix an der Wurzel:** Rename nur,
wenn IRGENDEIN folderCandidate einen echten Season-/Episode-Token trägt — ein token-loser
Ordner kann keine Episode autoritativ benennen.
**Meta-Lektionen:**
1. Bei „X nie 100%": die Fehler aus dem ECHTEN Log ziehen (greppen), nicht raten. Hier:
„Kein Server" 0×, „Antwort leer" 20k×; und 17 vs vermutete 12 (5 begannen mit Ziffer „4").
2. Symptom-Fix vs Wurzel-Fix: ein collect-seitiger Guard (Quell-Auflösung+Codec) hätte das
Symptom kaschiert + eine Restlücke gelassen; der Wurzel-Fix in der gemeinsamen Funktion
schließt BEIDE Pfade + ermöglicht ehrliches 100%.
3. Wenn ein (Sub-)Agent eine empirische Behauptung aufstellt, die der beobachteten Realität
widerspricht (Review: „liefert no-target" vs Test: „benennt um"), NICHT raten — mit einem
Wegwerf-Diagnose-Test die echte Rückgabe sichtbar machen, DANN entscheiden.
4. „raw-keep ist der Boden" als Guard-Prinzip: ein Rename darf nie einen schlechteren Namen
erzeugen als der Originalname.
## 2026-06-03 (2) — Renaming „verschlimmbessert" guten Quellnamen (Scene-Gruppe mit Unterstrich)
**Symptom (neues Desktop-Log):** `castle.s08e02.german.dl.720p.web.h264-idtv_int.mkv` (bereits
SAUBER) im Ordner `Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT` (Paket `scn2-cstl7`) wurde zu
`scn2-cstl7.S08E02.mkv` — also GUTER Name → obfuskierter Paketname. Andere Klasse als die 17
(roh→nicht-angefasst); hier gut→schlechter.
**Ursache (reproduziert, kein Raten):** `hasSceneGroupSuffix("...H264-idTV_iNT")` = false, weil
`SCENE_GROUP_SUFFIX_RE`/`_FALLBACK_RE` Unterstriche im Gruppen-Suffix verbieten. → buildAutoRenameBaseName
verwarf den sauberen Episoden-Ordner (return null) → fiel auf den Paketordner `scn2-cstl7` zurück
→ Episode angehängt = `scn2-cstl7.S08E02`. Guard A (Quelle-besser) griff nicht, weil
`hasMeaningfulSeriesPrefix("scn2-cstl7.S08E02")=true` (Gruppe sieht aus wie Serien-Prefix).
**Fix:** `extractFlexibleSceneGroupSuffix` (existierte, war nicht verdrahtet) in hasSceneGroupSuffix
einbinden → Unterstrich-Gruppen erkannt → sauberer Ordner gewinnt → idealer Name.
**Meta-Lektionen:**
1. „100%" gilt nur fuer die DATEN, die man hatte. Mein lueckenloser Check des 2026-06-02-Logs war
korrekt — aber ein NEUER Download (Castle/idTV_iNT) brachte eine Gruppen-Form, die im alten Log
nicht vorkam. Bei „nie 100%" ehrlich sagen: „fuer die bekannten Faelle 100%, neue Muster brauchen
neue Logs". Das Desktop-Log liefert genau diese neuen Muster.
2. Reproduzieren statt raten: ein 3-Zeilen-Diagnose-Test (buildAutoRenameBaseName pro Ordner +
decideAutoRenameBaseName) zeigte sofort, WELCHER Ordner verworfen wird und warum — nicht spekulieren.
3. Offener Backstop-Gedanke fuer echte Robustheit: ein generelles Guard "ersetze nie einen bereits
VOLLSTAENDIGEN Quellnamen (Serie+Episode+Aufloesung+Codec) durch einen, der die Serien-Identitaet
verliert" wuerde KUENFTIGE unbekannte Gruppen-Formate abfangen — riskanter Eingriff in Guard A,
nur mit Tests + auf User-Wunsch.
## 2026-06-03 (3) — Renaming-Klasse „Junk-Quellname + sauberer Release-Ordner" (Folge-Nummer statt SxxExx)
**Symptom (Log 18-18):** „Kreuzfahrt ins Glück" — 25 Folgen `bet_kig_01_hdt.mkv` (obfuskiert, KEIN
SxxExx-Token) im sauberen Episoden-Ordner `Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.
German.720p.HDTV.x264-BET` (Episode als bloße „01"). Auto-Rename: „kein Zielname" → 25× roh in die
Library. Diesmal SICHTBAR als 25 [WARN] (vorher 0 WARN) — das Log zeigt die Klasse direkt.
**Ursache (reproduziert):** `buildAutoRenameBaseName` gibt null zurück, sobald die QUELLE keinen
SxxExx-Token hat (Z.1288) — egal wie sauber der Ordner ist. Das „Folge 01"-Nummernformat (kein
S01E01) wurde nie unterstuetzt. VORBESTEHEND, nicht meine v1.7.178/179.
**Fix:** Fallback in decideAutoRenameBaseName — wenn kein Zielname UND Quelle hat keinen
Episode-Token, den ersten folderCandidate nehmen, der ein VOLLSTAENDIGER Scene-Release-Ordner ist:
`hasSceneGroupSuffix(f) && (RESOLUTION_RE.test(f) || CODEC_RE.test(f)) && !SCENE_SEASON_ONLY_RE.test(f)`.
Greift NUR ohne Quell-Episode-Token → schliesst sich mit dem Fabrikations-Guard aus (Mega-Direct hat
Quell-Token → unerreicht). note:"folder-as-is".
**Advisor-Punkt (wichtig):** NICHT nur Aufloesung pruefen — alte deutsche TV-Serien gibt es als
DVDRip/XviD OHNE 720p-Token. `RESOLUTION_RE ODER CODEC_RE` → sonst die naechste Runde. Pin-Test:
DVDRip-Variante (kein 720p, nur x264).
**Edge (Advisor):** Bonus/Sample muss VOR diesem Fallback gefiltert werden (sonst kriegt ein
Featurette/Sample im Episoden-Ordner den Episodennamen). Bestaetigt: Auto-Rename-Loop (Sample-Size +
BONUS_FILENAME_RE) und Collect filtern beide vor der Namensherleitung → gedeckt.
**Meta:** 3. „anderes Format" in Folge — diese Klasse (Junk-Quelle + sauberer Ordner) ist die
groesste verbleibende. Scene-Naming hat aber einen langen Schwanz: ehrlich „diese Klasse ist
abgedeckt", nicht „jetzt 100%". Das Desktop-Log liefert jede neue Klasse sofort.
## 2026-06-04 — KEINE „Claude/AI"-Spuren in oeffentlichen Releases (GitHub)
**Korrektur:** „kein SCHAU MAL wie ich mit claude gearbeitet hab release … entfern alles was da drin
steckt." Beim einmaligen GitHub-Sync (Sucukdeluxe/real-debrid-downloader) waren oeffentlich: `CLAUDE.md`,
`design-mockups/`, `tasks/lessons.md`+`todo.md`, historisch `.claude/`, und **357 Commits mit
`Co-Authored-By: Claude`-Trailer**.
**Regel ab jetzt:** Fuer dieses Projekt KEINE `Co-Authored-By: Claude`-Trailer mehr an Commits
(ueberschreibt die Default-Git-Anweisung — User-Wunsch hat Vorrang). Keine KI-Artefakte (CLAUDE.md,
Mockups, lessons/todo, .claude/) in irgendetwas, das oeffentlich gepusht wird.
**Wie sauber gemacht (ohne Gitea/lokal anzufassen):** isolierter `git clone``git filter-repo`
(`--invert-paths --path …` + `--message-callback` der Trailer-Zeilen droppt) → Force-Push NUR main +
v1.7.180 zu GitHub. Alte Tags NICHT geloescht, sondern via `.git/filter-repo/commit-map` auf ihre
sauberen Commits **umgehaengt** (89 Tags, alle Releases bleiben erhalten) — besser als Loeschen.
**Ehrliche Grenze (Advisor):** Force-Push säubert nur ref-erreichbare Historie. Verwaiste alte Commits
bleiben per voller SHA erreichbar, bis GitHub GC'd ODER das Repo neu angelegt wird (nur der User kann
das — Token hat kein `delete_repo`). Lokaler Klon verifiziert ≠ GitHub-Zustand: immer per `gh api`
gegenpruefen (Datei 404 am Tag, Commit-Messages trailer-frei).
**Methodik:** vor Force-Push Voll-Range-Secret-Scan (push-protection killt sonst mitten im Push) +
Tree-Content-Grep auf `claude|anthropic` (filter-repo tilgt Pfad-NAMEN + Trailer, nicht Datei-INHALTE).
## 2026-06-04 — Folge bleibt bei „Downloader Fertig" haengen: Episodentitel == Bonus-Wort
**Symptom (User-Screenshot + rd-support-bundle):** `Revenge.2011.S04E19.Interview...mkv` extrahiert +
korrekt umbenannt, aber NIE in die Library verschoben — kein Fehler. „selten, 4-5 Folgen pro 1,5TB".
**Diagnose (Bundle):** Paket-Log zeigte 22/23 „MKV verschoben", E19 fehlte, KEIN WARN/ERROR. Im
HAUPT-Log (`rd_downloader.log`) dann 5× `MKV-Sammelordner: Bonus-Datei uebersprungen: ...S04E19.Interview`.
**Root Cause:** `BONUS_FILENAME_RE` enthaelt `interview` (+ outtakes/special/featurette/bloopers/...). Der
Episodentitel „Interview" (UND der Episoden-Ordnername — `isInsideBonusDir` macht `.includes()` Substring)
matchte → `collectMkvFilesToLibrary` stufte die echte Folge als Bonus/Extras ein und skippte sie. Trifft
auch ganze Serien deren NAME ein Bonus-Wort ist. Skip war nur `logger.info` → im Paket-Log UNSICHTBAR
(darum „silent orphan", nur via Forensik gefunden).
**Fix:** neue exportierte `isBonusContent(filePath, packageDir, nameWithoutExt)` — eine Datei MIT echtem
SxxExx-Token (`extractEpisodeToken`) ist eine nummerierte Episode, NIE Bonus (egal welches Titelwort).
Echte Extras (kein Token / Extras-Subordner) bleiben gefiltert. Beide Call-Sites umgestellt (Auto-Rename
~4312 + Collect ~5054). 2 Integrationstests (Interview wird gesammelt / Making.Of bleibt) + 5 Unit-Tests.
**Diagnose-Lektion (Advisor-Gate):** „4-5 Folgen" plural → NICHT beim 1. Fund stoppen. Bundle-weit
gegengeprueft: 0 Move-Fehler, nur 1 Bonus-Skip. 4 weitere „noch frisch"-Defers sahen wie Orphans aus,
waren aber FALSE POSITIVES — Moves loggen NICHT ins Haupt-Log (nur Paket-Log), und deren Paket-Logs fehlten
im Bundle. Per Code bewiesen: finaler Deferred-Collect laeuft fuer jedes fertige Paket (`success` =
completed-Items, Z.11904) mit `deferFreshFiles=false` → faengt Frische-Defers. Also Frische orphan't NICHT;
Bonus schon (Filter ignoriert deferFreshFiles, skippt in JEDEM Pass inkl. final). Lehre: bevor man „X ist
Orphan" behauptet, pruefen ob der GEGENBEWEIS (Move) im verfuegbaren Log ueberhaupt sichtbar WAERE.
## 2026-06-05 — Folge bleibt ROH: vollstaendiger Episoden-Ordner OHNE -GROUP-Suffix
**Symptom (rename-session 2026-06-04):** `safari-fm-s04e08a.avi` / `...b.avi` landeten ROH in der Library
(entpackt2). Log: `Auto-Rename übersprungen: kein Zielname`. Funktionierende S01E02 hatte Ordner
`...XviD-SAFARi` (Gruppe), die kaputten S04E08a/b hatten `...SATRiP.XviD` (KEIN -GROUP).
**Root Cause (Wegwerf-Diagnose, NICHT geraten):** Erste Hypothese „a/b-Token nicht erkannt" war FALSCH —
`extractEpisodeToken("...s04e08a")`="S04E08" (das Lookahead `(?!\d)` verbietet nur Ziffern, nicht Buchstaben).
Echte Ursache: das Gate in `buildAutoRenameBaseName` (`isLegacy4sf || isSceneGroupFolder`) lehnt einen
vollstaendigen Episoden-Ordner OHNE -GROUP ab (endet auf bare Codec `.XviD`). Die QUELLE hat aber einen
Token → der v1.7.180-Fallback (greift NUR ohne Quell-Token) feuert nicht → no-target → roh gemoved.
**Fix:** Gate um `isCompleteEpisodeFolder` erweitert = echter Episoden-Token IM Ordner UND Codec-/
Aufloesungs-Marker (neue Module-Consts `SCENE_RESOLUTION_MARKER_RE` / `SCENE_CODEC_MARKER_RE`, inkl.
xvid/divx). Part-Buchstabe a/b bleibt erhalten (Ordnername dient unveraendert als Zielname; nur der
RANGE-Zweig schreibt Token um, und a/b ist kein Range). Konservativ: bare „Show.S01E01" ohne Marker bleibt
abgelehnt (kein Over-Firing). v1.7.180-Fallback nutzt jetzt dieselben Module-Consts (DRY). Greift in
Auto-Rename UND Collect (beide via decideAutoRenameBaseName). 5 Unit- + 1 Collect-Integrationstest.
**Methodik-Lektion:** Die naheliegende Hypothese (a/b-Suffix) per Diagnose-Test widerlegt, BEVOR gefixt —
das Lookahead genau gelesen statt angenommen. Spart einen Fix am falschen Ort.
## 2026-06-05 — Collect zerstoert fertigen S01E01-Namen via Episoden-Titel-Ordner (Miniserie)
**Symptom (rename-session 2026-06-05):** Miniserie "Steven Spielbergs Taken" landete als
"...E01.Hinter.dem.Himmel...-GTVG.S01E01.mkv" (Episodentitel + hinten angehaengtes S01E01) statt sauber
"...S01E01...-GTVG.mkv". User: "keine Staffel, nur Episodentitel".
**Root Cause (diagnostisch bewiesen):** Auto-Rename benannte korrekt zu "...S01E01...-GTVG.mkv" (kombiniert
S01 aus dem Paket/Season-Ordner + E01 aus der Quelle). Der COLLECT (deriveCleanCollectFileName ->
decideAutoRenameBaseName) leitet die Datei NEU ab — Quelle ist nun der schon-saubere Name. Der per-Episode-
Ordner traegt aber nur einen Episode-only-Token + Titel ("...E01.Hinter.dem.Himmel...-GTVG", KEIN S01).
buildAutoRenameBaseName nimmt den Ordner (Gruppen-Suffix -GTVG vorhanden). In Guard B `if (!targetEpisodeToken)`
wird der Quell-Token an den Ordnernamen ANGEHAENGT (applyEpisodeTokenToFolderName) -> "...-GTVG.S01E01"
(Token HINTER der Gruppe = verkrueppelt). Der Root-Guard greift NICHT, weil der Season-Ordner einen S01-Token
liefert (anyFolderHasSeasonOrEpisode=true).
**Fix:** In Guard B, im `!targetEpisodeToken`-Zweig VOR dem Anhaengen: ist die QUELLE ein NICHT
obfuskierter Scene-Name (`!looksLikeObfuscatedSceneFileName(sourceName)`), dann
`return {kind:"skip", reason:"source-better"}` -> Collect behaelt den fertigen Namen. In diesem Zweig
traegt die Quelle den EINZIGEN SxxExx-Token (Ordner hat keinen) -> obfuskiert? -> Ordner gewinnt (Append),
sauber? -> Quelle gewinnt. Greift NUR im `!targetEpisodeToken`-Zweig (Ordner ohne SxxExx); safari
(Ordner MIT Token) unberuehrt. 4 Unit- + 1 Collect-Integrationstest. tsc 6 (Baseline), 700/700 gruen, Build gruen.
**Methodik:** Erst Diagnose (decideAutoRenameBaseName mit Collect-Inputs) -> exakt der mangled Name
reproduziert. Per User-Wunsch adversarial via Workflow gegengeprueft (ultracode, 3 Lenses + Synthese).
**Adversarialer Befund (Workflow fing's):** Mein erster Guard hatte einen ZWEITEN Konjunkt
`hasMeaningfulSeriesPrefix(sourceBaseName)` (>=3 Alpha vor S0x). Der ist sachfremd: KURZE Serien (ER, V,
24, Yu) fallen durch -> selber verkrueppelter Name. Gestrichen -> nur `!obfuskiert` gaten. Lehre: ein
zusaetzlicher "klingt-vernuenftig"-Konjunkt (Praefix-Laenge) kann eine ganze reale Klasse (Kurz-Titel)
stumm ausschliessen; adversariale Verifikation mit konkretem Gegenbeispiel (ER.S01E01) hat's gefunden.

View File

@ -0,0 +1,104 @@
# Plan: „Nur deutsche Tonspur behalten" (.DL.) als Tool-Funktion
Quelle der Idee: User-Script `Remove Non German Audio.py` (ffmpeg `-map 0:v:0 -map 0:a:0
-c copy -map_metadata -1`, + `.DL.`→`.` Rename). Soll als **toggle­barer Post-Extract-Schritt**
nach jedem Entpacken laufen, nur für **MKV/MP4 mit `.DL.` im Namen** (Dual-Language),
und nur die **deutsche** Spur behalten. Fundiert per 6-Agent-Analyse + Advisor.
## 1. Verhalten (Soll)
- Läuft automatisch nach dem Entpacken eines Pakets (wenn Toggle an), bevor MKV-Collect.
- Pro extrahierter Video-Datei mit `.DL.` im Namen (case-insensitive, nur .mkv/.mp4):
1. Audiospuren prüfen → deutsche/erste Spur bestimmen (Modus = User-Entscheidung, s.u.).
2. Wenn >1 Audiospur: remux (stream-copy, kein Re-Encode) → behält Video + 1 Audio
(+ optional dt. Untertitel) → Temp-Datei → atomar ersetzen.
3. `.DL.` aus dem Dateinamen strippen (`.DL.`→`.`, `.DL`→``), Companion-Dateien (Untertitel/.nfo) mitziehen.
4. Wenn nur 1 Audiospur: **kein** Remux (spart Neuschreiben großer Dateien), ABER `.DL.`-Strip trotzdem.
- Status pro Item sichtbar (z.B. „Tonspur wird bereinigt" / „Deutsche Spur behalten").
## 2. Architektur
- **NEUES Modul `src/main/video-processor.ts`** (spiegelt `extractor.ts`: exportierte async-Funktion
+ Options-Bag, KEINE DI-Klasse — es gibt keinen Constructor-Seam). Enthält:
- ffmpeg/ffprobe-Spawn nach dem `runExtractCommand`-Muster (extractor.ts:1296): `spawn(cmd,args,{windowsHide:true})`,
Promise-Wrapper, Timeout-Watchdog → `killProcessTree` (taskkill /T /F), **AbortSignal IN den Child** geben.
- **Pure exportierte Helfer** für Unit-Tests: `pickGermanAudioTrack(probeJson, mode)`, `stripDualLangMarker(name)`,
`buildFfmpegRemuxArgs(...)`, `computeRemuxTimeoutMs(bytes)`.
- ffmpeg-Exit-Codes ≠ 7-Zip (NICHT die „exit 1 = ok"-Logik kopieren — nur das Spawn/Await/Kill-Gerüst).
- ffprobe-JSON auf stdout NICHT durch den 48KB-Tail-Cap (`appendLimited`) — stdout separat voll puffern.
- **ffmpeg-Discovery (Option a, empfohlen):** System-PATH + `RD_FFMPEG_BIN` env + lazy `ffmpeg -version`-Probe
gecacht (spiegelt `RD_7Z_BIN`, extractor.ts:1030-1083). **Nicht bündeln** (~80-150MB → triggert den
eigenen 150MB-Large-Bundle-Selfcheck debug-setup.ts:22 + GPL-Lizenzpflicht). Wenn ffmpeg fehlt → Schritt
überspringen + WARN loggen + (optional) in Health-Check/Errors surfacen. NIE Downloads blockieren.
- **CPU-Priorität:** `lowerExtractProcessPriority(pid, priority)` + `extractOsPriority` wiederverwenden,
Priorität als **expliziten Param** (nicht das Modul-Global `currentExtractCpuPriority` — Cross-Talk-Gefahr).
Honoriert `settings.extractCpuPriority`.
## 3. Einhängepunkte (BEIDE Pfade — kritisch!)
Post-Processing ist **pro Paket**, zwei Pfade; Hybrid-Pakete durchlaufen NIE den Deferred-Pass:
- **Deferred** (download-manager.ts ~11614): nach `autoRenameExtractedVideoFiles`, VOR archive-cleanup/collect.
- **Hybrid** (download-manager.ts ~10944): zwischen Rename und Collect im detached Block.
- Beide: **innerhalb `chainPackageFileOp(pkg.id, ...)`** (serialisiert Datei-Ops pro Paket), nur auf
`pkg.extractDir` operieren — NIE im geteilten `mkvLibraryDir` (= der v1.7.107-revertierte Cross-Package-Crash;
autoRename bricht bei Overlap ab, 3905-3919).
- **Gate:** neuen Flag in den Post-Process-Aggregator OR-en (~7078-7084), sonst läuft der Schritt nie
standalone. Hängt inhärent an `autoExtract` (braucht entpackte Dateien).
- Datei-Enumeration: `collectVideoFiles(rootDir)` (rekursiv, SAMPLE_VIDEO_EXTENSIONS, constants.ts:28) — nur
.mkv/.mp4 verarbeiten; Sample/Bonus-Dateien per vorhandenem Skip-Prädikat auslassen.
## 4. Der .DL.-Knoten (LÖST den „Feature no-op"-Fehler)
- Selektion = „Datei hat `.DL.`"; der Schritt strippt `.DL.`. → KEIN früherer Schritt darf den Marker entfernen.
- **autoRename NICHT ändern** (behält `.DL.` verbatim) → Marker überlebt bis zum Video-Schritt.
- Video-Schritt läuft **nach** autoRename → sieht `.DL.` → remuxt + strippt `.DL.` atomar pro Datei.
- **NUR `collectMkvFilesToLibrary.deriveCleanCollectFileName`** bekommt den `.DL.`-Strip als Post-Transform
(läuft NACH dem Video-Schritt → kann den Selektor nicht brechen, verhindert nur Re-Einführung aus dem
Ordner-Token). Companion-Files via `renameCompanionFiles`/`moveCompanionFiles` mitziehen.
## 5. Sicherheitsmodell (Original NIE verlieren)
- Remux → Temp-Datei → Größe > 0 (idealerweise ~plausibel) prüfen → erst dann atomar ersetzen/umbenennen
(`renamePathWithExdevFallback` + `verifyRenameAsync`). ffmpeg-Fehler/Abbruch → Temp löschen, Original bleibt.
- **Disk-Space-Pre-Check**: vor Remux freien Platz ≥ Dateigröße (+Marge) prüfen, sonst skip+log
(Temp verdoppelt transient den Platz auf einer Platte, die grad entpackt hat / parallel lädt).
- **AbortSignal in den ffmpeg-Child** (Deferred-/Hybrid-Controller) → Stop/Cancel/Reset killt laufenden Remux.
- **mtime erhalten** (`fs.utimes` nach Remux) → sonst überspringt Hybrid-Collect (deferFreshFiles=true) die
frisch angefasste Datei.
- **Sicherheits-Invariante (BEIDE Modi):** Original nur ersetzen, wenn die behaltene Spur sicher die richtige
ist. Bei Unsicherheit (keine Tags / kein Deutsch gefunden) → Datei UNANGETASTET lassen + loggen, statt
versehentlich die einzige brauchbare Spur zu löschen.
- Dispositions-Flag der behaltenen Spur auf „default" setzen.
- Best-effort pro Datei: ein Fehler markiert NICHT das Paket als failed und blockiert nicht den Collect anderer Dateien.
## 6. ffmpeg/ffprobe-Aufrufe (Stream-Copy, schnell)
- Probe (nur im Tag-Modus): `ffprobe -v error -select_streams a -show_entries stream=index:stream_tags=language,title -of json INPUT`
- Remux erste Spur (Script-Parität): `ffmpeg -i INPUT -map 0:v:0 -map 0:a:0 [-map 0:s? je nach Untertitel-Option] -c copy -map_metadata -1 -disposition:a:0 default -y TEMP`
- Remux deutsche Spur (Tag-Modus): `-map 0:v:0 -map 0:a:<dt-Index> ...` (Index aus ffprobe).
## 7. Settings/UI-Wiring (5 Pflicht-Stellen, +1 optional)
1. `src/shared/types.ts` AppSettings: `keepGermanAudioOnly: boolean` (+ ggf. `germanAudioMode`, `keepGermanSubs`, `ffmpegPath`).
2. `src/main/constants.ts` defaultSettings: `keepGermanAudioOnly: false` etc.
3. `src/main/storage.ts` normalizeSettings: `Boolean(...)` (Pfad: `asText`, NICHT normalizeAbsoluteDir → leer = System-ffmpeg).
4. `src/renderer/App.tsx` Settings-Tab „entpacken" neben collectMkvToLibrary: Toggle + eingerückte Sub-Optionen (disabled wenn aus).
5. `src/renderer/App.tsx` **emptySnapshot()-Literal** (~840-859) — sonst tsc-Fehler (Feld non-optional).
6. (optional) `src/main/support-data.ts` ~95: Flag in Diagnose-Export spiegeln.
## 8. Tests + Verifikations-Gate
- ffmpeg in Tests **gemockt** (kein echter ffmpeg-Lauf): neues Modul via `vi.mock` in download-manager.test.ts
(assert: korrekt aufgerufen + Sequenz nach autoRename / vor collect, Deferred + Hybrid). KEIN blankes
`vi.mock("node:child_process")` in download-manager.test.ts (bricht echte Extractor-ZIP-Tests).
- Separate `video-processor.test.ts`: `node:child_process` mocken → ffmpeg/ffprobe-ARGS asserten (Track-Wahl, Untertitel-Option).
- Pure Helfer fs-frei testen (wie tests/auto-rename.test.ts): `pickGermanAudioTrack`, `stripDualLangMarker`.
- Negativ-Test: Toggle aus → keine Verarbeitung. Edge: 1-Audio-`.DL.` → nur Rename, kein Remux. Kein-Deutsch → unangetastet.
- **Gate:** tsc-Baseline = 6 vorbestehende Fehler (NICHT clean) → „keine NEUEN tsc-Fehler" + vitest 728→728+N grün + `npm run self-check` grün.
## 9. OFFENE ENTSCHEIDUNGEN (vor Bau — per AskUserQuestion)
- **A. Spurauswahl:** Script-Parität (immer erste Audiospur, kein ffprobe, validiertes Verhalten) vs.
Smart (deutsche Spur per Sprach-Tag, Fallback erste Spur, skip wenn kein Deutsch).
- **B. Untertitel:** weglassen (wie Script) vs. deutsche Untertitel behalten.
- **C. ffmpeg-Quelle:** nur System-PATH + `RD_FFMPEG_BIN` env vs. zusätzlich Settings-Pfad-Feld im UI.
## 10. Umsetzungsreihenfolge (nach Entscheidungen)
1. `video-processor.ts` + pure Helfer + deren Unit-Tests (TDD).
2. ffmpeg/ffprobe-Discovery (probe+cache).
3. Settings-Wiring (5 Stellen) + UI-Toggle.
4. Einhängen in Deferred + Hybrid (in chainPackageFileOp), Gate OR-en.
5. collect deriveCleanCollectFileName: `.DL.`-Strip-Safety-Net.
6. Logging (logRenameProcess, neuer Stage 'audio-strip').
7. Tests (download-manager mock + video-processor args + negativ/edge). Gate prüfen.

164
tasks/todo.md Normal file
View File

@ -0,0 +1,164 @@
# Real-Debrid-Downloader — Tasks (Stand 2026-06-08)
**Status:** Alle zugesagten Features erledigt+released (Archiv unten). Aktuell läuft ein
**intensiver Bug-Audit** (User-Goal 2026-06-08, "schaue intensiv nach weiteren Bugs") —
Fortschritt direkt unten.
---
## 🔴 LAUFEND — Bug-Audit 2026-06-08 (Multi-Agent find→verify, 18 bestätigt)
Advisor-Triage: **A = einzige echte Daten-Verlust-Notlage** (zerstört echte Datei auf Platte)
→ zuerst, ALLEINE Release. **B verifiziert demoted:** applyRetroactiveCleanupPolicy/
removePackageFromSession löschen KEINE Platten-Dateien (nur Session/Queue-Einträge + ggf.
History-Eintrag) → Queue-Integrität, nicht Daten-Verlust → in v1.7.190-Batch.
Sequenz: Release 1 (v1.7.189) = **A allein**; Release 2 (v1.7.190) = B/I,C,D/E,F,G,H,J,L,M,N,O,P,Q.
Ein Commit pro Fix, jeder einzeln verifiziert. **K übersprungen** (auto-rename-Reorder,
schlechtestes Risiko/Nutzen, kann für diesen User gar nicht feuern).
### Release 1 — Daten-Verlust-Stopper (v1.7.189, A ALLEIN)
- [x] **A** `video-processor.ts` atomic-replace zerstörte bei Windows-Lock BEIDE Kopien
(rm(original) VOR bestätigtem Replace + outer-catch rm(temp) → 0 Kopien). **GEFIXT:**
atomic replace-over + `renameWithRetry` (EBUSY/EACCES/EPERM/EEXIST, Backoff 200/500/1000ms),
rm-first-Fallback entfernt, **unique** Temp-Name (`~rd<pid><rand>`, löst auch C-Kollision).
Advisor bestätigt Ansatz besser als bak-dance (kein Missing-File-Window). 3 neue Tests
(Recovery + Retry-Pfad), 41 video-processor-Tests grün, tsc=6 (Baseline). Commit 189af22.
### Release 2 — v1.7.190 (GEFIXT + verifiziert, ein Commit pro Fix)
- [x] **L+M** video-processor.ts zu weite Deutsch-Erkennung. isGermanStream Titel-Fallback nur
ganze Wörter (ger/deu raus → konnten falsche Spur picken + echte dt. löschen); looksLikeGerman
Release 'dubbed' raus (ital./franz. Dub triggerte German-first). 2 Negativtests. Commit 272a41a.
- [x] **H** logger.ts flushAsync slice-snapshot korrumpiert bei 1MB-Cap-Trim während await →
ungeschriebene Zeilen verloren. Move-snapshot (Buffer auf [] übernehmen) + Requeue bei
Schreibfehler. Commit 4432fa2.
- [x] **J+Q** download-manager. J: runPackagePostProcessing finally löschte Map-Eintrag ohne
Identity-Guard → Abort+Neustart-Race riss neuen Task raus (Waise + Doppel-Lauf); jetzt nur
löschen wenn Map noch auf DIESEN Task/Controller zeigt (handle-Objekt wegen TS2454). Q:
collectFilesByExtensions filtert `~rd`-Temp-Präfix (crash-verwaiste Teil-Remuxe nie ins
Library). Commit 3c33b98.
- [x] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives
gepurged → verschachtelte Archive beim Resume neu entpackt; `startsWith("nested:")` im Prune
übersprungen. Commit 61a8304.
- [x] **B/I** app-controller.ts importBackup settings-only purgte LIVE-Queue (Dateien blieben auf
Platte) + rollte Usage-Zähler zurück. Fix: setSettings({suppressRetroactiveCleanup}) +
overlayLiveUsageCounters (extrahiert+wiederverwendet, inkl. Key-Filter). Commit dc05b51.
### Verifiziert KEINE Bugs / bewusst NICHT angefasst (Advisor-Disziplin: erst belegen, dann ändern)
- **G** dropItemContribution "subtrahiert Session-Totals nicht" → **KEIN Bug**: Test "keeps
cumulative session totals when completed items are removed" kodifiziert die Absicht (Session-
Zähler kumulativ, divergieren bewusst von der Item-Map; Retry-Pfad zieht ab, weil neu geladen
wird). Fix-Versuch ließ den Test failen → revertiert, Klarstellungs-Kommentar gesetzt.
- **N** stripDualLangFromFileName "Kollision" → **bereits geguarded**: existsAsync-Skip verhindert
Überschreiben; Remux machte Inhalt eh deutsch-only; collect strippt `.DL.` downstream. Residual
= generischer Rename-TOCTOU (in JEDEM Rename-Pfad), kein spezifischer Bug hier.
- **D/E** abort-Klassifizierung über signal.reason statt Text → **deferred (Robustheit, kein
Live-Bug auf User-Pfad)**. BELEGT: mega-web-fallback normalisiert JEDEN Abort (Timeout UND
Cancel) zu `new Error("aborted:mega-web")` → aktueller Guard `/aborted/i && !/timeout/i` FEUERT
→ v1.7.187-Cooldown LÄUFT auf dem Web-Pfad (User-Pfad). Einzige Imperfektion: Cancel >8s wird
fälschlich gecooled (minor). Empirisch bestätigt: `AbortSignal.any([ac,timeout]).reason?.name===
'TimeoutError'` (timeout) vs string/AbortError (cancel) — falls je gebaut: signal.aborted-gaten,
reason.name nutzen, Text-Fallback behalten, reason-Test. Hoch-Risiko (kritischer Unrestrict-Pfad
JEDES Downloads) → nicht für Robustheit anfassen. API-Pfad-Abort-Text nicht erschöpfend geprüft.
- **E** "API 'cancel'-Pfad umgeht" → **nicht real**: kein `'cancel'`-throw im Code gefunden.
- **O** classifyAccountFailure abort-Branch tot → **stehen lassen**: tot NUR wegen aktueller
Text-Interception; ein signal.aborted-gated D/E würde ihn wiederbeleben. Kein Kosmetik-Churn.
- **F** Mega-Web empty-streak Concurrency → **N-shaped, deferred**: Streak wird bei Erfolg (1956)
+ Nicht-Limit-Fehler (2005) gecleart; "bis Neustart gesperrt" ist bewusste Tageslimit-Logik,
Restart-cleared; Mega-Web single-flight → Concurrency greift nicht. Keine fühlbare Schädigung
konstruierbar → keine Park-State-Maschinerie.
- **C** → in A subsumiert (unique Temp-Name). **K** übersprungen (auto-rename-Reorder, Risiko≫Nutzen).
---
## 🟢 OFFEN — Backlog (optional, nie begonnen)
### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187)
**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief)
setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel,
RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account
und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure).
Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt
rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2
rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1
brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte
per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft.
**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)**
**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen
Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert.
**Verifizierter Mechanismus (Code):**
- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s →
weiter zu Account 2. Account 2 → `aborted:debrid`.
- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein →
`throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.**
- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim
download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein
Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2.
- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort,
timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis
180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt
das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance.
**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich"
(11:51:4511:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 —
**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der
wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal →
Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig
bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt
nur sporadisch (no-server-Poll) bis ins 60s-Timeout.
**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap.
debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann
beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink`
({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider
unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal,
AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop;
eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3).
**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST).
**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download).
Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation
nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten.
### Features / UX (nach ROI)
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.
1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — SM. Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
2. [ ] **Fernsteuerung über Debug-Server** (POST-Endpunkte) — SM. Server hat HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop`.
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird nie geprüft → versehentliche Re-Downloads. Warnen: "3 Links bereits geladen".
4. [ ] **Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen".
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung für Downloads → Abbruch mitten drin bei voller Platte.
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen". (Daten dafür liegen jetzt teils in der Error-Ring aus v1.7.185.)
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nicht dargestellt. Welches Abo lohnt sich?
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — SM. Quota/Cooldown-Fails am nächsten Tag automatisch neu.
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1.
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M.
### Design-Richtung (Entscheidung steht aus)
4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon).
→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich).
### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren)
- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host
- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär
- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas
- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate())
- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`)
- [ ] Hybrid race: 1 Datei/Staffel evtl. beim MKV-Move nicht umbenannt (NUR per-package fixen — Post-MKV-Move-Scan ist tabu, v1.7.107 revertiert)
---
## ✅ ERLEDIGT — Archiv (Details in git-History + Memory)
- **Erweitertes Logging** → released **v1.7.185** (Crash-Handler, Renderer-Fehler-IPC, RD_DEBUG-Level, Error-Ring + `/errors`, ENOSPC-Klassifizierung, Memory-Heartbeat). → Memory: extended-logging
- **Link-Prefetch** → untersucht (6-Agent) + **bewusst verworfen** (marginal bei maxParallel 8, Mega-Web single-flight). → Memory: link-prefetch-declined
- **Backup nur Settings** → v1.7.184 (`backupIncludeDownloads`-Toggle + 4 Selektions/Flicker-Fixes). → Memory: backup-settings-only
- **Account-Rotation-Overhaul** → v1.7.164168 (Validity/Premium-Badges, Live-Panel, "Alle prüfen"). → Memory: account-rotation
- **Mega-Debrid-Account deaktivieren (UI)** → erledigt (Toggle im Edit-Dialog, im Code verifiziert 2026-06-07)
- **Bugs/Robustheit (Deferred-Pipeline H1/H2/H3/M1/M2/N1)** → v1.7.158/159; M3 bewusst übersprungen (Generation-Guard schützt Integrität bereits)
- **Deferred-Pfad Rename-Gap** → gefixt v1.7.162+ (finaler Deferred-Pass benennt frische Dateien vor Collect um; Repro-Test grün)
- **Repo-Privacy-Audit** → GitHub gelöscht+neu (saubere History), Gitea unberührt. → Memory: repo-privacy-audit
### Bewusst NICHT angefasst (Crash-Debris / alte Experimente)
- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar
- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai)

161
tests/account-check.test.ts Normal file
View File

@ -0,0 +1,161 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
import type { AppSettings } from "../src/shared/types";
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
}
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
}
function mockFetchOnce(status: number, body: unknown): void {
const text = typeof body === "string" ? body : JSON.stringify(body);
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: status >= 200 && status < 300,
status,
text: async () => text
})) as unknown as typeof fetch);
}
const NOW = 1_700_000_000_000;
afterEach(() => {
vi.unstubAllGlobals();
});
describe("checkMegaDebridAccount", () => {
it("reports valid + premium from vip_end (future Unix ts)", async () => {
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(true);
expect(st.premiumUntilMs).toBe(futureSec * 1000);
expect(st.email).toBe("a@b.de");
expect(st.message).toMatch(/Premium noch/);
});
it("reports valid but NOT premium when vip_end is in the past", async () => {
const pastSec = Math.floor(NOW / 1000) - 1000;
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
});
it("reports valid but no premium when vip_end is 0/missing", async () => {
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
expect(st.premiumUntilMs).toBe(0);
expect(st.message).toMatch(/Kein Premium/);
});
it("reports invalid login when response_code != ok", async () => {
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.isPremium).toBe(false);
expect(st.message).toMatch(/Ungueltiger Login/);
});
it("reports invalid on HTTP error", async () => {
mockFetchOnce(500, "server error");
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
});
it("never throws on network error — returns a failed status", async () => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch);
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
});
});
describe("checkDebridLinkKey", () => {
it("reports valid + premium from premiumLeft seconds", async () => {
const premiumLeft = 60 * 24 * 60 * 60;
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(true);
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
});
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
expect(st.message).toMatch(/Free/);
});
it("reports invalid key on HTTP 401", async () => {
mockFetchOnce(401, { success: false, error: "badToken" });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.message).toMatch(/Ungueltiger API-Key/);
});
it("reports invalid key when success=false", async () => {
mockFetchOnce(200, { success: false, error: "badToken" });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
});
});
describe("checkAllDebridAccounts", () => {
it("returns empty array when nothing configured", async () => {
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toEqual([]);
});
it("checks every configured mega account + debrid-link key", async () => {
const futureSec = Math.floor(Date.now() / 1000) + 1000;
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
if (String(url).includes("mega-debrid")) {
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: String(futureSec) }) };
}
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
}) as unknown as typeof fetch);
const settings = {
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
megaPassword: "",
debridLinkApiKeys: "key1\nkey2\nkey3"
} as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(5);
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
expect(result.every((r) => r.valid)).toBe(true);
});
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
let inFlight = 0;
let maxInFlight = 0;
vi.stubGlobal("fetch", vi.fn(async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
}) as unknown as typeof fetch);
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(9);
expect(maxInFlight).toBeLessThanOrEqual(4);
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
});
});

View File

@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
import type { RotationEvent } from "../src/shared/types";
describe("rotation item-sink (AsyncLocalStorage)", () => {
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
const captured: RotationEvent[] = [];
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" });
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
await Promise.resolve();
});
const events = captured.map((e) => e.event);
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
const failed = captured.find((e) => e.event === "FAILED");
expect(failed?.reason).toBe("Timeout");
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
});
it("does not leak events to the sink outside the run() scope", () => {
const captured: RotationEvent[] = [];
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
expect(captured).toHaveLength(0);
});
it("isolates two parallel item sinks (no cross-attribution)", async () => {
const a: RotationEvent[] = [];
const b: RotationEvent[] = [];
await Promise.all([
runWithRotationItemSink((ev) => a.push(ev), async () => {
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
await new Promise((r) => setTimeout(r, 10));
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
}),
runWithRotationItemSink((ev) => b.push(ev), async () => {
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
await new Promise((r) => setTimeout(r, 5));
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
})
]);
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
});
it("still feeds the global UI ring (outcomes only, TEST filtered)", () => {
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
const ring = getRecentRotationEvents(10);
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
});
});

48
tests/audit-log.test.ts Normal file
View File

@ -0,0 +1,48 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownAuditLog();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("audit-log", () => {
it("writes audit events to the audit log", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-"));
tempDirs.push(baseDir);
initAuditLog(baseDir);
logAuditEvent("INFO", "Settings changed", { changedKeys: ["token", "autoExtract"] });
const logPath = getAuditLogPath();
expect(logPath).not.toBeNull();
expect(fs.existsSync(logPath!)).toBe(true);
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Audit-Log Start");
expect(content).toContain("Settings changed");
expect(content).toContain("changedKeys");
});
it("rotates oversized audit logs on startup", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-"));
tempDirs.push(baseDir);
const oversizedPath = path.join(baseDir, "audit.log");
fs.mkdirSync(baseDir, { recursive: true });
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
initAuditLog(baseDir);
expect(fs.existsSync(oversizedPath)).toBe(true);
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
const content = fs.readFileSync(oversizedPath, "utf8");
expect(content).toContain("Audit-Log Start");
});
});

View File

@ -6,9 +6,223 @@ import {
ensureRepackToken, ensureRepackToken,
buildAutoRenameBaseName, buildAutoRenameBaseName,
buildAutoRenameBaseNameFromFolders, buildAutoRenameBaseNameFromFolders,
buildAutoRenameBaseNameFromFoldersWithOptions buildAutoRenameBaseNameFromFoldersWithOptions,
hasMeaningfulSeriesPrefix,
looksLikeObfuscatedSceneFileName,
decideAutoRenameBaseName,
isBonusContent
} from "../src/main/download-manager"; } from "../src/main/download-manager";
describe("decideAutoRenameBaseName (shared naming decision — used by auto-rename AND mkv-collect)", () => {
it("derives the clean name for a Herzflimmern episode from the per-episode folder (S07E12 — the reported failure)", () => {
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
const folders = [
"Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV",
"Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"
];
const decision = decideAutoRenameBaseName(
folders,
source,
"tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720",
folders[0],
folders[1]
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe("Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV");
});
it("derives the clean name from a SEASON-only folder by injecting the source episode token (Herzflimmern S03E14)", () => {
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s03e14-720.mkv";
const seasonFolder = "Herzflimmern.die.Klinik.am.See.S03.German.720p.Webrip.x264-TVARCHiV";
const decision = decideAutoRenameBaseName(
[seasonFolder],
source,
"tvarchiv.herzflimmern.die.klinik.am.see.s03e14-720",
seasonFolder,
seasonFolder
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe("Herzflimmern.die.Klinik.am.See.S03E14.German.720p.Webrip.x264-TVARCHiV");
});
it("derives the clean name for the Fritzie S04 files that sat raw in Downloader Unfertig (4sf- scene group, season folder)", () => {
const source = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
const seasonFolder = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
const decision = decideAutoRenameBaseName(
[seasonFolder],
source,
"4sf-fritzie.himmel.muss.warten.web.7p-s04e01",
seasonFolder,
seasonFolder
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe("Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF");
});
it("is idempotent: an already-clean file in its clean folder derives to the same name (no worse-than-now)", () => {
const clean = "Herzflimmern.Die.Klinik.am.See.S07E02.German.720p.Webrip.x264-TVARCHiV";
const decision = decideAutoRenameBaseName(
[clean, "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"],
`${clean}.mkv`,
clean,
clean,
"Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe(clean);
});
it("GUARD: lets the parent folder token override an OBFUSCATED source filename (anti-piracy scramble)", () => {
const decision = decideAutoRenameBaseName(
["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"],
"awa-diethundermans02e16hd.mkv",
"awa-diethundermans02e16hd",
"Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake",
"Die.Thundermans.S02.GERMAN.x264-aWake"
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toContain("S02E01");
});
it("GUARD: a CLEAN scene source is NEVER overridden by a mismatching folder token (folder is wrong, not the file)", () => {
const decision = decideAutoRenameBaseName(
["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"],
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv",
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f",
"The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON",
"The.Royals.2015.S01.German.DL.720p.BluRay.x264-iNTENTiON"
);
expect(decision.kind).toBe("skip");
expect(decision.kind === "skip" && decision.reason).toBe("token-mismatch");
});
it("skips (no-target) when no folder candidate yields a usable scene name", () => {
const decision = decideAutoRenameBaseName(
["random user folder", "another plain dir"],
"some.file.mkv",
"some.file",
"random user folder",
"another plain dir"
);
expect(decision.kind).toBe("skip");
});
it("uses the CLEAN per-episode folder (scene group WITH underscore, e.g. -idTV_iNT) — not the obfuscated package folder", () => {
const epFolder = "Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT";
const decision = decideAutoRenameBaseName(
[epFolder, "scn2-cstl7"],
"castle.s08e02.german.dl.720p.web.h264-idtv_int.mkv",
"castle.s08e02.german.dl.720p.web.h264-idtv_int",
epFolder,
"scn2-cstl7"
);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe(epFolder);
});
it("uses the complete per-episode folder when the SOURCE has no SxxExx token (bare 'Folge 01' format)", () => {
const folders = [
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET",
"kig.hdtv.7p-001",
"Kreuzfahrt ins Glück S01"
];
const decision = decideAutoRenameBaseName(folders, "bet_kig_01_hdt.mkv", "bet_kig_01_hdt", folders[0], folders[2]);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe(folders[0]);
});
it("complete-folder fallback fires on CODEC alone (no resolution token — DVDRip/XviD class)", () => {
const folders = [
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.DVDRip.x264-BET",
"Kreuzfahrt ins Glück S01"
];
const decision = decideAutoRenameBaseName(folders, "bet_kig_01.mkv", "bet_kig_01", folders[0], folders[1]);
expect(decision.kind).toBe("rename");
expect(decision.kind === "rename" && decision.baseName).toBe(folders[0]);
});
it("complete-folder fallback does NOT fire when the source HAS an episode token (generic pack stays no-target)", () => {
const decision = decideAutoRenameBaseName(
["Mega-Direct-Pack"],
"Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv",
"Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT",
"Mega-Direct-Pack",
"Mega-Direct-Pack"
);
expect(decision.kind).toBe("skip");
});
});
describe("hasMeaningfulSeriesPrefix", () => {
it("recognizes a real series name before the season token", () => {
expect(hasMeaningfulSeriesPrefix("Desperate.Housewives.S01.Synced.DL.720p.WEB-DL.AC3.h264")).toBe(true);
expect(hasMeaningfulSeriesPrefix("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake")).toBe(true);
expect(hasMeaningfulSeriesPrefix("Mistresses.2013.S02.GERMAN.DL.720p.WEB.x264-TSCC")).toBe(true);
expect(hasMeaningfulSeriesPrefix("show.name.s01e01.720p")).toBe(true);
});
it("rejects generic season-label folders without a series name", () => {
expect(hasMeaningfulSeriesPrefix("S01 Complete")).toBe(false);
expect(hasMeaningfulSeriesPrefix("S02")).toBe(false);
expect(hasMeaningfulSeriesPrefix("S01E01 Complete")).toBe(false);
expect(hasMeaningfulSeriesPrefix(".S01.bla")).toBe(false);
});
it("returns false when there is no season token at all", () => {
expect(hasMeaningfulSeriesPrefix("Some Random Folder")).toBe(false);
expect(hasMeaningfulSeriesPrefix("")).toBe(false);
});
});
describe("looksLikeObfuscatedSceneFileName", () => {
it("flags hoster-obfuscated names with no scene markers as obfuscated", () => {
expect(looksLikeObfuscatedSceneFileName("awa-diethundermans02e16hd.mkv")).toBe(true);
expect(looksLikeObfuscatedSceneFileName("scn-dthund7-S02E06.mkv")).toBe(true);
expect(looksLikeObfuscatedSceneFileName("4sj-blue-bloods-s08e21-720p.mkv")).toBe(true);
});
it("treats clean scene releases with multiple markers as NOT obfuscated", () => {
expect(looksLikeObfuscatedSceneFileName("the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv")).toBe(false);
expect(looksLikeObfuscatedSceneFileName("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake.mkv")).toBe(false);
expect(looksLikeObfuscatedSceneFileName("Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264.mkv")).toBe(false);
});
it("handles edge cases (empty, very short)", () => {
expect(looksLikeObfuscatedSceneFileName("")).toBe(true);
expect(looksLikeObfuscatedSceneFileName("a.mkv")).toBe(true);
});
it("treats long dotted names as scene-style even with few markers", () => {
expect(looksLikeObfuscatedSceneFileName("Some.Show.With.Many.Tokens.S01E01.mkv")).toBe(false);
});
});
describe("extractEpisodeToken (extended formats)", () => {
it("recognizes the older xX format (capped at 2 episode digits)", () => {
expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01");
expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05");
expect(extractEpisodeToken("Show.Name.10x99.mkv")).toBe("S10E99");
expect(extractEpisodeToken("Show.Name.10x100.mkv")).toBeNull();
expect(extractEpisodeToken("Show.Name.S10E100.mkv")).toBe("S10E100");
});
it("does not falsely match resolution tokens like 1080x720", () => {
expect(extractEpisodeToken("show.1080p.mkv")).toBeNull();
expect(extractEpisodeToken("show.S01E01.1080p.mkv")).toBe("S01E01");
});
it("does not falsely match codec tokens like x264 / x265 (caps episode digits)", () => {
expect(extractEpisodeToken("Movie.x264-GROUP.mkv")).toBeNull();
expect(extractEpisodeToken("Movie.5x265.x265.mkv")).toBeNull();
expect(extractEpisodeToken("Show.S01E01.x265-GROUP.mkv")).toBe("S01E01");
});
it("does not falsely match common aspect ratios like 1920x1080", () => {
expect(extractEpisodeToken("Movie.1920x1080.mkv")).toBeNull();
});
});
describe("extractEpisodeToken", () => { describe("extractEpisodeToken", () => {
it("extracts S01E01 from standard scene format", () => { it("extracts S01E01 from standard scene format", () => {
expect(extractEpisodeToken("show.name.s01e01.720p")).toBe("S01E01"); expect(extractEpisodeToken("show.name.s01e01.720p")).toBe("S01E01");
@ -78,6 +292,10 @@ describe("extractEpisodeToken", () => {
it("extracts double episode with single-digit numbers", () => { it("extracts double episode with single-digit numbers", () => {
expect(extractEpisodeToken("show-s1e1e2-720p")).toBe("S01E01E02"); expect(extractEpisodeToken("show-s1e1e2-720p")).toBe("S01E01E02");
}); });
it("extracts episode when title and season token are joined", () => {
expect(extractEpisodeToken("mdgp-carters02e01-720p")).toBe("S02E01");
});
}); });
describe("applyEpisodeTokenToFolderName", () => { describe("applyEpisodeTokenToFolderName", () => {
@ -249,7 +467,6 @@ describe("buildAutoRenameBaseName", () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
// Edge cases
it("handles 2160p quality token", () => { it("handles 2160p quality token", () => {
const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv"); const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv");
expect(result).toBe("Show.S01E01.REPACK.2160p-4sf"); expect(result).toBe("Show.S01E01.REPACK.2160p-4sf");
@ -267,12 +484,10 @@ describe("buildAutoRenameBaseName", () => {
it("handles high season and episode numbers", () => { it("handles high season and episode numbers", () => {
const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv"); const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv");
// SCENE_EPISODE_RE allows up to 3-digit episodes and 2-digit seasons
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!).toContain("S99E999"); expect(result!).toContain("S99E999");
}); });
// Real-world scene release patterns
it("real-world: German series with dots", () => { it("real-world: German series with dots", () => {
const result = buildAutoRenameBaseName( const result = buildAutoRenameBaseName(
"Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ", "Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ",
@ -337,18 +552,13 @@ describe("buildAutoRenameBaseName", () => {
expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF"); expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF");
}); });
// Bug-hunting edge cases
it("source filename extension is not included in episode detection", () => { it("source filename extension is not included in episode detection", () => {
// The sourceFileName passed to buildAutoRenameBaseName is the basename without extension
// so .mkv should not interfere, but let's verify with an actual extension
const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv"); const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv");
// "mkv" should not be treated as part of the filename match
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!).toContain("S01E01"); expect(result!).toContain("S01E01");
}); });
it("does not match episode-like patterns in codec strings", () => { it("does not match episode-like patterns in codec strings", () => {
// h.265 has digits but should not be confused with episode tokens
const token = extractEpisodeToken("show.s01e01.h.265"); const token = extractEpisodeToken("show.s01e01.h.265");
expect(token).toBe("S01E01"); expect(token).toBe("S01E01");
}); });
@ -366,23 +576,19 @@ describe("buildAutoRenameBaseName", () => {
"Show.S01E05.720p-4sf", "Show.S01E05.720p-4sf",
"show.s01e05.720p" "show.s01e05.720p"
); );
// Must NOT produce "Show.S01E05.720p.S01E05-4sf" (double episode bug)
expect(result).toBe("Show.S01E05.720p-4sf"); expect(result).toBe("Show.S01E05.720p-4sf");
}); });
it("handles folder with only -4sf suffix (edge case)", () => { it("handles folder with only -4sf suffix (edge case)", () => {
const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv"); const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv");
// Extreme edge case - sanitizeFilename trims leading dots
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!).toContain("S01E01"); expect(result!).toContain("S01E01");
expect(result!).toContain("-4sf"); expect(result!).toContain("-4sf");
expect(result!).not.toContain(".S01E01.S01E01"); // no duplication expect(result!).not.toContain(".S01E01.S01E01");
}); });
it("sanitizes special characters from result", () => { it("sanitizes special characters from result", () => {
// sanitizeFilename should strip dangerous chars
const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv"); const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv");
// The colon should be sanitized away
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!).not.toContain(":"); expect(result!).not.toContain(":");
}); });
@ -547,6 +753,28 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
expect(result).toBe("Lethal.Weapon.S02E11.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS"); expect(result).toBe("Lethal.Weapon.S02E11.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS");
}); });
it("maps compact code 319a to episode 19 in season 3 folder", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
[
"Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03.GERMAN.AC3.720p.HDTV.x264-hrs"
],
"hrs-bpol.hdtv.7p-319a",
{ forceEpisodeForSeasonFolder: true }
);
expect(result).toBe("Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03E19.GERMAN.AC3.720p.HDTV.x264-hrs");
});
it("maps compact code 319b to next episode in season 3 folder", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
[
"Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03.GERMAN.AC3.720p.HDTV.x264-hrs"
],
"hrs-bpol.hdtv.7p-319b",
{ forceEpisodeForSeasonFolder: true }
);
expect(result).toBe("Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03E20.GERMAN.AC3.720p.HDTV.x264-hrs");
});
it("maps episode-only token e01 via season folder hint and keeps REPACK", () => { it("maps episode-only token e01 via season folder hint and keeps REPACK", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions( const result = buildAutoRenameBaseNameFromFoldersWithOptions(
[ [
@ -624,7 +852,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD"); expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD");
}); });
// Last-resort fallback: folder has season but no scene group suffix (user-renamed packages)
it("renames when folder has season but no scene group suffix (Mystery Road case)", () => { it("renames when folder has season but no scene group suffix (Mystery Road case)", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions( const result = buildAutoRenameBaseNameFromFoldersWithOptions(
["Mystery Road S02"], ["Mystery Road S02"],
@ -652,7 +879,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
"myst.road.de.dl.hdtv.7p-s02e05", "myst.road.de.dl.hdtv.7p-s02e05",
{ forceEpisodeForSeasonFolder: true } { forceEpisodeForSeasonFolder: true }
); );
// Should use the scene-group folder (hrs), not the custom one
expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs"); expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs");
}); });
@ -691,4 +917,173 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
); );
expect(result).toBe("Room.104.S04E01.GERMAN.DL.720p.WEBRiP.x264-LAW"); expect(result).toBe("Room.104.S04E01.GERMAN.DL.720p.WEBRiP.x264-LAW");
}); });
it("renames Carter when source joins title and season token", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
["Carter.S02.GERMAN.DL.720p.HDTV.x264-MDGP"],
"mdgp-carters02e01-720p",
{ forceEpisodeForSeasonFolder: true }
);
expect(result).toBe("Carter.S02E01.GERMAN.DL.720p.HDTV.x264-MDGP");
});
it("renames abbreviated source bupr.de.dl.web.7p-s01e03 via season folder", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
["Burning.Promise.S01.GERMAN.DL.720p.WEB.H264-WvF"],
"bupr.de.dl.web.7p-s01e03",
{ forceEpisodeForSeasonFolder: true }
);
expect(result).toBe("Burning.Promise.S01E03.GERMAN.DL.720p.WEB.H264-WvF");
});
it("renames abbreviated 4SF source amilllt.de.dl.web.7p-s03e10 via season folder", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
["A.Million.Little.Things.S03.GERMAN.DL.720p.WEB.H264-4SF"],
"4sf-amilllt.de.dl.web.7p-s03e10",
{ forceEpisodeForSeasonFolder: true }
);
expect(result).toBe("A.Million.Little.Things.S03E10.GERMAN.DL.720p.WEB.H264-4SF");
});
it("renames abbreviated source jkl.web.7p-s01e13 via season folder", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
["9JKL.S01.GERMAN.720p.WEB.x264-WvF"],
"jkl.web.7p-s01e13",
{ forceEpisodeForSeasonFolder: true }
);
expect(result).toBe("9JKL.S01E13.GERMAN.720p.WEB.x264-WvF");
});
it("renames abbreviated source jkl.web.7p-s01e14 via season folder", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
["9JKL.S01.GERMAN.720p.WEB.x264-WvF"],
"jkl.web.7p-s01e14",
{ forceEpisodeForSeasonFolder: true }
);
expect(result).toBe("9JKL.S01E14.GERMAN.720p.WEB.x264-WvF");
});
it("documents malformed package name (S01GERMAN) limitation", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
[
"3MH.web.7p-101",
"Drei.Meter.ueber.dem.Himmel.S01GERMAN.DL.720P.WEB.X264-WAYNE"
],
"Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE",
{ forceEpisodeForSeasonFolder: true }
);
if (result !== null) {
expect(typeof result).toBe("string");
}
});
});
describe("isBonusContent (numbered episodes are never bonus)", () => {
const pkgDir = "/pkg/Show.S04.GERMAN.DL.720p.WEB.x264-GRP";
it("does NOT treat a numbered episode as bonus even when its TITLE is a bonus word", () => {
const name = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
const fp = `${pkgDir}/${name}/${name}.mkv`;
expect(isBonusContent(fp, pkgDir, name)).toBe(false);
});
it("covers further bonus-word episode titles with a token", () => {
for (const title of ["Special", "Featurette", "Outtakes", "Bloopers", "Making.Of"]) {
const name = `Show.S04E07.${title}.GERMAN.720p.WEB.x264-GRP`;
expect(isBonusContent(`${pkgDir}/${name}.mkv`, pkgDir, name)).toBe(false);
}
});
it("STILL treats genuine extras WITHOUT an episode token as bonus", () => {
for (const name of [
"Show.Making.Of.GERMAN.720p.WEB.x264-GRP",
"Show.Behind.The.Scenes.GERMAN-GRP",
"Some.Interview.With.Cast"
]) {
expect(isBonusContent(`${pkgDir}/${name}.mkv`, pkgDir, name)).toBe(true);
}
});
it("a token-bearing file inside an Extras subfolder is still kept (numbered episode wins)", () => {
const name = "Show.S04E19.Interview.GROUP";
const fp = `${pkgDir}/Extras/${name}/${name}.mkv`;
expect(isBonusContent(fp, pkgDir, name)).toBe(false);
});
it("a token-less file inside an Extras subfolder is bonus", () => {
const fp = `${pkgDir}/Extras/Making.Of.mkv`;
expect(isBonusContent(fp, pkgDir, "Making.Of")).toBe(true);
});
});
describe("complete episode folder WITHOUT group suffix (codec/resolution only)", () => {
const hash = "c284d9d9072eaf3ac314d05f951dd115";
it("uses the clean folder name when it has an episode token + codec but no -GROUP (safari S04E08a)", () => {
const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
expect(decision).toEqual({ kind: "rename", baseName: folder });
});
it("keeps multi-part letters a/b distinct (Teil.1 vs Teil.2 do NOT collide)", () => {
const fa = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
const fb = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD";
const da = decideAutoRenameBaseName([fa, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
const db = decideAutoRenameBaseName([fb, hash], "safari-fm-s04e08b.avi", "safari-fm-s04e08b", hash, hash);
expect(da).toEqual({ kind: "rename", baseName: fa });
expect(db).toEqual({ kind: "rename", baseName: fb });
expect((da as any).baseName).not.toBe((db as any).baseName);
});
it("the previously-working group-suffix folder still works (no regression)", () => {
const folder = "Fluss-Monster.S01E02.Auf.der.Suche.nach.dem.Killer-Wels.German.DOKU.SATRiP.XviD-SAFARi";
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s01e02.avi", "safari-fm-s01e02", hash, hash);
expect(decision).toEqual({ kind: "rename", baseName: folder });
});
it("does NOT use a bare episode folder WITHOUT any codec/resolution marker (stays conservative)", () => {
const decision = decideAutoRenameBaseName(["Show.S01E01", hash], "abc-s01e01.avi", "abc-s01e01", hash, hash);
expect(decision.kind).toBe("skip");
});
it("does NOT fabricate a name from a token-LESS folder (Mega-Direct guard intact)", () => {
const decision = decideAutoRenameBaseName(["Mega-Direct-Pack", hash], "Direct.Show.S01E01.DIRECT.mkv", "Direct.Show.S01E01.DIRECT", hash, hash);
expect(decision.kind).toBe("skip");
});
});
describe("collect must not mangle an already-clean SxxExx name via an episode-title folder", () => {
const hash = "c284d9d9072eaf3ac314d05f951dd115";
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG";
const pkgFolder = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
const cleanSource = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG";
it("keeps the clean source (skip) instead of appending the token to the episode-title folder", () => {
const decision = decideAutoRenameBaseName([epFolder, pkgFolder], cleanSource + ".mkv", cleanSource, epFolder, pkgFolder);
expect(decision.kind).toBe("skip");
expect(JSON.stringify(decision)).not.toContain("GTVG.S01E01");
});
it("still cleans a JUNK/obfuscated source via an episode-title folder (append path intact, no skip)", () => {
const epFolder = "Show.E05.Die.Sache.German.720p.HDTV.x264-GRP";
const seasonFolder = "Show.S01.German.720p.HDTV.x264-GRP";
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], "scn-show7-S01E05.mkv", "scn-show7-S01E05", epFolder, seasonFolder);
expect(decision.kind).toBe("rename");
expect(extractEpisodeToken((decision as any).baseName)).toBe("S01E05");
});
it("does NOT affect a folder that already carries an SxxExx token (safari S04E08a stays a rename)", () => {
const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
expect(decision).toEqual({ kind: "rename", baseName: folder });
});
it("keeps a clean SHORT-prefix series source (ER) instead of the crippled token append", () => {
const epFolder = "ER.E01.Tag.und.Nacht.German.720p.HDTV.x264-GROUP";
const seasonFolder = "ER.S01.German.720p.HDTV.x264-GROUP";
const cleanSource = "ER.S01E01.German.720p.HDTV.x264-GROUP";
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], cleanSource + ".mkv", cleanSource, epFolder, seasonFolder);
expect(decision.kind).toBe("skip");
expect(JSON.stringify(decision)).not.toContain("GROUP.S01E01");
});
}); });

View File

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
describe("backup-crypto", () => {
it("encrypts and decrypts a round-trip correctly", () => {
const original = JSON.stringify({
version: 2,
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
session: { packages: {}, items: {} },
history: [{ id: "h1", name: "Test" }]
});
const encrypted = encryptBackup(original);
const decrypted = decryptBackup(encrypted);
expect(decrypted).toBe(original);
});
it("produces binary output that is not plaintext readable", () => {
const secret = "super-secret-token-12345";
const plaintext = JSON.stringify({ settings: { token: secret } });
const encrypted = encryptBackup(plaintext);
expect(encrypted.toString("utf8")).not.toContain(secret);
expect(encrypted.toString("latin1")).not.toContain(secret);
});
it("starts with the MDD1 magic bytes", () => {
const encrypted = encryptBackup("test");
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
});
it("produces different ciphertext for the same input (random IV)", () => {
const plaintext = "same input data";
const a = encryptBackup(plaintext);
const b = encryptBackup(plaintext);
expect(a.equals(b)).toBe(false);
expect(decryptBackup(a)).toBe(plaintext);
expect(decryptBackup(b)).toBe(plaintext);
});
it("throws on truncated data", () => {
const encrypted = encryptBackup("test data");
const truncated = encrypted.subarray(0, 10);
expect(() => decryptBackup(truncated)).toThrow();
});
it("throws on corrupted ciphertext", () => {
const encrypted = encryptBackup("test data");
const corrupted = Buffer.from(encrypted);
corrupted[corrupted.length - 1] ^= 0xff;
expect(() => decryptBackup(corrupted)).toThrow();
});
it("throws on wrong magic bytes", () => {
const encrypted = encryptBackup("test data");
const wrongMagic = Buffer.from(encrypted);
wrongMagic[0] = 0x00;
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
});
it("throws on empty buffer", () => {
expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
});
it("handles large payloads", () => {
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
const encrypted = encryptBackup(large);
const decrypted = decryptBackup(encrypted);
expect(decrypted).toBe(large);
});
it("handles unicode content", () => {
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
const encrypted = encryptBackup(unicode);
expect(decryptBackup(encrypted)).toBe(unicode);
});
it("handles empty string round-trip", () => {
const encrypted = encryptBackup("");
expect(decryptBackup(encrypted)).toBe("");
});
});

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
}
const session: SessionState = {
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
reconnectReason: "", paused: false, running: true, updatedAt: 0
};
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
describe("buildBackupPayload — default is settings-only", () => {
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
expect(p.history).toBeUndefined();
expect(p.settings).toBeDefined();
});
it("includes session + history when backupIncludeDownloads is true", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
expect(p.kind).toBe("full");
expect(p.session).toBe(session);
expect(p.history).toBe(history);
});
it("treats a missing flag as settings-only (safe default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
});
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
// "Haken aus bleibt aus": the exported settings object preserves the flag,
// so importing it keeps the toggle off.
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
});
});
describe("planBackupImport — decision follows the file, not the local toggle", () => {
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
expect(plan.valid).toBe(true);
expect(plan.restoreDownloads).toBe(false);
expect(plan.message).toMatch(/Einstellungen/);
});
it("full backup (with session) -> restore downloads + relaunch", () => {
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
expect(plan.valid).toBe(true);
expect(plan.restoreDownloads).toBe(true);
});
it("rejects payloads without settings", () => {
expect(planBackupImport({ session }).valid).toBe(false);
expect(planBackupImport(null).valid).toBe(false);
expect(planBackupImport("nope").valid).toBe(false);
expect(planBackupImport({}).valid).toBe(false);
});
it("a settings-only export then import does NOT pull in the download list", () => {
// Build with toggle off, then plan the import of exactly that payload.
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
});
it("a full export then import DOES restore the download list", () => {
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
expect(plan.restoreDownloads).toBe(true);
});
});

View File

@ -0,0 +1,167 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockCookiesSet,
mockFetch,
mockClearStorageData,
mockClearCache,
mockFromPartition,
mockSession
} = vi.hoisted(() => {
const cookiesSet = vi.fn();
const fetch = vi.fn();
const clearStorageData = vi.fn();
const clearCache = vi.fn();
const fromPartition = vi.fn();
return {
mockCookiesSet: cookiesSet,
mockFetch: fetch,
mockClearStorageData: clearStorageData,
mockClearCache: clearCache,
mockFromPartition: fromPartition,
mockSession: {
cookies: {
set: cookiesSet
},
fetch,
clearStorageData,
clearCache
}
};
});
vi.mock("electron", () => ({
session: {
fromPartition: mockFromPartition
}
}));
vi.mock("../src/main/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}));
import { BestDebridWebFallback } from "../src/main/bestdebrid-web";
function createCookieFile(contents: string): string {
const filePath = path.join(os.tmpdir(), `bestdebrid-cookies-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
fs.writeFileSync(filePath, contents, "utf8");
return filePath;
}
describe("bestdebrid-web", () => {
const tempFiles: string[] = [];
beforeEach(() => {
mockFromPartition.mockReturnValue(mockSession);
});
afterEach(() => {
vi.clearAllMocks();
mockFromPartition.mockReturnValue(mockSession);
while (tempFiles.length > 0) {
const filePath = tempFiles.pop();
if (!filePath) {
continue;
}
try {
fs.rmSync(filePath, { force: true });
} catch {
}
}
});
it("imports HttpOnly Netscape cookies instead of skipping them as comments", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
"#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session",
".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking"
].join("\n"));
tempFiles.push(filePath);
const fallback = new BestDebridWebFallback(() => true);
const count = await fallback.importCookiesFromFile(filePath);
expect(count).toBe(2);
expect(mockClearStorageData).toHaveBeenCalledTimes(1);
expect(mockClearStorageData).toHaveBeenCalledWith({
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
});
expect(mockCookiesSet).toHaveBeenCalledTimes(2);
expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({
name: "PHPSESSID",
domain: ".bestdebrid.com",
httpOnly: true,
secure: true
}));
});
it("deduplicates conflicting session cookies and prefers the HttpOnly variant", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585384\tPHPSESSID\tnon-http-only",
"#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\thttp-only"
].join("\n"));
tempFiles.push(filePath);
const fallback = new BestDebridWebFallback(() => true);
const count = await fallback.importCookiesFromFile(filePath);
expect(count).toBe(1);
expect(mockCookiesSet).toHaveBeenCalledTimes(1);
expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({
name: "PHPSESSID",
value: "http-only",
httpOnly: true,
domain: ".bestdebrid.com"
}));
});
it("rejects cookie files that only contain tracking cookies", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
".bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\t__stripe_mid\tstripe",
".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking"
].join("\n"));
tempFiles.push(filePath);
const fallback = new BestDebridWebFallback(() => true);
await expect(fallback.importCookiesFromFile(filePath))
.rejects.toThrow("Login-Cookie");
expect(mockCookiesSet).not.toHaveBeenCalled();
});
it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
].join("\n"));
tempFiles.push(filePath);
mockFetch
.mockResolvedValueOnce(new Response(JSON.stringify({
error: 1,
message: "Free users are not allowed to download using a VPN or proxy. Please purchase a premium plan."
}), { status: 200 }))
.mockResolvedValueOnce(new Response("<div class=\"font-medium\">Guest</div>", { status: 200 }));
const fallback = new BestDebridWebFallback(() => true);
await fallback.importCookiesFromFile(filePath);
await expect(fallback.unrestrict("https://1fichier.com/?abc"))
.rejects.toThrow("Nicht eingeloggt");
await expect(fallback.unrestrict("https://1fichier.com/?abc"))
.rejects.toThrow("Keine Cookies importiert");
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://bestdebrid.com/api/v1/generateLink");
expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/");
});
});

View File

@ -42,7 +42,6 @@ describe("cleanup", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir); tempDirs.push(dir);
// Create nested directory structure with archive files
const sub1 = path.join(dir, "season1"); const sub1 = path.join(dir, "season1");
const sub2 = path.join(dir, "season1", "extras"); const sub2 = path.join(dir, "season1", "extras");
fs.mkdirSync(sub2, { recursive: true }); fs.mkdirSync(sub2, { recursive: true });
@ -51,17 +50,15 @@ describe("cleanup", () => {
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x"); fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x"); fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x"); fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
// Non-archive files should be kept
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content"); fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content"); fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
const removed = cleanupCancelledPackageArtifacts(dir); const removed = cleanupCancelledPackageArtifacts(dir);
expect(removed).toBe(4); // 2 rar parts + zip + 7z expect(removed).toBe(4);
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false); expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false); expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false); expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false); expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
// Non-archives kept
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true); expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true); expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
}); });
@ -70,23 +67,17 @@ describe("cleanup", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir); tempDirs.push(dir);
// File with link-like name containing URLs should be removed
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n"); fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
// File with link-like name but no URLs should be kept
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs"); fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
// Regular text file that doesn't match the link pattern should be kept
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com"); fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
// .url files should always be removed
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com"); fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
// .dlc files should always be removed
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data"); fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
const removed = await removeDownloadLinkArtifacts(dir); const removed = await removeDownloadLinkArtifacts(dir);
expect(removed).toBeGreaterThanOrEqual(3); // download_links.txt + bookmark.url + container.dlc expect(removed).toBeGreaterThanOrEqual(3);
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false); expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false); expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false); expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
// Non-matching files should be kept
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true); expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
}); });

View File

@ -22,9 +22,7 @@ describe("container", () => {
const oversizedFilePath = path.join(dir, "oversized.dlc"); const oversizedFilePath = path.join(dir, "oversized.dlc");
fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1)); fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
// Create a valid mockup DLC that would be skipped if an error was thrown
const validFilePath = path.join(dir, "valid.dlc"); const validFilePath = path.join(dir, "valid.dlc");
// Just needs to be short enough to pass file limits but fail parsing, triggering dcrypt fallback
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content...")); fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
const fetchSpy = vi.fn(async (url: string | URL | Request) => { const fetchSpy = vi.fn(async (url: string | URL | Request) => {
@ -38,7 +36,6 @@ describe("container", () => {
const result = await importDlcContainers([oversizedFilePath, validFilePath]); const result = await importDlcContainers([oversizedFilePath, validFilePath]);
// Expect the oversized to be silently skipped, and valid to be parsed into 1 package with DLC filename
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].name).toBe("valid"); expect(result[0].name).toBe("valid");
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]); expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
@ -60,17 +57,14 @@ describe("container", () => {
tempDirs.push(dir); tempDirs.push(dir);
const filePath = path.join(dir, "fallback.dlc"); const filePath = path.join(dir, "fallback.dlc");
// A file large enough to trigger local decryption attempt (needs > 89 bytes to pass the slice check)
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64")); fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => { const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url); const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) { if (urlStr.includes("service.jdownloader.org")) {
// Mock local RC service failure (returning 404)
return new Response("", { status: 404 }); return new Response("", { status: 404 });
} }
if (urlStr.includes("dcrypt.it/decrypt/upload")) { if (urlStr.includes("dcrypt.it/decrypt/upload")) {
// Mock dcrypt fallback success
return new Response("http://fallback.com/1", { status: 200 }); return new Response("http://fallback.com/1", { status: 200 });
} }
return new Response("", { status: 404 }); return new Response("", { status: 404 });
@ -81,7 +75,6 @@ describe("container", () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].name).toBe("fallback"); expect(result[0].name).toBe("fallback");
expect(result[0].links).toEqual(["http://fallback.com/1"]); expect(result[0].links).toEqual(["http://fallback.com/1"]);
// Should have tried both!
expect(fetchSpy).toHaveBeenCalledTimes(2); expect(fetchSpy).toHaveBeenCalledTimes(2);
}); });
@ -135,7 +128,6 @@ describe("container", () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].name).toBe("big-dlc"); expect(result[0].name).toBe("big-dlc");
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]); expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
// local RC + upload + paste = 3 calls
expect(fetchSpy).toHaveBeenCalledTimes(3); expect(fetchSpy).toHaveBeenCalledTimes(3);
}); });

File diff suppressed because it is too large Load Diff

538
tests/debug-server.test.ts Normal file
View File

@ -0,0 +1,538 @@
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 AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/main/windows-host-diagnostics", () => ({
getWindowsHostDiagnostics: () => ({
collectedAt: "2026-03-09T00:00:03.000Z",
supported: true,
platform: "win32",
crashControl: {
crashDumpEnabled: 3,
minidumpDir: "C:\\Windows\\Minidumps",
dumpFile: "C:\\Windows\\MEMORY.DMP",
overwrite: 1,
logEvent: 1,
autoReboot: 1
},
recentKernelPower: [
{
timeCreated: "2026-03-09T00:00:04.000Z",
id: 41,
providerName: "Microsoft-Windows-Kernel-Power",
levelDisplayName: "Critical",
message: "unexpected restart",
bugcheckCode: "0",
bugcheckCodeHex: "",
reportId: ""
}
],
recentWerKernel: [],
recentKernelDump: [],
recentAppCrashes: [],
recentMinidumps: [],
assessmentHints: ["watchdog hint"],
errors: []
})
}));
import { defaultSettings } from "../src/main/constants";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
import { startDebugServer, stopDebugServer } from "../src/main/debug-server";
import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
import { configureLogger, getLogFilePath, logger } from "../src/main/logger";
import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log";
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage";
import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
import type { DownloadManager } from "../src/main/download-manager";
import type { UiSnapshot } from "../src/shared/types";
const tempDirs: string[] = [];
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 response = await fetch(url);
if (response.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`debug server not ready: ${url}`);
}
function buildSnapshot(baseDir: string): UiSnapshot {
const settings = {
...defaultSettings(),
outputDir: path.join(baseDir, "downloads"),
extractDir: path.join(baseDir, "extract")
};
return {
settings,
session: {
version: 1,
packageOrder: ["pkg-1"],
packages: {
"pkg-1": {
id: "pkg-1",
name: "server-package",
outputDir: path.join(baseDir, "downloads", "server-package"),
extractDir: path.join(baseDir, "extract", "server-package"),
status: "downloading",
itemIds: ["item-1", "item-2"],
cancelled: false,
enabled: true,
priority: "normal",
postProcessLabel: "",
createdAt: Date.now() - 30_000,
updatedAt: Date.now()
}
},
items: {
"item-1": {
id: "item-1",
packageId: "pkg-1",
url: "https://hoster.example/file-1",
provider: "realdebrid",
providerLabel: "Real-Debrid",
status: "downloading",
retries: 1,
speedBps: 8 * 1024 * 1024,
downloadedBytes: 64 * 1024 * 1024,
totalBytes: 256 * 1024 * 1024,
progressPercent: 25,
fileName: "episode.part1.rar",
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part1.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft (Real-Debrid)",
createdAt: Date.now() - 30_000,
updatedAt: Date.now()
},
"item-2": {
id: "item-2",
packageId: "pkg-1",
url: "https://hoster.example/file-2",
provider: "realdebrid",
providerLabel: "Real-Debrid",
status: "failed",
retries: 3,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "episode.part2.rar",
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part2.rar"),
resumable: false,
attempts: 3,
lastError: "hoster unavailable",
fullStatus: "Fehler: hoster unavailable",
createdAt: Date.now() - 30_000,
updatedAt: Date.now()
}
},
runStartedAt: Date.now() - 30_000,
totalDownloadedBytes: 64 * 1024 * 1024,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: Date.now()
},
summary: null,
stats: {
totalDownloaded: 64 * 1024 * 1024,
totalDownloadedAllTime: 128 * 1024 * 1024,
totalFilesSession: 0,
totalFilesAllTime: 0,
totalPackages: 1,
sessionStartedAt: Date.now() - 30_000,
appSessionStartedAt: Date.now() - 60_000,
sessionRuntimeMs: 60_000,
totalRuntimeMs: 3 * 60_000,
runtimeMeasuredAt: Date.now()
},
speedText: "8.0 MB/s",
etaText: "ETA: 00:25",
canStart: false,
canStop: true,
canPause: true,
clipboardActive: false,
reconnectSeconds: 0,
packageSpeedBps: {
"pkg-1": 8 * 1024 * 1024
}
};
}
async function createFixture() {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debug-"));
tempDirs.push(baseDir);
const token = "debug-secret";
const port = await getFreePort();
const snapshot = buildSnapshot(baseDir);
const storagePaths = createStoragePaths(baseDir);
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"), "0.0.0.0", "utf8");
const debridLinkApiKeys = "key-a\nkey-b";
const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys);
saveSettings(storagePaths, {
...snapshot.settings,
token: "rd-secret-token",
realDebridUseWebLogin: true,
debridLinkApiKeys,
debridLinkDisabledKeyIds: debridLinkKeyIds[1] ? [debridLinkKeyIds[1]] : [],
totalDownloadedAllTime: 128 * 1024 * 1024,
totalCompletedFilesAllTime: 12,
totalRuntimeAllTimeMs: 5 * 60_000
});
saveHistory(storagePaths, [
{
id: "hist-1",
name: "server-package",
totalBytes: 123,
downloadedBytes: 123,
fileCount: 2,
provider: "realdebrid",
completedAt: Date.now() - 5_000,
durationSeconds: 42,
status: "completed",
outputDir: path.join(baseDir, "downloads", "server-package"),
urls: ["https://hoster.example/file-1"]
}
]);
configureLogger(baseDir);
fs.writeFileSync(getLogFilePath(), "2026-03-09T00:00:00.000Z [INFO] MAIN-LINE\n", "utf8");
initAuditLog(baseDir);
const auditLogPath = getAuditLogPath();
if (!auditLogPath) {
throw new Error("audit log path missing");
}
logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" });
initRenameLog(baseDir);
logRenameEvent("INFO", "RENAME-LINE", { stage: "auto-rename", sourcePath: "C:\\extract\\old.mkv" });
initTraceLog(baseDir);
setTraceEnabled(true, "test-fixture");
logTraceEvent("INFO", "support", "TRACE-EVENT", { scope: "fixture" });
initSessionLog(baseDir);
const sessionLogPath = getSessionLogPath();
if (!sessionLogPath) {
throw new Error("session log path missing");
}
fs.appendFileSync(sessionLogPath, "2026-03-09T00:00:01.000Z [INFO] SESSION-LINE\n", "utf8");
logger.info("TRACE-MAIN-LINE");
initPackageLogs(baseDir);
initItemLogs(baseDir);
const packageLogPath = ensurePackageLog({
packageId: "pkg-1",
name: "server-package",
outputDir: snapshot.session.packages["pkg-1"]!.outputDir,
extractDir: snapshot.session.packages["pkg-1"]!.extractDir
});
if (!packageLogPath) {
throw new Error("package log path missing");
}
fs.appendFileSync(packageLogPath, "2026-03-09T00:00:02.000Z [INFO] PACKAGE-LINE\n", "utf8");
const itemLogPath = ensureItemLog({
itemId: "item-2",
packageId: "pkg-1",
packageName: "server-package",
fileName: "episode.part2.rar",
targetPath: snapshot.session.items["item-2"]!.targetPath
});
if (!itemLogPath) {
throw new Error("item log path missing");
}
fs.appendFileSync(itemLogPath, "2026-03-09T00:00:03.000Z [ERROR] ITEM-LINE\n", "utf8");
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: (packageId: string) => packageId === "pkg-1" ? packageLogPath : null,
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
} as unknown as DownloadManager;
startDebugServer(manager, baseDir);
const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health?token=${token}`);
await new Promise((resolve) => setTimeout(resolve, 300));
return {
baseUrl,
token,
baseDir
};
}
afterEach(() => {
stopDebugServer();
shutdownSessionLog();
shutdownPackageLogs();
shutdownItemLogs();
shutdownRenameLog();
shutdownTraceLog();
shutdownAuditLog();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("debug-server", () => {
it("serves diagnostics with main, session, and package log tails", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/diagnostics?token=${fixture.token}&package=server-package&lines=20`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.meta?.appVersion).toBeTruthy();
expect(payload.meta?.debugServer?.host).toBe("0.0.0.0");
expect(payload.status?.running).toBe(true);
expect(payload.host?.platform).toBe("win32");
expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41);
expect(payload.selectedPackage?.name).toBe("server-package");
expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE");
expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-LINE");
expect((payload.logs?.rename?.lines || []).join("\n")).toContain("RENAME-LINE");
expect((payload.logs?.trace?.lines || []).join("\n")).toContain("TRACE-EVENT");
expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE");
expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE");
expect(payload.accounts?.realDebrid?.configured).toBe(true);
expect(payload.history?.total).toBe(1);
});
it("writes a machine-readable AI support manifest into the runtime folder", async () => {
const fixture = await createFixture();
const manifestPath = path.join(fixture.baseDir, "debug_ai_manifest.json");
expect(fs.existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>;
expect(manifest.appVersion).toBeTruthy();
expect(manifest.debugServer?.port).toBeGreaterThan(0);
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>");
expect(manifest.quickstart?.[1]).toContain("server IP");
expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
expect(manifest.selfCheckEndpoint).toBe("/self-check");
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`);
expect(metaResponse.ok).toBe(true);
const metaPayload = await metaResponse.json() as Record<string, any>;
expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath);
expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath());
expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath());
expect(metaPayload.logPaths?.rename).toBe(getRenameLogPath());
expect(metaPayload.supportChecks?.setup).toBe("/debug/setup");
expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check");
});
it("serves a debug setup check with trace expiry details", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/debug/setup?token=${fixture.token}`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.enabled).toBe(true);
expect(payload.status).toBe("ok");
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
expect(payload.host).toBe("0.0.0.0");
expect(payload.localOnly).toBe(false);
expect(payload.tokenConfigured).toBe(true);
expect(payload.aiManifestPresent).toBe(true);
expect(payload.traceEnabled).toBe(true);
expect(payload.traceAutoDisableAt).toBeTruthy();
expect(payload.diskSpace?.runtime?.freeBytes).toBeGreaterThan(0);
expect(payload.diskSpace?.output?.freeBytes).toBeGreaterThan(0);
expect(payload.diskSpace?.extract?.freeBytes).toBeGreaterThan(0);
expect(payload.logSummary?.totalBytes).toBeGreaterThan(0);
expect(payload.logSummary?.rename?.bytes).toBeGreaterThan(0);
expect(payload.logSummary?.packageLogs?.fileCount).toBe(1);
expect(payload.logSummary?.itemLogs?.fileCount).toBe(1);
expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0);
expect(payload.remoteUrlTemplates?.health).toContain("<SERVER_IP_OR_DNS>");
expect(Array.isArray(payload.notes)).toBe(true);
});
it("serves the self-check alias", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.status).toBe("ok");
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
});
it("writes the client IP into the debug trace log", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, {
headers: {
"X-Forwarded-For": "159.195.63.46"
}
});
expect(response.ok).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 200));
const traceLogPath = getTraceLogPath();
expect(traceLogPath).toBeTruthy();
const traceText = fs.readFileSync(traceLogPath!, "utf8");
expect(traceText).toContain("clientIp=159.195.63.46");
});
it("serves package details and package log by package query", async () => {
const fixture = await createFixture();
const packagesResponse = await fetch(`${fixture.baseUrl}/packages?token=${fixture.token}&package=server&includeItems=1`);
expect(packagesResponse.ok).toBe(true);
const packagesPayload = await packagesResponse.json() as Record<string, any>;
expect(packagesPayload.count).toBe(1);
expect(packagesPayload.packages?.[0]?.items?.length).toBe(2);
const logResponse = await fetch(`${fixture.baseUrl}/logs/package?token=${fixture.token}&package=server-package&lines=20`);
expect(logResponse.ok).toBe(true);
const logPayload = await logResponse.json() as Record<string, any>;
expect(logPayload.package?.name).toBe("server-package");
expect((logPayload.lines || []).join("\n")).toContain("PACKAGE-LINE");
});
it("serves item log by item query", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/logs/item?token=${fixture.token}&item=episode.part2.rar&lines=20`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.item?.id).toBe("item-2");
expect(payload.item?.fileName).toBe("episode.part2.rar");
expect((payload.lines || []).join("\n")).toContain("ITEM-LINE");
});
it("serves host diagnostics separately", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/host/diagnostics?token=${fixture.token}`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.platform).toBe("win32");
expect(payload.crashControl?.crashDumpEnabled).toBe(3);
expect(payload.assessmentHints?.[0]).toContain("watchdog");
});
it("serves audit log, settings, accounts, stats, and history", async () => {
const fixture = await createFixture();
const auditResponse = await fetch(`${fixture.baseUrl}/logs/audit?token=${fixture.token}&lines=20`);
expect(auditResponse.ok).toBe(true);
const auditPayload = await auditResponse.json() as Record<string, any>;
expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE");
const renameResponse = await fetch(`${fixture.baseUrl}/logs/rename?token=${fixture.token}&lines=20`);
expect(renameResponse.ok).toBe(true);
const renamePayload = await renameResponse.json() as Record<string, any>;
expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE");
const traceResponse = await fetch(`${fixture.baseUrl}/logs/trace?token=${fixture.token}&lines=50`);
expect(traceResponse.ok).toBe(true);
const tracePayload = await traceResponse.json() as Record<string, any>;
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT");
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE");
const traceConfigResponse = await fetch(`${fixture.baseUrl}/trace/config?token=${fixture.token}&enable=0&note=test`);
expect(traceConfigResponse.ok).toBe(true);
const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>;
expect(traceConfigPayload.config?.enabled).toBe(false);
const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`);
expect(settingsResponse.ok).toBe(true);
const settingsPayload = await settingsResponse.json() as Record<string, any>;
expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true);
expect(settingsPayload.extraction?.archivePasswordCount).toBe(0);
expect(JSON.stringify(settingsPayload)).not.toContain("rd-secret-token");
expect(JSON.stringify(settingsPayload)).not.toContain("key-a");
expect(JSON.stringify(settingsPayload)).not.toContain("key-b");
const accountsResponse = await fetch(`${fixture.baseUrl}/accounts?token=${fixture.token}`);
expect(accountsResponse.ok).toBe(true);
const accountsPayload = await accountsResponse.json() as Record<string, any>;
expect(accountsPayload.debridLink?.keyCount).toBe(2);
expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1);
const statsResponse = await fetch(`${fixture.baseUrl}/stats?token=${fixture.token}`);
expect(statsResponse.ok).toBe(true);
const statsPayload = await statsResponse.json() as Record<string, any>;
expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0);
expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0);
const historyResponse = await fetch(`${fixture.baseUrl}/history?token=${fixture.token}&limit=10`);
expect(historyResponse.ok).toBe(true);
const historyPayload = await historyResponse.json() as Record<string, any>;
expect(historyPayload.total).toBe(1);
expect(historyPayload.entries?.[0]?.name).toBe("server-package");
expect(historyPayload.entries?.[0]?.urlCount).toBe(1);
});
it("downloads a support bundle zip", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`);
expect(response.ok).toBe(true);
expect(response.headers.get("content-type")).toContain("application/zip");
const buffer = Buffer.from(await response.arrayBuffer());
const zip = new AdmZip(buffer);
const entries = zip.getEntries().map((entry) => entry.entryName);
expect(entries).toContain("overview/settings.json");
expect(entries).toContain("overview/accounts.json");
expect(entries).toContain("overview/debug-setup.json");
expect(entries).toContain("overview/self-check.json");
expect(entries).toContain("overview/trace-config.json");
expect(entries).toContain("logs/audit.log");
expect(entries).toContain("logs/rename.log");
expect(entries).toContain("logs/trace.log");
expect(entries).toContain("runtime/debug_ai_manifest.json");
expect(entries).not.toContain("runtime/debug_token.txt");
});
it("rejects unauthenticated requests", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/status`);
expect(response.status).toBe(401);
});
});

View File

@ -0,0 +1,124 @@
import { afterEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
getDesktopRenameLogPath,
initDesktopRenameLog,
logDesktopRename,
shutdownDesktopRenameLog,
verifyRename
} from "../src/main/desktop-rename-log";
const createdTmpDirs: string[] = [];
function tmpDesktop(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-log-"));
createdTmpDirs.push(dir);
return dir;
}
afterEach(() => {
shutdownDesktopRenameLog();
for (const dir of createdTmpDirs) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
createdTmpDirs.length = 0;
});
describe("desktop-rename-log", () => {
it("creates the Downloader-Log folder + session file on init and appends formatted lines", () => {
const desktop = tmpDesktop();
initDesktopRenameLog(desktop);
const logPath = getDesktopRenameLogPath();
expect(logPath).toBeTruthy();
expect(path.dirname(logPath as string).endsWith("Downloader-Log")).toBe(true);
expect(fs.existsSync(logPath as string)).toBe(true);
logDesktopRename("INFO", "Test-Rename", { source: "a.mkv", requested: "b.mkv" });
const content = fs.readFileSync(logPath as string, "utf8");
expect(content).toContain("Rename-Session gestartet");
expect(content).toContain("Test-Rename");
expect(content).toContain("source=a.mkv");
expect(content).toContain("requested=b.mkv");
expect(content).toMatch(/\[INFO\]/);
});
it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => {
const desktop = tmpDesktop();
initDesktopRenameLog(desktop);
const logPath = getDesktopRenameLogPath() as string;
logDesktopRename("INFO", "ZeileA");
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
expect(fs.existsSync(logPath)).toBe(false);
logDesktopRename("INFO", "ZeileB");
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
expect(fs.existsSync(logPath)).toBe(true);
const content = fs.readFileSync(logPath, "utf8");
expect(content).toContain("Rename-Session gestartet");
expect(content).toContain("ZeileB");
});
it("is a silent no-op when initialized without a desktop path (never throws)", () => {
initDesktopRenameLog("");
expect(getDesktopRenameLogPath()).toBeNull();
expect(() => logDesktopRename("INFO", "egal")).not.toThrow();
});
it("verifyRename: ok when the target exists under the exact name and the source is gone", () => {
const dir = tmpDesktop();
const source = path.join(dir, "scn-xyz.part1.rar");
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
fs.writeFileSync(target, "data");
const v = verifyRename(source, target);
expect(v.ok).toBe(true);
expect(v.level).toBe("INFO");
expect(v.targetExists).toBe(true);
expect(v.onDiskName).toBe("Movie.2024.German.1080p.part1.rar");
expect(v.nameMatches).toBe(true);
expect(v.sourceGone).toBe(true);
expect(v.targetSize).toBe(4);
});
it("verifyRename: FAILS when the target is missing although rename reported success", () => {
const dir = tmpDesktop();
const v = verifyRename(path.join(dir, "src.rar"), path.join(dir, "never-created.rar"));
expect(v.ok).toBe(false);
expect(v.level).toBe("ERROR");
expect(v.targetExists).toBe(false);
expect(v.reason).toMatch(/nicht gefunden/i);
});
it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => {
const dir = tmpDesktop();
const source = path.join(dir, "src.rar");
const target = path.join(dir, "dst.rar");
fs.writeFileSync(source, "x");
fs.writeFileSync(target, "x");
const v = verifyRename(source, target);
expect(v.ok).toBe(false);
expect(v.level).toBe("ERROR");
expect(v.sourceGone).toBe(false);
expect(v.reason).toMatch(/Quelldatei existiert noch/i);
});
it("verifyRename: an in-place rename (same path) is ok and does not flag a lingering source", () => {
const dir = tmpDesktop();
const p = path.join(dir, "file.mkv");
fs.writeFileSync(p, "x");
const v = verifyRename(p, p);
expect(v.ok).toBe(true);
expect(v.targetExists).toBe(true);
expect(v.nameMatches).toBe(true);
});
});

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
describe("download-completion", () => {
describe("planDownloadCompletion", () => {
it("uses content-length when present", () => {
const plan = planDownloadCompletion({
existingBytes: 0, responseStatus: 200, contentLength: 1000,
totalFromRange: null, knownTotal: null, correctedTotal: null
});
expect(plan.source).toBe("content-length");
expect(plan.expectedTotal).toBe(1000);
});
it("falls back to stream-end when no size info is available", () => {
const plan = planDownloadCompletion({
existingBytes: 0, responseStatus: 200, contentLength: 0,
totalFromRange: null, knownTotal: null, correctedTotal: null
});
expect(plan.source).toBe("stream-end");
expect(plan.expectedTotal).toBeNull();
});
});
describe("validateDownloadedFileCompletion", () => {
const streamEnd = { expectedTotal: null, source: "stream-end" as const, canFinishEarly: false };
const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true });
const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false });
it("rejects a 0-byte stream-end download (H3)", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
expect(result.ok).toBe(false);
expect(result.error).toContain("download_underflow");
});
it("accepts a non-empty stream-end download", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 5_000_000, plan: streamEnd });
expect(result.ok).toBe(true);
expect(result.totalBytes).toBe(5_000_000);
});
it("rejects an underflowing content-length download", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 400, plan: contentLength(1000), toleranceBytes: 0 });
expect(result.ok).toBe(false);
});
it("accepts a complete content-length download", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 1000, plan: contentLength(1000) });
expect(result.ok).toBe(true);
});
it("rejects a 0-byte download even with known provider size", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: providerMeta(2000) });
expect(result.ok).toBe(false);
});
it("accepts provider-metadata download and flags size mismatch", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
expect(result.ok).toBe(false);
});
});
});

File diff suppressed because it is too large Load Diff

44
tests/error-ring.test.ts Normal file
View File

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { createErrorRing } from "../src/main/error-ring";
describe("createErrorRing", () => {
it("keeps entries in insertion order", () => {
const ring = createErrorRing(10);
ring.push({ ts: "t1", level: "ERROR", message: "a" });
ring.push({ ts: "t2", level: "WARN", message: "b" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["a", "b"]);
expect(ring.size()).toBe(2);
});
it("caps at capacity by dropping the oldest", () => {
const ring = createErrorRing(3);
for (const m of ["a", "b", "c", "d", "e"]) {
ring.push({ ts: m, level: "ERROR", message: m });
}
expect(ring.snapshot().map((e) => e.message)).toEqual(["c", "d", "e"]);
expect(ring.size()).toBe(3);
});
it("snapshot returns a copy, not the live buffer", () => {
const ring = createErrorRing(5);
ring.push({ ts: "t", level: "WARN", message: "x" });
const snap = ring.snapshot();
snap.push({ ts: "t2", level: "ERROR", message: "injected" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["x"]);
});
it("clear empties the ring", () => {
const ring = createErrorRing(5);
ring.push({ ts: "t", level: "ERROR", message: "x" });
ring.clear();
expect(ring.snapshot()).toEqual([]);
expect(ring.size()).toBe(0);
});
it("coerces a non-positive capacity to at least 1", () => {
const ring = createErrorRing(0);
ring.push({ ts: "t1", level: "ERROR", message: "a" });
ring.push({ ts: "t2", level: "ERROR", message: "b" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["b"]);
});
});

View File

@ -74,7 +74,6 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
const targetDir = path.join(root, "out"); const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(packageDir, { recursive: true });
// Create a ZIP with some content to trigger progress
const zipPath = path.join(packageDir, "progress-test.zip"); const zipPath = path.join(packageDir, "progress-test.zip");
const zip = new AdmZip(); const zip = new AdmZip();
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100))); zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
@ -108,20 +107,16 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(result.extracted).toBe(1); expect(result.extracted).toBe(1);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
// Should have at least preparing, extracting, and done phases
const phases = new Set(progressUpdates.map((u) => u.phase)); const phases = new Set(progressUpdates.map((u) => u.phase));
expect(phases.has("preparing")).toBe(true); expect(phases.has("preparing")).toBe(true);
expect(phases.has("extracting")).toBe(true); expect(phases.has("extracting")).toBe(true);
// Extracting phase should include the archive name
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip"); const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
expect(extracting.length).toBeGreaterThan(0); expect(extracting.length).toBeGreaterThan(0);
// Should end at 100%
const lastExtracting = extracting[extracting.length - 1]; const lastExtracting = extracting[extracting.length - 1];
expect(lastExtracting.archivePercent).toBe(100); expect(lastExtracting.archivePercent).toBe(100);
// Files should exist
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
}); });
@ -135,7 +130,6 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
const targetDir = path.join(root, "out"); const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(packageDir, { recursive: true });
// Create two separate ZIP archives
const zip1 = new AdmZip(); const zip1 = new AdmZip();
zip1.addFile("episode01.txt", Buffer.from("ep1 content")); zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
zip1.writeZip(path.join(packageDir, "archive1.zip")); zip1.writeZip(path.join(packageDir, "archive1.zip"));
@ -162,10 +156,8 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(result.extracted).toBe(2); expect(result.extracted).toBe(2);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
// Both archive names should have appeared in progress
expect(archiveNames.has("archive1.zip")).toBe(true); expect(archiveNames.has("archive1.zip")).toBe(true);
expect(archiveNames.has("archive2.zip")).toBe(true); expect(archiveNames.has("archive2.zip")).toBe(true);
// Both files extracted
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
}); });

View File

@ -5,12 +5,19 @@ import AdmZip from "adm-zip";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { import {
buildExternalExtractArgs, buildExternalExtractArgs,
cleanErrorText,
collectArchiveCleanupTargets, collectArchiveCleanupTargets,
extractPackageArchives, extractPackageArchives,
type ExtractArchiveFailureInfo,
archiveFilenamePasswords, archiveFilenamePasswords,
detectArchiveSignature, detectArchiveSignature,
classifyExtractionError, classifyExtractionError,
shouldSerialRetryParallelFailures,
findArchiveCandidates, findArchiveCandidates,
orderExtractorCandidatesForArchive,
resolveExtractorBackendModeForArchive,
resolveExtractorBackendMode,
shouldFallbackLegacyRarToJvm,
} from "../src/main/extractor"; } from "../src/main/extractor";
const tempDirs: string[] = []; const tempDirs: string[] = [];
@ -65,6 +72,11 @@ describe("extractor", () => {
expect(unrarRename[2]).toBe("-p-"); expect(unrarRename[2]).toBe("-p-");
expect(unrarRename[3]).toBe("-y"); expect(unrarRename[3]).toBe("-y");
expect(unrarRename[unrarRename.length - 2]).toBe("archive.rar"); expect(unrarRename[unrarRename.length - 2]).toBe("archive.rar");
const rarCliArgs = buildExternalExtractArgs("Rar.exe", "archive.rar", "C:\\target", "overwrite", "serienjunkies.org");
expect(rarCliArgs.slice(0, 4)).toEqual(["x", "-o+", "-pserienjunkies.org", "-y"]);
expect(rarCliArgs[rarCliArgs.length - 2]).toBe("archive.rar");
expect(rarCliArgs[rarCliArgs.length - 1]).toBe("C:\\target\\");
}); });
it("deletes only successfully extracted archives", async () => { it("deletes only successfully extracted archives", async () => {
@ -853,7 +865,6 @@ describe("extractor", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-"));
tempDirs.push(root); tempDirs.push(root);
const filePath = path.join(root, "test.rar"); const filePath = path.join(root, "test.rar");
// RAR5 signature: 52 61 72 21 1A 07
fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex")); fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex"));
const sig = await detectArchiveSignature(filePath); const sig = await detectArchiveSignature(filePath);
expect(sig).toBe("rar"); expect(sig).toBe("rar");
@ -930,7 +941,6 @@ describe("extractor", () => {
const candidates = await findArchiveCandidates(packageDir); const candidates = await findArchiveCandidates(packageDir);
const names = candidates.map((c) => path.basename(c)); const names = candidates.map((c) => path.basename(c));
expect(names).toContain("movie.001"); expect(names).toContain("movie.001");
// .002 should NOT be in candidates (only .001 is the entry point)
expect(names).not.toContain("movie.002"); expect(names).not.toContain("movie.002");
}); });
@ -945,9 +955,41 @@ describe("extractor", () => {
const candidates = await findArchiveCandidates(packageDir); const candidates = await findArchiveCandidates(packageDir);
const names = candidates.map((c) => path.basename(c)); const names = candidates.map((c) => path.basename(c));
// .zip.001 should appear once from zipSplit detection, not duplicated by genericSplit
expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1); expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1);
}); });
it("ignores duplicate-suffixed multipart rar volumes as standalone candidates", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rar-dup-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part1.rar"), "data", "utf8");
fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part2.rar"), "data", "utf8");
fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part1 (1).rar"), "data", "utf8");
fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part2 (1).rar"), "data", "utf8");
fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part5 (1).rar"), "data", "utf8");
const candidates = await findArchiveCandidates(packageDir);
const names = candidates.map((c) => path.basename(c));
expect(names).toContain("Sanctuary720-01x07.part1.rar");
expect(names).not.toContain("Sanctuary720-01x07.part1 (1).rar");
expect(names).not.toContain("Sanctuary720-01x07.part2 (1).rar");
expect(names).not.toContain("Sanctuary720-01x07.part5 (1).rar");
});
it("keeps single rar files with duplicate suffix as valid candidates", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-single-rar-dup-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(path.join(packageDir, "Movie (1).rar"), "data", "utf8");
const candidates = await findArchiveCandidates(packageDir);
expect(candidates.map((c) => path.basename(c))).toContain("Movie (1).rar");
});
}); });
describe("classifyExtractionError", () => { describe("classifyExtractionError", () => {
@ -988,12 +1030,66 @@ describe("extractor", () => {
expect(classifyExtractionError("WinRAR/UnRAR nicht gefunden")).toBe("no_extractor"); expect(classifyExtractionError("WinRAR/UnRAR nicht gefunden")).toBe("no_extractor");
}); });
it("prioritizes checksum errors over embedded wrong-password wording", () => {
expect(classifyExtractionError("Checksum error in the encrypted file. Corrupt file or wrong password.")).toBe("crc_error");
});
it("returns unknown for unrecognized errors", () => { it("returns unknown for unrecognized errors", () => {
expect(classifyExtractionError("something weird happened")).toBe("unknown"); expect(classifyExtractionError("something weird happened")).toBe("unknown");
}); });
it("keeps important tail markers when long extractor output is trimmed", () => {
const noisy = `Extracting from archive.rar ${"x".repeat(700)} Unexpected end of archive`;
const cleaned = cleanErrorText(noisy);
expect(cleaned).toContain("Unexpected end of archive");
expect(classifyExtractionError(cleaned)).toBe("missing_parts");
});
});
describe("shouldSerialRetryParallelFailures", () => {
it("keeps serial recovery enabled after mixed parallel results", () => {
expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(true);
expect(shouldSerialRetryParallelFailures(2, ["missing_parts"])).toBe(true);
});
it("only retries a total parallel wipe-out for contention-like failures", () => {
expect(shouldSerialRetryParallelFailures(0, ["crc_error", "wrong_password", "unknown"])).toBe(true);
expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false);
expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false);
});
}); });
describe("password discovery", () => { describe("password discovery", () => {
it("reports per-archive failures through onArchiveFailure", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-"));
tempDirs.push(root);
const packageDir = path.join(root, "pkg");
const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip", "utf8");
const failures: ExtractArchiveFailureInfo[] = [];
const result = await extractPackageArchives({
packageDir,
targetDir,
cleanupMode: "none",
conflictMode: "overwrite",
removeLinks: false,
removeSamples: false,
onArchiveFailure: (failure) => {
failures.push(failure);
}
});
expect(result.extracted).toBe(0);
expect(result.failed).toBe(1);
expect(failures).toHaveLength(1);
expect(failures[0]?.archiveName).toBe("broken.zip");
expect(failures[0]?.category).toBe("unsupported_format");
expect(failures[0]?.suggestRedownload).toBe(false);
});
it("extracts first archive serially before parallel pool when multiple passwords", async () => { it("extracts first archive serially before parallel pool when multiple passwords", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-"));
tempDirs.push(root); tempDirs.push(root);
@ -1001,7 +1097,6 @@ describe("extractor", () => {
const targetDir = path.join(root, "out"); const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(packageDir, { recursive: true });
// Create 3 zip archives
for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) { for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) {
const zip = new AdmZip(); const zip = new AdmZip();
zip.addFile(`${name}.txt`, Buffer.from(name)); zip.addFile(`${name}.txt`, Buffer.from(name));
@ -1028,7 +1123,6 @@ describe("extractor", () => {
expect(result.extracted).toBe(3); expect(result.extracted).toBe(3);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
// First archive should be ep01 (natural order, extracted serially for discovery)
expect(seenOrder[0]).toBe("ep01.zip"); expect(seenOrder[0]).toBe("ep01.zip");
}); });
@ -1045,7 +1139,6 @@ describe("extractor", () => {
zip.writeZip(path.join(packageDir, name)); zip.writeZip(path.join(packageDir, name));
} }
// No passwordList → only empty string → length=1 → no discovery phase
const result = await extractPackageArchives({ const result = await extractPackageArchives({
packageDir, packageDir,
targetDir, targetDir,
@ -1086,4 +1179,79 @@ describe("extractor", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
}); });
}); });
describe("backend selection", () => {
it("defaults to auto in production when no backend override is set", () => {
expect(resolveExtractorBackendMode(undefined, false)).toBe("auto");
});
it("defaults to legacy in vitest when no backend override is set", () => {
expect(resolveExtractorBackendMode(undefined, true)).toBe("legacy");
});
it("respects explicit backend overrides", () => {
expect(resolveExtractorBackendMode("legacy", false)).toBe("legacy");
expect(resolveExtractorBackendMode("jvm", false)).toBe("jvm");
expect(resolveExtractorBackendMode("auto", false)).toBe("auto");
});
it("prefers legacy for rar archives in auto mode on Windows", () => {
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", undefined, false, "win32")).toBe("legacy");
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.r00", undefined, false, "win32")).toBe("legacy");
});
it("falls back from legacy rar to jvm after partial-progress failure in auto mode on Windows", () => {
expect(
shouldFallbackLegacyRarToJvm(
"C:\\Downloads\\episode.part01.rar",
"auto",
"legacy",
"Error: Extracting from C:\\Downloads\\episode.part01.rar",
38,
"win32"
)
).toBe(true);
});
it("skips legacy rar to jvm fallback for explicit legacy mode and non-rar cases", () => {
expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "legacy", "legacy", "checksum error", 38, "win32")).toBe(false);
expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.zip", "auto", "legacy", "unknown failure", 38, "win32")).toBe(false);
expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "auto", "legacy", "timeout", 38, "win32")).toBe(false);
});
it("keeps auto for non-rar archives and respects explicit overrides", () => {
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.zip", undefined, false, "win32")).toBe("auto");
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "jvm", false, "win32")).toBe("jvm");
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "legacy", false, "win32")).toBe("legacy");
});
});
describe("orderExtractorCandidatesForArchive", () => {
it("prefers RAR-native CLIs over 7-Zip for rar archives", () => {
const ordered = orderExtractorCandidatesForArchive(
["7z.exe", "Rar.exe", "UnRAR.exe", "WinRAR.exe"],
"C:\\Downloads\\archive.part01.rar"
);
expect(ordered.slice(0, 3)).toEqual(["Rar.exe", "UnRAR.exe", "WinRAR.exe"]);
expect(ordered[3]).toBe("7z.exe");
});
it("keeps 7-Zip first for non-rar archives", () => {
const ordered = orderExtractorCandidatesForArchive(
["UnRAR.exe", "7z.exe", "WinRAR.exe"],
"C:\\Downloads\\archive.zip"
);
expect(ordered[0]).toBe("7z.exe");
});
it("prefers the remembered command within the matching archive class", () => {
const ordered = orderExtractorCandidatesForArchive(
["UnRAR.exe", "WinRAR.exe", "7z.exe"],
"C:\\Downloads\\archive.part01.rar",
"WinRAR.exe"
);
expect(ordered[0]).toBe("WinRAR.exe");
expect(ordered[1]).toBe("UnRAR.exe");
});
});
}); });

49
tests/fs-error.test.ts Normal file
View File

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { classifyDiskError } from "../src/main/fs-error";
import { isDebugFlagEnabled } from "../src/main/logger";
describe("classifyDiskError", () => {
it("maps ENOSPC from an error code to a disk-full reason", () => {
const err = Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
expect(classifyDiskError(err)).toMatch(/Festplatte voll/);
});
it("maps EACCES from a code to a permission reason", () => {
const err = Object.assign(new Error("nope"), { code: "EACCES" });
expect(classifyDiskError(err)).toMatch(/Zugriff verweigert/);
});
it("lower-case codes are normalized", () => {
const err = Object.assign(new Error("x"), { code: "enospc" });
expect(classifyDiskError(err)).toMatch(/ENOSPC/);
});
it("falls back to scanning the message text when no code is present", () => {
expect(classifyDiskError(new Error("operation failed: ENOSPC on volume"))).toMatch(/Festplatte voll/);
});
it("handles a plain string error", () => {
expect(classifyDiskError("EROFS: read-only file system")).toMatch(/schreibgeschützt/);
});
it("returns null for an unrelated error", () => {
expect(classifyDiskError(new Error("write_drain_timeout"))).toBeNull();
expect(classifyDiskError(new Error("premature close"))).toBeNull();
expect(classifyDiskError(null)).toBeNull();
expect(classifyDiskError(undefined)).toBeNull();
});
});
describe("isDebugFlagEnabled", () => {
it("is true for affirmative values", () => {
for (const v of ["1", "true", "TRUE", "yes", "on", " on "]) {
expect(isDebugFlagEnabled(v)).toBe(true);
}
});
it("is false for empty/negative/garbage values", () => {
for (const v of [undefined, "", "0", "false", "off", "no", "maybe"]) {
expect(isDebugFlagEnabled(v)).toBe(false);
}
});
});

View File

@ -0,0 +1,150 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock only processVideoFile (the ffmpeg boundary); keep the real pure helpers
// (stripDualLangMarker / hasDualLangMarker / isRemuxableVideoFile) so the
// download-manager's selection + .DL.-rename wiring is exercised for real.
vi.mock("../src/main/video-processor", async (importActual) => {
const actual = await importActual<typeof import("../src/main/video-processor")>();
return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() };
});
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor";
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
const tempDirs: string[] = [];
afterEach(() => {
mockedProcess.mockReset();
mockedTooling.mockReset();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: DownloadManager; pkg: any } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ga-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
const stateDir = path.join(root, "state");
fs.mkdirSync(extractDir, { recursive: true });
fs.mkdirSync(stateDir, { recursive: true });
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
keepGermanAudioOnly,
germanAudioMode: "tag",
autoRename4sf4sj: false,
outputDir: path.join(root, "out"),
extractDir,
mkvLibraryDir: path.join(stateDir, "_mkv")
},
emptySession(),
createStoragePaths(stateDir)
);
const pkg: any = {
id: "ga-pkg-1",
name: "Test.Show.S01.GERMAN.DL.720p",
outputDir: path.join(root, "out", "Test.Show"),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 0,
updatedAt: 0
};
// Default: ffmpeg/ffprobe "available" so the step proceeds to the (mocked)
// processVideoFile. Tests that need the no-tool path override this.
mockedTooling.mockResolvedValue({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
return { extractDir, manager, pkg };
}
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv";
const SAMPLE_DL = "Show.sample.DL.mkv";
const DL_AVI = "Show.S01E03.German.DL.avi";
function stage(extractDir: string): void {
for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) {
fs.writeFileSync(path.join(extractDir, f), "x");
}
}
describe("keepGermanAudioOnly integration", () => {
it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(mockedProcess).toHaveBeenCalledTimes(1);
expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV));
expect(n).toBe(1);
const files = fs.readdirSync(extractDir);
expect(files).toContain("Show.S01E01.German.720p.x264.mkv"); // .DL. stripped
expect(files).not.toContain(DL_MKV);
expect(files).toContain(PLAIN_MKV); // non-.DL. untouched
expect(files).toContain(SAMPLE_DL); // sample skipped
expect(files).toContain(DL_AVI); // avi not remuxable, skipped
});
it("does nothing when the setting is off", async () => {
const { extractDir, manager, pkg } = setup(false);
stage(extractDir);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled();
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
});
it("leaves the file fully untouched (name included) when no German track is found", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult);
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(mockedProcess).toHaveBeenCalledTimes(1);
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed
});
it("still strips .DL. for a single-audio file (no remux needed)", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0); // not counted as a remux
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv");
});
it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
});
});

View File

@ -34,25 +34,20 @@ describe("integrity", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
tempDirs.push(dir); tempDirs.push(dir);
// Create a .md5 manifest that exceeds the 5MB limit
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000); const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
const manifestPath = path.join(dir, "hashes.md5"); const manifestPath = path.join(dir, "hashes.md5");
fs.writeFileSync(manifestPath, largeContent, "utf8"); fs.writeFileSync(manifestPath, largeContent, "utf8");
// Verify the file is actually > 5MB
const stat = fs.statSync(manifestPath); const stat = fs.statSync(manifestPath);
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024); expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
// readHashManifest should skip the oversized file
const manifest = readHashManifest(dir); const manifest = readHashManifest(dir);
expect(manifest.size).toBe(0); expect(manifest.size).toBe(0);
}); });
it("does not parse SHA256 (64-char hex) as valid hash", () => { it("does not parse SHA256 (64-char hex) as valid hash", () => {
// SHA256 is 64 chars - parseHashLine only supports 32 (MD5) and 40 (SHA1)
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin"; const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
const result = parseHashLine(sha256Line); const result = parseHashLine(sha256Line);
// 64-char hex should not match the MD5 (32) or SHA1 (40) pattern
expect(result).toBeNull(); expect(result).toBeNull();
}); });

85
tests/item-log.test.ts Normal file
View File

@ -0,0 +1,85 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ensureItemLog, getItemLogPath, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownItemLogs();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("item-log", () => {
it("creates a persistent item log file", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir);
initItemLogs(baseDir);
const logPath = ensureItemLog({
itemId: "item-1",
packageId: "pkg-1",
packageName: "Test Paket",
fileName: "episode.part2.rar",
targetPath: "C:\\downloads\\Test Paket\\episode.part2.rar"
});
expect(logPath).not.toBeNull();
expect(fs.existsSync(logPath!)).toBe(true);
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Item-Log Start");
expect(content).toContain("episode.part2.rar");
});
it("writes detail events into the item log", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir);
initItemLogs(baseDir);
ensureItemLog({
itemId: "item-2",
packageId: "pkg-2",
packageName: "Detail Paket",
fileName: "episode.part2.rar",
targetPath: "C:\\downloads\\Detail Paket\\episode.part2.rar"
});
logItemEvent("item-2", "ERROR", "Entpack-Fehler", {
archive: "episode.part2.rar",
code: "missing_parts",
detail: "Unexpected end of archive"
});
await new Promise((resolve) => setTimeout(resolve, 350));
const logPath = getItemLogPath("item-2");
expect(logPath).not.toBeNull();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Entpack-Fehler");
expect(content).toContain("archive=episode.part2.rar");
expect(content).toContain("code=missing_parts");
});
it("keeps traversal-like item ids inside the item log directory", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir);
initItemLogs(baseDir);
const logPath = ensureItemLog({
itemId: "..\\..\\outside",
packageId: "pkg-traversal",
packageName: "Traversal Paket",
fileName: "episode.part2.rar",
targetPath: "C:\\downloads\\Traversal Paket\\episode.part2.rar"
});
expect(logPath).not.toBeNull();
const logsDir = path.resolve(path.join(baseDir, "item-logs"));
const resolvedLogPath = path.resolve(logPath!);
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
});
});

153
tests/link-export.test.ts Normal file
View File

@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { buildLinkExportSelection, serializeLinkExportText } from "../src/main/link-export";
import { parseCollectorInput } from "../src/main/link-parser";
import type { UiSnapshot } from "../src/shared/types";
function buildSnapshot(): UiSnapshot {
return {
settings: {} as UiSnapshot["settings"],
session: {
version: 1,
packageOrder: ["pkg-1", "pkg-2"],
packages: {
"pkg-1": {
id: "pkg-1",
name: "Dave Staffel 1",
outputDir: "C:\\Downloads\\Dave Staffel 1",
extractDir: "C:\\Extract\\Dave Staffel 1",
status: "queued",
itemIds: ["item-1", "item-2"],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1,
updatedAt: 1
},
"pkg-2": {
id: "pkg-2",
name: "Andere Staffel",
outputDir: "C:\\Downloads\\Andere Staffel",
extractDir: "C:\\Extract\\Andere Staffel",
status: "queued",
itemIds: ["item-3"],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1,
updatedAt: 1
}
},
items: {
"item-1": {
id: "item-1",
packageId: "pkg-1",
url: "https://example.com/e01",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "Dave.S01E01.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
},
"item-2": {
id: "item-2",
packageId: "pkg-1",
url: "https://example.com/e02",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "Dave.S01E02.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
},
"item-3": {
id: "item-3",
packageId: "pkg-2",
url: "https://example.com/other",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "Andere.S01E01.rar",
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
}
},
runStartedAt: 0,
totalDownloadedBytes: 0,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: 1
},
summary: null,
stats: {
totalDownloaded: 0,
totalDownloadedAllTime: 0,
totalFilesSession: 0,
totalFilesAllTime: 0,
totalPackages: 2,
sessionStartedAt: 0,
appSessionStartedAt: 0,
sessionRuntimeMs: 0,
totalRuntimeMs: 0,
runtimeMeasuredAt: 0
},
speedText: "",
etaText: "",
canStart: true,
canStop: false,
canPause: false,
clipboardActive: false,
reconnectSeconds: 0,
packageSpeedBps: {}
};
}
describe("link-export", () => {
it("keeps original package names when exporting selected items", () => {
const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-3"]);
expect(selection.packageCount).toBe(2);
expect(selection.linkCount).toBe(2);
expect(selection.packages.map((pkg) => pkg.name)).toEqual(["Dave Staffel 1", "Andere Staffel"]);
});
it("roundtrips exported text back into parsed package inputs", () => {
const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-2"]);
const text = serializeLinkExportText(selection.packages);
const reparsed = parseCollectorInput(text, "");
expect(reparsed).toHaveLength(1);
expect(reparsed[0]?.name).toBe("Dave Staffel 1");
expect(reparsed[0]?.links).toEqual(["https://example.com/e01", "https://example.com/e02"]);
expect(reparsed[0]?.fileNames).toEqual(["Dave.S01E01.rar", "Dave.S01E02.rar"]);
});
});

View File

@ -8,15 +8,15 @@ describe("link-parser", () => {
{ name: "Package A", links: ["http://link1", "http://link2"] }, { name: "Package A", links: ["http://link1", "http://link2"] },
{ name: "Package B", links: ["http://link3"] }, { name: "Package B", links: ["http://link3"] },
{ name: "Package A", links: ["http://link4", "http://link1"] }, { name: "Package A", links: ["http://link4", "http://link1"] },
{ name: "", links: ["http://link5"] } // empty name will be inferred { name: "", links: ["http://link5"] }
]; ];
const result = mergePackageInputs(input); const result = mergePackageInputs(input);
expect(result).toHaveLength(3); // Package A, Package B, and inferred 'Paket' expect(result).toHaveLength(3);
const pkgA = result.find(p => p.name === "Package A"); const pkgA = result.find(p => p.name === "Package A");
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]); // link1 deduplicated expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]);
const pkgB = result.find(p => p.name === "Package B"); const pkgB = result.find(p => p.name === "Package B");
expect(pkgB?.links).toEqual(["http://link3"]); expect(pkgB?.links).toEqual(["http://link3"]);
@ -30,9 +30,20 @@ describe("link-parser", () => {
const result = mergePackageInputs(input); const result = mergePackageInputs(input);
// "Valid?Name*" becomes "Valid Name " -> trimmed to "Valid Name"
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]); expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
}); });
it("preserves file name hints when merging packages", () => {
const input = [
{ name: "Package A", links: ["http://link1", "http://link2"], fileNames: ["one.rar", "two.rar"] },
{ name: "Package A", links: ["http://link3", "http://link1"], fileNames: ["three.rar", "ignored.rar"] }
];
const result = mergePackageInputs(input);
expect(result).toHaveLength(1);
expect(result[0]?.links).toEqual(["http://link1", "http://link2", "http://link3"]);
expect(result[0]?.fileNames).toEqual(["one.rar", "two.rar", "three.rar"]);
});
}); });
describe("parseCollectorInput", () => { describe("parseCollectorInput", () => {
@ -55,7 +66,6 @@ describe("link-parser", () => {
const result = parseCollectorInput(rawText, "DefaultFallback"); const result = parseCollectorInput(rawText, "DefaultFallback");
// Should have 2 packages: "DefaultFallback" and "Custom_Name"
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
const defaultPkg = result.find(p => p.name === "DefaultFallback"); const defaultPkg = result.find(p => p.name === "DefaultFallback");
@ -64,7 +74,7 @@ describe("link-parser", () => {
"http://example.com/part2.rar" "http://example.com/part2.rar"
]); ]);
const customPkg = result.find(p => p.name === "Custom_Name"); // sanitized! const customPkg = result.find(p => p.name === "Custom_Name");
expect(customPkg?.links).toEqual([ expect(customPkg?.links).toEqual([
"http://other.com/file1", "http://other.com/file1",
"http://other.com/file2" "http://other.com/file2"

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { logTimestamp } from "../src/main/log-timestamp";
describe("logTimestamp", () => {
it("formats local time with an explicit UTC offset (ISO 8601), not a UTC 'Z' string", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
const formatted = logTimestamp(instant);
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
expect(formatted.endsWith("Z")).toBe(false);
});
it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime());
});
it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
const formatted = logTimestamp(instant);
expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0"));
});
});

View File

@ -0,0 +1,178 @@
import crypto from "node:crypto";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
decryptMegaAttributes,
isMegaFileUrl,
parseMegaUrl,
resolveMegaFilename
} from "../src/main/mega-public-api";
function base64Url(buf: Buffer): string {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function makeRandomFileKey(): Buffer {
return crypto.randomBytes(32);
}
function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string {
const plain = "MEGA" + JSON.stringify(jsonAttrs);
const padded = Buffer.from(plain, "utf8");
const padLen = (16 - (padded.length % 16)) % 16;
const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]);
const cipher = crypto.createCipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
cipher.setAutoPadding(false);
const enc = Buffer.concat([cipher.update(buf), cipher.final()]);
return base64Url(enc);
}
describe("mega-public-api", () => {
describe("isMegaFileUrl", () => {
it("recognizes new format", () => {
expect(isMegaFileUrl("https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo")).toBe(true);
});
it("recognizes legacy format", () => {
expect(isMegaFileUrl("https://mega.nz/#!abc123!def456")).toBe(true);
});
it("recognizes mega.co.nz", () => {
expect(isMegaFileUrl("https://mega.co.nz/file/abc#xyz")).toBe(true);
});
it("rejects folder URLs", () => {
expect(isMegaFileUrl("https://mega.nz/folder/abc#xyz")).toBe(false);
});
it("rejects non-mega URLs", () => {
expect(isMegaFileUrl("https://example.com/file/abc#xyz")).toBe(false);
});
it("rejects garbage", () => {
expect(isMegaFileUrl("")).toBe(false);
expect(isMegaFileUrl("foo")).toBe(false);
});
});
describe("parseMegaUrl", () => {
it("parses new-format URL into id + 32-byte key", () => {
const url = "https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo";
const parsed = parseMegaUrl(url);
expect(parsed).not.toBeNull();
expect(parsed?.id).toBe("pZl1wBRQ");
expect(parsed?.rawKey.length).toBe(32);
});
it("parses legacy-format URL", () => {
const id = "abcDEF12";
const key = makeRandomFileKey();
const url = `https://mega.nz/#!${id}!${base64Url(key)}`;
const parsed = parseMegaUrl(url);
expect(parsed?.id).toBe(id);
expect(parsed?.rawKey.equals(key)).toBe(true);
});
it("rejects URL with folder key (16 bytes)", () => {
const url = `https://mega.nz/file/abc#${base64Url(crypto.randomBytes(16))}`;
expect(parseMegaUrl(url)).toBeNull();
});
it("rejects malformed URLs", () => {
expect(parseMegaUrl("not-a-url")).toBeNull();
expect(parseMegaUrl("https://mega.nz/file/abc")).toBeNull();
});
});
describe("decryptMegaAttributes", () => {
it("round-trips encrypted Mega attributes", () => {
const aesKey = crypto.randomBytes(16);
const original = { n: "Test.S01E01.German.1080p.WEB.x264-DEMO.mkv", c: "ignored" };
const enc = encryptAttributes(original, aesKey);
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
const decrypted = decryptMegaAttributes(decoded, aesKey);
expect(decrypted).not.toBeNull();
expect(decrypted?.n).toBe(original.n);
});
it("returns null for wrong key", () => {
const aesKey = crypto.randomBytes(16);
const wrongKey = crypto.randomBytes(16);
const enc = encryptAttributes({ n: "x" }, aesKey);
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
expect(decryptMegaAttributes(decoded, wrongKey)).toBeNull();
});
it("returns null for non-multiple-of-16 input", () => {
const aesKey = crypto.randomBytes(16);
expect(decryptMegaAttributes(Buffer.alloc(15), aesKey)).toBeNull();
});
it("returns null for wrong key length", () => {
expect(decryptMegaAttributes(Buffer.alloc(16), Buffer.alloc(8))).toBeNull();
});
});
describe("resolveMegaFilename (mocked fetch)", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("returns filename + size for a valid Mega response", async () => {
const fileKey = makeRandomFileKey();
const aesKey = fileKey.subarray(0, 16);
const url = `https://mega.nz/file/testId12#${base64Url(fileKey)}`;
const encrypted = encryptAttributes(
{ n: "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv" },
aesKey
);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
async json() {
return [{ s: 1234567890, at: encrypted, msd: 1 }];
}
} as unknown as Response);
const result = await resolveMegaFilename(url);
expect(result).not.toBeNull();
expect(result?.name).toBe("Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv");
expect(result?.size).toBe(1234567890);
});
it("returns null when Mega returns numeric error", async () => {
const fileKey = makeRandomFileKey();
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
async json() {
return -9;
}
} as unknown as Response);
expect(await resolveMegaFilename(url)).toBeNull();
});
it("returns null when response is array with error code", async () => {
const fileKey = makeRandomFileKey();
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
async json() {
return [-16];
}
} as unknown as Response);
expect(await resolveMegaFilename(url)).toBeNull();
});
it("returns null when fetch throws", async () => {
const fileKey = makeRandomFileKey();
const url = `https://mega.nz/file/networkFail#${base64Url(fileKey)}`;
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
expect(await resolveMegaFilename(url)).toBeNull();
});
it("returns null for non-mega URL without making any fetch call", async () => {
const fetchSpy = vi.fn();
global.fetch = fetchSpy as unknown as typeof fetch;
expect(await resolveMegaFilename("https://example.com/file/abc#xyz")).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
});

View File

@ -33,7 +33,6 @@ describe("mega-web-fallback", () => {
} }
if (urlStr.includes("form=debrid")) { if (urlStr.includes("form=debrid")) {
// The POST to generate the code
return new Response(` return new Response(`
<div class="acp-box"> <div class="acp-box">
<h3>Link: https://mega.debrid/link1</h3> <h3>Link: https://mega.debrid/link1</h3>
@ -43,7 +42,6 @@ describe("mega-web-fallback", () => {
} }
if (urlStr.includes("ajax=debrid")) { if (urlStr.includes("ajax=debrid")) {
// Polling endpoint
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 }); return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
} }
@ -56,15 +54,98 @@ describe("mega-web-fallback", () => {
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result?.directUrl).toBe("https://mega.direct/123"); expect(result?.directUrl).toBe("https://mega.direct/123");
expect(result?.fileName).toBe("link1"); expect(result?.fileName).toBe("link1");
// Calls: 1. Login POST, 2. Verify GET, 3. Generate POST, 4. Polling POST
expect(fetchCallCount).toBe(4); expect(fetchCallCount).toBe(4);
}); });
it("fails fast on 'Kein Server für diesen Hoster' (account hoster quota) instead of re-login + re-poll", async () => {
let ajaxCalls = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
ajaxCalls += 1;
return new Response(JSON.stringify({ link: "", text: "Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal." }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
expect(ajaxCalls).toBe(1);
});
it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => {
let ajaxCalls = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
ajaxCalls += 1;
return new Response(JSON.stringify({ link: "https://should.not/happen" }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
expect(ajaxCalls).toBe(0);
});
it("logs in with the per-account credentials passed to unrestrict, not the default", async () => {
const loginsUsed: string[] = [];
globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: { body?: unknown }) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const params = new URLSearchParams(String(opts?.body ?? ""));
loginsUsed.push(params.get("login") || "");
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" }));
const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" });
expect(result?.directUrl).toBe("https://mega.direct/ok");
expect(loginsUsed).toContain("account2");
expect(loginsUsed).not.toContain("defaultacc");
});
it("throws if login fails to set cookie", async () => { it("throws if login fails to set cookie", async () => {
globalThis.fetch = vi.fn(async (url: string | URL | Request) => { globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url); const urlStr = String(url);
if (urlStr.includes("form=login")) { if (urlStr.includes("form=login")) {
const headers = new Headers(); // No cookie const headers = new Headers();
return new Response("", { headers, status: 200 }); return new Response("", { headers, status: 200 });
} }
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
@ -85,7 +166,6 @@ describe("mega-web-fallback", () => {
return new Response("", { headers, status: 200 }); return new Response("", { headers, status: 200 });
} }
if (urlStr.includes("page=debrideur")) { if (urlStr.includes("page=debrideur")) {
// Missing form!
return new Response('<html><body>Nothing here</body></html>', { status: 200 }); return new Response('<html><body>Nothing here</body></html>', { status: 200 });
} }
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
@ -111,7 +191,6 @@ describe("mega-web-fallback", () => {
return new Response('<form id="debridForm"></form>', { status: 200 }); return new Response('<form id="debridForm"></form>', { status: 200 });
} }
if (urlStr.includes("form=debrid")) { if (urlStr.includes("form=debrid")) {
// The generate POST returns HTML without any codes
return new Response(`<div>No links here</div>`, { status: 200 }); return new Response(`<div>No links here</div>`, { status: 200 });
} }
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
@ -120,7 +199,6 @@ describe("mega-web-fallback", () => {
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" })); const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
const result = await fallback.unrestrict("http://mega.debrid/file"); const result = await fallback.unrestrict("http://mega.debrid/file");
// Generation fails -> resets cookie -> tries again -> fails again -> returns null
expect(result).toBeNull(); expect(result).toBeNull();
}); });

82
tests/package-log.test.ts Normal file
View File

@ -0,0 +1,82 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ensurePackageLog, getPackageLogPath, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownPackageLogs();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("package-log", () => {
it("creates a persistent package log file", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
tempDirs.push(baseDir);
initPackageLogs(baseDir);
const logPath = ensurePackageLog({
packageId: "pkg-1",
name: "Test Paket",
outputDir: "C:\\downloads\\Test Paket",
extractDir: "C:\\extract\\Test Paket"
});
expect(logPath).not.toBeNull();
expect(fs.existsSync(logPath!)).toBe(true);
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Paket-Log Start");
expect(content).toContain("Test Paket");
});
it("writes detail events into the package log", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
tempDirs.push(baseDir);
initPackageLogs(baseDir);
ensurePackageLog({
packageId: "pkg-2",
name: "Detail Paket",
outputDir: "C:\\downloads\\Detail Paket",
extractDir: "C:\\extract\\Detail Paket"
});
logPackageEvent("pkg-2", "INFO", "Passwort-Versuch", {
archive: "episode.part1.rar",
attempt: "1/3",
password: "\"secret\""
});
await new Promise((resolve) => setTimeout(resolve, 350));
const logPath = getPackageLogPath("pkg-2");
expect(logPath).not.toBeNull();
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Passwort-Versuch");
expect(content).toContain("archive=episode.part1.rar");
expect(content).toContain("password=\"secret\"");
});
it("keeps traversal-like package ids inside the package log directory", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
tempDirs.push(baseDir);
initPackageLogs(baseDir);
const logPath = ensurePackageLog({
packageId: "..\\..\\outside",
name: "Traversal Paket",
outputDir: "C:\\downloads\\Traversal Paket",
extractDir: "C:\\extract\\Traversal Paket"
});
expect(logPath).not.toBeNull();
const logsDir = path.resolve(path.join(baseDir, "package-logs"));
const resolvedLogPath = path.resolve(logPath!);
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
});
});

109
tests/package-order.test.ts Normal file
View File

@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import type { DownloadItem, PackageEntry } from "../src/shared/types";
import { sortPackagesForDisplay } from "../src/renderer/package-order";
function createPackage(id: string, itemIds: string[]): PackageEntry {
const now = Date.now();
return {
id,
name: id,
outputDir: "",
extractDir: "",
status: "queued",
itemIds,
cancelled: false,
enabled: true,
priority: "normal",
createdAt: now,
updatedAt: now
};
}
function createItem(id: string, packageId: string, status: DownloadItem["status"], downloadedBytes: number): DownloadItem {
const now = Date.now();
return {
id,
packageId,
url: `https://hoster.example/${id}`,
provider: null,
status,
retries: 0,
speedBps: 0,
downloadedBytes,
totalBytes: downloadedBytes,
progressPercent: downloadedBytes > 0 ? 50 : 0,
fileName: `${id}.bin`,
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "",
createdAt: now,
updatedAt: now
};
}
describe("sortPackagesForDisplay", () => {
it("floats active packages to the top, keeping queue order within each group", () => {
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
// their original queue order; pkg-c (queued only) sinks below.
const packages = [
createPackage("pkg-a", ["a1", "a2"]),
createPackage("pkg-c", ["c1"]),
createPackage("pkg-b", ["b1", "b2"])
];
const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 250),
a2: createItem("a2", "pkg-a", "completed", 500),
c1: createItem("c1", "pkg-c", "queued", 0),
b1: createItem("b1", "pkg-b", "downloading", 800),
b2: createItem("b2", "pkg-b", "completed", 900)
};
const sorted = sortPackagesForDisplay(packages, items, true, true);
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c]
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"])
];
// Both active. pkg-b initially has more bytes than pkg-a.
const before: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 100),
b1: createItem("b1", "pkg-b", "downloading", 900)
};
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
// both are still active, so they keep queue order. (Old code swapped them.)
const after: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 5000),
b1: createItem("b1", "pkg-b", "downloading", 950)
};
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
expect(orderAfter).toEqual(orderBefore);
});
it("keeps package order untouched when auto sort is disabled", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"]),
createPackage("pkg-c", ["c1"])
];
const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "queued", 0),
b1: createItem("b1", "pkg-b", "downloading", 500),
c1: createItem("c1", "pkg-c", "queued", 0)
};
const sorted = sortPackagesForDisplay(packages, items, true, false);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
});

View File

@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockSessionFetch,
mockClearStorageData,
mockClearCache,
mockFromPartition,
mockBrowserWindow,
mockBrowserWindowCtor,
mockExecuteJavaScript,
mockLoadURL,
mockShow,
mockFocus
} = vi.hoisted(() => {
const sessionFetch = vi.fn();
const clearStorageData = vi.fn();
const clearCache = vi.fn();
const fromPartition = vi.fn();
const executeJavaScript = vi.fn();
const loadURL = vi.fn(async () => {});
const show = vi.fn();
const focus = vi.fn();
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
let destroyed = false;
const browserWindow = {
isDestroyed: vi.fn(() => destroyed),
isMinimized: vi.fn(() => false),
restore: vi.fn(),
show,
focus,
close: vi.fn(() => {
destroyed = true;
windowEvents.closed?.();
}),
setMenuBarVisibility: vi.fn(),
loadURL,
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
windowEvents[event] = handler;
return browserWindow;
}),
webContents: {
setUserAgent: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
webContentsEvents[event] = handler;
}),
executeJavaScript
}
};
const BrowserWindowCtor = vi.fn(() => {
destroyed = false;
return browserWindow;
});
return {
mockSessionFetch: sessionFetch,
mockClearStorageData: clearStorageData,
mockClearCache: clearCache,
mockFromPartition: fromPartition,
mockBrowserWindow: browserWindow,
mockBrowserWindowCtor: BrowserWindowCtor,
mockExecuteJavaScript: executeJavaScript,
mockLoadURL: loadURL,
mockShow: show,
mockFocus: focus
};
});
vi.mock("electron", () => ({
session: {
fromPartition: mockFromPartition
},
BrowserWindow: mockBrowserWindowCtor
}));
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
describe("realdebrid-web", () => {
const mockSession = {
fetch: mockSessionFetch,
clearStorageData: mockClearStorageData,
clearCache: mockClearCache
};
beforeEach(() => {
mockFromPartition.mockReturnValue(mockSession);
mockExecuteJavaScript.mockReset();
mockLoadURL.mockClear();
mockShow.mockClear();
mockFocus.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
mockFromPartition.mockReturnValue(mockSession);
});
it("extracts private tokens from current Real-Debrid HTML patterns", () => {
expect(extractPrivateTokenFromHtml("document.querySelectorAll('input[name=private_token]')[0].value = 'abc123';"))
.toBe("abc123");
expect(extractPrivateTokenFromHtml("<input type=\"text\" name=\"private_token\" value=\"def456\">"))
.toBe("def456");
expect(extractPrivateTokenFromHtml("<input value=\"ghi789\" name=\"private_token\">"))
.toBe("ghi789");
});
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
download: "https://cdn.real-debrid.example/file.bin",
filename: "file.bin",
filesize: 12345
}), { status: 200 }));
vi.stubGlobal("fetch", apiFetch);
mockExecuteJavaScript.mockResolvedValue("token-from-window");
const fallback = new RealDebridWebFallback(() => true);
await fallback.openLoginWindow();
const result = await fallback.unrestrict("https://rapidgator.net/file/abc");
expect(result).toEqual({
directUrl: "https://cdn.real-debrid.example/file.bin",
fileName: "file.bin",
fileSize: 12345,
retriesUsed: 0
});
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com");
expect(mockShow).toHaveBeenCalled();
expect(mockFocus).toHaveBeenCalled();
expect(mockSessionFetch).not.toHaveBeenCalled();
expect(apiFetch).toHaveBeenCalledTimes(1);
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
});
});

52
tests/rename-log.test.ts Normal file
View File

@ -0,0 +1,52 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("rename-log", () => {
it("writes rename events to the rename log", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-"));
tempDirs.push(baseDir);
initRenameLog(baseDir);
logRenameEvent("INFO", "Auto-Rename durchgeführt", {
packageName: "Test Paket",
sourcePath: "C:\\extract\\old.mkv",
targetPath: "C:\\extract\\new.mkv"
});
const logPath = getRenameLogPath();
expect(logPath).not.toBeNull();
expect(fs.existsSync(logPath!)).toBe(true);
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Rename-Log Start");
expect(content).toContain("Auto-Rename durchgeführt");
expect(content).toContain("sourcePath=C:\\extract\\old.mkv");
});
it("rotates oversized rename logs on startup", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-rotate-"));
tempDirs.push(baseDir);
const oversizedPath = path.join(baseDir, "rename.log");
fs.mkdirSync(baseDir, { recursive: true });
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
initRenameLog(baseDir);
expect(fs.existsSync(oversizedPath)).toBe(true);
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
const content = fs.readFileSync(oversizedPath, "utf8");
expect(content).toContain("Rename-Log Start");
});
});

View File

@ -17,7 +17,6 @@ function makeItems(names: string[]): MinimalItem[] {
} }
describe("resolveArchiveItemsFromList", () => { describe("resolveArchiveItemsFromList", () => {
// ── Multipart RAR (.partN.rar) ──
it("matches multipart .part1.rar archives", () => { it("matches multipart .part1.rar archives", () => {
const items = makeItems([ const items = makeItems([
@ -46,8 +45,6 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
}); });
// ── Old-style RAR (.rar + .r00, .r01, etc.) ──
it("matches old-style .rar + .rNN volumes", () => { it("matches old-style .rar + .rNN volumes", () => {
const items = makeItems([ const items = makeItems([
"Archive.rar", "Archive.rar",
@ -60,8 +57,6 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(4); expect(result).toHaveLength(4);
}); });
// ── Single RAR ──
it("matches a single .rar file", () => { it("matches a single .rar file", () => {
const items = makeItems(["SingleFile.rar", "Other.mkv"]); const items = makeItems(["SingleFile.rar", "Other.mkv"]);
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any); const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
@ -69,8 +64,6 @@ describe("resolveArchiveItemsFromList", () => {
expect((result[0] as any).fileName).toBe("SingleFile.rar"); expect((result[0] as any).fileName).toBe("SingleFile.rar");
}); });
// ── Split ZIP ──
it("matches split .zip.NNN files", () => { it("matches split .zip.NNN files", () => {
const items = makeItems([ const items = makeItems([
"Data.zip", "Data.zip",
@ -82,8 +75,6 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(4); expect(result).toHaveLength(4);
}); });
// ── Split 7z ──
it("matches split .7z.NNN files", () => { it("matches split .7z.NNN files", () => {
const items = makeItems([ const items = makeItems([
"Backup.7z.001", "Backup.7z.001",
@ -93,8 +84,6 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
}); });
// ── Generic .NNN splits ──
it("matches generic .NNN split files", () => { it("matches generic .NNN split files", () => {
const items = makeItems([ const items = makeItems([
"video.001", "video.001",
@ -105,8 +94,6 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
}); });
// ── Exact filename match ──
it("matches a single .zip by exact name", () => { it("matches a single .zip by exact name", () => {
const items = makeItems(["myarchive.zip", "other.rar"]); const items = makeItems(["myarchive.zip", "other.rar"]);
const result = resolveArchiveItemsFromList("myarchive.zip", items as any); const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
@ -114,8 +101,6 @@ describe("resolveArchiveItemsFromList", () => {
expect((result[0] as any).fileName).toBe("myarchive.zip"); expect((result[0] as any).fileName).toBe("myarchive.zip");
}); });
// ── Case insensitivity ──
it("matches case-insensitively", () => { it("matches case-insensitively", () => {
const items = makeItems([ const items = makeItems([
"MOVIE.PART1.RAR", "MOVIE.PART1.RAR",
@ -125,40 +110,26 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
}); });
// ── Stem-based fallback ──
it("uses stem-based fallback when exact patterns fail", () => { it("uses stem-based fallback when exact patterns fail", () => {
// Simulate a debrid service that renames "Movie.part1.rar" to "Movie.part1_dl.rar"
// but the disk file is "Movie.part1.rar"
const items = makeItems([ const items = makeItems([
"Movie.rar", "Movie.rar",
]); ]);
// The archive on disk is "Movie.part1.rar" but there's no item matching the
// .partN pattern. The stem "movie" should match "Movie.rar" via fallback.
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any); const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
// stem fallback: "movie" starts with "movie" and ends with .rar
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
}); });
// ── Single item fallback ──
it("returns single archive item when no pattern matches", () => { it("returns single archive item when no pattern matches", () => {
const items = makeItems(["totally-different-name.rar"]); const items = makeItems(["totally-different-name.rar"]);
const result = resolveArchiveItemsFromList("Original.rar", items as any); const result = resolveArchiveItemsFromList("Original.rar", items as any);
// Single item in list with archive extension → return it
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
}); });
// ── Empty when no match ──
it("returns empty when items have no archive extensions", () => { it("returns empty when items have no archive extensions", () => {
const items = makeItems(["video.mkv", "subtitle.srt"]); const items = makeItems(["video.mkv", "subtitle.srt"]);
const result = resolveArchiveItemsFromList("Archive.rar", items as any); const result = resolveArchiveItemsFromList("Archive.rar", items as any);
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
// ── Items without targetPath ──
it("falls back to fileName when targetPath is missing", () => { it("falls back to fileName when targetPath is missing", () => {
const items = [ const items = [
{ fileName: "Movie.part1.rar", id: "1", status: "completed" }, { fileName: "Movie.part1.rar", id: "1", status: "completed" },
@ -168,8 +139,6 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
}); });
// ── Multiple archives, should not cross-match ──
it("does not cross-match different archive groups", () => { it("does not cross-match different archive groups", () => {
const items = makeItems([ const items = makeItems([
"Episode.S01E01.part1.rar", "Episode.S01E01.part1.rar",

44
tests/selection.test.ts Normal file
View File

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { pruneSelection } from "../src/renderer/selection";
import type { SessionState } from "../src/shared/types";
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
const packages: Record<string, never> = {};
const items: Record<string, never> = {};
for (const id of packageIds) packages[id] = {} as never;
for (const id of itemIds) items[id] = {} as never;
return { packages, items };
}
describe("pruneSelection", () => {
it("drops ids whose package/item no longer exists", () => {
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
expect([...next].sort()).toEqual(["i1", "p1"]);
});
it("returns the SAME set instance when nothing changed (no needless re-render)", () => {
const sel = new Set(["p1", "i1"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
expect(next).toBe(sel);
});
it("returns the same instance for an empty selection", () => {
const sel = new Set<string>();
expect(pruneSelection(sel, session(["p1"], ["i1"]))).toBe(sel);
});
it("prunes everything when the whole session was swapped out", () => {
const sel = new Set(["p1", "i1"]);
const next = pruneSelection(sel, session([], []));
expect(next.size).toBe(0);
expect(next).not.toBe(sel);
});
it("keeps a mixed package+item selection when both survive", () => {
const sel = new Set(["p1", "p2", "i1"]);
const next = pruneSelection(sel, session(["p1", "p2"], ["i1", "i2"]));
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
expect(next).toBe(sel); // unchanged → same instance
});
});

View File

@ -8,9 +8,7 @@ import { setLogListener } from "../src/main/logger";
const tempDirs: string[] = []; const tempDirs: string[] = [];
afterEach(() => { afterEach(() => {
// Ensure session log is shut down between tests
shutdownSessionLog(); shutdownSessionLog();
// Ensure listener is cleared between tests
setLogListener(null); setLogListener(null);
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
@ -42,11 +40,9 @@ describe("session-log", () => {
initSessionLog(baseDir); initSessionLog(baseDir);
const logPath = getSessionLogPath()!; const logPath = getSessionLogPath()!;
// Simulate a log line via the listener
const { logger } = await import("../src/main/logger"); const { logger } = await import("../src/main/logger");
logger.info("Test-Nachricht für Session-Log"); logger.info("Test-Nachricht für Session-Log");
// Wait for flush (200ms interval + margin)
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
const content = fs.readFileSync(logPath, "utf8"); const content = fs.readFileSync(logPath, "utf8");
@ -77,7 +73,6 @@ describe("session-log", () => {
shutdownSessionLog(); shutdownSessionLog();
// Log after shutdown - should NOT appear in session log
const { logger } = await import("../src/main/logger"); const { logger } = await import("../src/main/logger");
logger.info("Nach-Shutdown-Nachricht"); logger.info("Nach-Shutdown-Nachricht");
@ -94,21 +89,16 @@ describe("session-log", () => {
const logsDir = path.join(baseDir, "session-logs"); const logsDir = path.join(baseDir, "session-logs");
fs.mkdirSync(logsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true });
// Create a fake old session log
const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt"); const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt");
fs.writeFileSync(oldFile, "old session"); fs.writeFileSync(oldFile, "old session");
// Set mtime to 30 days ago
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
fs.utimesSync(oldFile, oldTime, oldTime); fs.utimesSync(oldFile, oldTime, oldTime);
// Create a recent file
const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt"); const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt");
fs.writeFileSync(newFile, "new session"); fs.writeFileSync(newFile, "new session");
// initSessionLog triggers cleanup
initSessionLog(baseDir); initSessionLog(baseDir);
// Wait for async cleanup
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
expect(fs.existsSync(oldFile)).toBe(false); expect(fs.existsSync(oldFile)).toBe(false);
@ -124,7 +114,6 @@ describe("session-log", () => {
const logsDir = path.join(baseDir, "session-logs"); const logsDir = path.join(baseDir, "session-logs");
fs.mkdirSync(logsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true });
// Create a file from 2 days ago (should be kept)
const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt"); const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt");
fs.writeFileSync(recentFile, "recent session"); fs.writeFileSync(recentFile, "recent session");
const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
@ -147,7 +136,6 @@ describe("session-log", () => {
const path1 = getSessionLogPath(); const path1 = getSessionLogPath();
shutdownSessionLog(); shutdownSessionLog();
// Small delay to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 1100)); await new Promise((resolve) => setTimeout(resolve, 1100));
initSessionLog(baseDir); initSessionLog(baseDir);

Some files were not shown because too many files have changed in this diff Show More