Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77c937888a | ||
|
|
fbbc960d9d | ||
|
|
dc05b51083 | ||
|
|
61a830475b | ||
|
|
3c33b988c3 | ||
|
|
4432fa25e8 | ||
|
|
272a41a4a7 | ||
|
|
b71866c3dc | ||
|
|
b200b4e5b1 | ||
|
|
189af2242f | ||
|
|
92890f9649 | ||
|
|
2b93f47d3a | ||
|
|
15edfbeb74 | ||
|
|
aa65f56c28 | ||
|
|
92a36e2e47 | ||
|
|
77661389f3 | ||
|
|
397e667af2 | ||
|
|
20c803302d | ||
|
|
468df99142 | ||
|
|
2ececf699a | ||
|
|
d006a60553 | ||
|
|
3ed3877ac9 | ||
|
|
f3159b9c6e | ||
|
|
07b034440b | ||
|
|
7b39b33cc7 | ||
|
|
339c46bdd2 | ||
|
|
74aec6f056 | ||
|
|
afba79cdfd | ||
|
|
95a951ccc3 | ||
|
|
dc271e08ff | ||
|
|
e9c6a6bcae | ||
|
|
68f6923e42 | ||
|
|
5349554b01 | ||
|
|
f2e9de8da0 | ||
|
|
288a0762a6 | ||
|
|
9a71e01417 | ||
|
|
8d03ca124f | ||
|
|
53cc6b11eb | ||
|
|
251c41ca6c | ||
|
|
da72c11772 | ||
|
|
f5d435ccd2 | ||
|
|
ffcd0817cf | ||
|
|
254fce8736 | ||
|
|
2ad08fda05 | ||
|
|
99e4b2b885 | ||
|
|
c4c0110f84 | ||
|
|
0be5248a36 | ||
|
|
fd2cb724a3 | ||
|
|
9d8351c017 | ||
|
|
1e5cd3012b | ||
|
|
c5a4cb3488 | ||
|
|
efb5696c13 | ||
|
|
4a883fb93f | ||
|
|
66878174e6 | ||
|
|
661b1e8c21 | ||
|
|
613ebfd50a | ||
|
|
f098f52498 | ||
|
|
13885b830c | ||
|
|
4bded129ce | ||
|
|
664e34fc53 | ||
|
|
34a1a59a2a | ||
|
|
c4a49d99ed | ||
|
|
2448ae5c7a | ||
|
|
211e7e16cf | ||
|
|
dd31bee8b1 | ||
|
|
b414ab1773 | ||
|
|
5fc80b7b7f | ||
|
|
4b1625c5ee | ||
|
|
3977184fd4 | ||
|
|
748c07a531 | ||
|
|
d923d6dabb | ||
|
|
5495f5f24f | ||
|
|
35622445da | ||
|
|
30dbbbae9e | ||
|
|
98dc36648c | ||
|
|
8870a3aeca | ||
|
|
18eada963f | ||
|
|
e061997ed2 | ||
|
|
08372f99cb | ||
|
|
4398afa271 | ||
|
|
682bd1d759 | ||
|
|
6dc32303a0 | ||
|
|
7d52d5a495 | ||
|
|
d1274d23dc | ||
|
|
96bcbd13d7 | ||
|
|
d54a9d603b | ||
|
|
ceda9817f8 | ||
|
|
dfab5e0cb4 | ||
|
|
6a90eb500e | ||
|
|
342b4180a1 | ||
|
|
7ba8dd07b9 | ||
|
|
8ec5d17e09 | ||
|
|
6fcad8bc6c | ||
|
|
80b4b379f7 | ||
|
|
7f7bcf8ab2 | ||
|
|
c9d4e69bea | ||
|
|
709a93b405 | ||
|
|
5369ec0958 | ||
|
|
36ff1c5a86 | ||
|
|
84d02c5f98 | ||
|
|
c417ebb57f | ||
|
|
7ab508617a | ||
|
|
834da04b45 | ||
|
|
b1291d2e3c | ||
|
|
19c31caab5 |
13
.gitignore
vendored
13
.gitignore
vendored
@ -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/
|
||||||
@ -38,3 +36,14 @@ deploy/forgejo/caddy/config/
|
|||||||
deploy/forgejo/caddy/logs/
|
deploy/forgejo/caddy/logs/
|
||||||
deploy/forgejo/backups/
|
deploy/forgejo/backups/
|
||||||
.secrets
|
.secrets
|
||||||
|
|
||||||
|
*.log.old
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
rust-postprocess/
|
||||||
|
electron-postprocess/
|
||||||
|
python-postprocess/
|
||||||
|
scripts/*.py
|
||||||
|
scripts/*.ps1
|
||||||
|
scripts/*.md
|
||||||
|
scripts/fix-library-renames.mjs
|
||||||
|
|||||||
@ -1,183 +0,0 @@
|
|||||||
# Intensive Analyse: Pausen zwischen Pack-Entpackungen (10–15 Sekunden)
|
|
||||||
|
|
||||||
**Nur Analyse – keine Code-Änderungen.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Problem
|
|
||||||
|
|
||||||
Nach dem Entpacken eines Packs (z.B. 3 Parts einer Serie) passiert ca. 10–15 Sekunden lang scheinbar nichts, bevor das nächste Pack mit dem Entpacken beginnt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Steuerungslogik: Ein Slot für alle Packs
|
|
||||||
|
|
||||||
- **Nur ein Pack** darf gleichzeitig Post-Processing (inkl. Entpacken) machen.
|
|
||||||
- Steuerung: `acquirePostProcessSlot(packageId)` / `releasePostProcessSlot()` in `download-manager.ts`.
|
|
||||||
- Weitere Packs warten in `packagePostProcessWaiters` und kommen erst dran, wenn der aktive Task im **finally**-Block `releasePostProcessSlot()` aufruft.
|
|
||||||
|
|
||||||
```3761:3804:src/main/download-manager.ts
|
|
||||||
private async acquirePostProcessSlot(packageId: string): Promise<void> {
|
|
||||||
const maxConcurrent = 1;
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
private releasePostProcessSlot(): void {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Der Slot wird **erst** freigegeben, wenn die gesamte `runPackagePostProcessing`-Task-Funktion durch ist – genauer: wenn ihr **finally**-Block läuft (dort `releasePostProcessSlot()`). Alles, was vorher im gleichen Task **synchron** (await) läuft, blockiert den Slot und damit das nächste Pack.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Zwei relevante Code-Pfade
|
|
||||||
|
|
||||||
### 3.1 Pfad A: Hybrid-Extract (Pack noch nicht fertig)
|
|
||||||
|
|
||||||
- Bedingung: `!allDone && settings.hybridExtract && autoExtract && failed === 0 && success > 0`.
|
|
||||||
- Es werden nur die **bereits fertigen** Archive des Packs entpackt (`onlyArchives: readyArchives`), mit `skipPostCleanup: true` (kein Post-Cleanup im Extractor).
|
|
||||||
- Ablauf:
|
|
||||||
1. `handlePackagePostProcessing` → `runHybridExtraction`.
|
|
||||||
2. `await extractPackageArchives(..., onlyArchives, skipPostCleanup: true)`.
|
|
||||||
3. **Direkt danach (im gleichen Callstack, vor Rückkehr):**
|
|
||||||
`await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg)` (Zeile 6490).
|
|
||||||
4. Dann return aus `handlePackagePostProcessing` → **finally** → `releasePostProcessSlot()`.
|
|
||||||
|
|
||||||
**Folge:** Im Hybrid-Pfad blockiert **Auto-Rename** den Slot. Solange Rename läuft (rekursives Scannen + Umbenennen), kann das nächste Pack nicht starten. Das kann gut 10–15 Sekunden ausmachen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 Pfad B: Finales Post-Processing (Pack komplett, alle Items fertig)
|
|
||||||
|
|
||||||
- Bedingung: `allDone` (alle Items completed/failed/cancelled).
|
|
||||||
- Es wird das **gesamte** Pack entpackt (`extractPackageArchives` ohne `onlyArchives`), **ohne** `skipPostCleanup`.
|
|
||||||
- Ablauf:
|
|
||||||
1. `await extractPackageArchives(...)` – **inklusive allem, was der Extractor danach noch macht** (siehe Abschnitt 4).
|
|
||||||
2. Status-Updates, `recordPackageHistory(...)` (synchron, schnell).
|
|
||||||
3. `void this.runDeferredPostExtraction(...)` – wird **nicht** awaitet; Rename, MKV-Sammlung, Cleanup laufen im Hintergrund.
|
|
||||||
4. `handlePackagePostProcessing` kehrt zurück → **finally** → `releasePostProcessSlot()`.
|
|
||||||
|
|
||||||
**Folge:** Im Final-Pfad blockieren **nicht** mehr Rename/MKV/Cleanup im Download-Manager den Slot – die sind in `runDeferredPostExtraction` ausgelagert. Was den Slot aber **noch** blockiert, ist alles, was **innerhalb** von `extractPackageArchives` **nach** dem eigentlichen Entpacken passiert (Post-Cleanup und ggf. Nested-Extraction im Extractor).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Was passiert INNERHALB von `extractPackageArchives` (Extractor) – und blockiert
|
|
||||||
|
|
||||||
Nach dem Durchlauf über alle Kandidaten-Archive folgt im Extractor (`extractor.ts`) noch:
|
|
||||||
|
|
||||||
### 4.1 Nested-Extraction (Zeilen 2208–2284)
|
|
||||||
|
|
||||||
- Wenn `extracted > 0 && !skipPostCleanup && !onlyArchives`: Es werden Archive **im Zielordner** gesucht (`findArchiveCandidates(options.targetDir)`) und nacheinander entpackt.
|
|
||||||
- Pro nested-Archiv: Entpacken, ggf. `cleanupArchives([nestedArchive], ...)`.
|
|
||||||
- Kann bei vielen/vollen Archiven deutlich Zeit kosten und den Slot blockieren.
|
|
||||||
|
|
||||||
### 4.2 Post-Cleanup (Zeilen 2286–2328)
|
|
||||||
|
|
||||||
- Nur wenn `!options.skipPostCleanup`:
|
|
||||||
- **cleanupArchives(cleanupSources, cleanupMode):** Entfernen/Trash der entpackten Quell-Archive (readdir pro Verzeichnis, ggf. viele `rm`/rename).
|
|
||||||
- **removeDownloadLinkArtifacts(targetDir):** Link-Artefakte im Zielordner entfernen.
|
|
||||||
- **removeSampleArtifacts(targetDir):** Rekursives Durchlaufen des kompletten Extract-Ordners, Erkennung von Sample-Dateien/Ordnern, Löschen.
|
|
||||||
- **removeEmptyDirectoryTree(packageDir):** Rekursives Auflisten aller Unterordner, dann sortiert leere Ordner von tief nach flach löschen.
|
|
||||||
|
|
||||||
All das läuft **vor** dem Return von `extractPackageArchives`. Erst danach kommt im Download-Manager noch `recordPackageHistory` und `void runDeferredPostExtraction`. Der Slot wird also erst nach dem **gesamten** `extractPackageArchives`-Lauf (inkl. Nested + Post-Cleanup) freigegeben.
|
|
||||||
|
|
||||||
**Typische Zeitfresser (10–15 s):**
|
|
||||||
|
|
||||||
- `cleanupArchives`: viele Dateien/Archive → viele I/O-Ops.
|
|
||||||
- `removeSampleArtifacts`: vollständiger rekursiver Scan des Extract-Ordners.
|
|
||||||
- `removeEmptyDirectoryTree`: rekursives readdir über die ganze Verzeichnisstruktur.
|
|
||||||
- Nested-Extraction: zusätzliches Entpacken und ggf. weiteres Cleanup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Was im Download-Manager NACH dem Extractor noch passiert (Final-Pfad)
|
|
||||||
|
|
||||||
- **recordPackageHistory:** synchron, in-memory + Callback – vernachlässigbar.
|
|
||||||
- **runDeferredPostExtraction:** wird mit `void` gestartet, blockiert den Slot **nicht**. Darin laufen (im Hintergrund):
|
|
||||||
- `autoRenameExtractedVideoFiles`
|
|
||||||
- `cleanupRemainingArchiveArtifacts` (bei Hybrid-Szenario/cleanupMode)
|
|
||||||
- `collectMkvFilesToLibrary`
|
|
||||||
- `applyPackageDoneCleanup`
|
|
||||||
|
|
||||||
Diese Schritte verursachen **keine** Pause mehr zwischen zwei Packs im Final-Pfad, weil der Slot schon vorher freigegeben wird.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Zusammenfassung: Wo entstehen die 10–15 Sekunden Pause?
|
|
||||||
|
|
||||||
| Szenario | Was blockiert den Slot (Pause bis zum nächsten Pack)? |
|
|
||||||
|----------|--------------------------------------------------------|
|
|
||||||
| **Hybrid-Extract** (Pack hat noch offene Items) | `await autoRenameExtractedVideoFiles` **direkt nach** `extractPackageArchives` in `runHybridExtraction` (Zeile 6490). Rekursives Scannen + Umbenennen aller Video-Dateien. |
|
|
||||||
| **Finales Post-Processing** (Pack fertig) | Alles **innerhalb** von `extractPackageArchives`: Nested-Extraction (falls vorhanden) + Post-Cleanup (`cleanupArchives`, `removeDownloadLinkArtifacts`, `removeSampleArtifacts`, `removeEmptyDirectoryTree`). Rekursive Scans und viele I/O-Ops. |
|
|
||||||
|
|
||||||
In beiden Fällen ist die Pause also die Zeit **vor** `releasePostProcessSlot()` – einmal durch Rename im Manager (Hybrid), einmal durch Post-Cleanup und Nested-Extraction im Extractor (Final).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Mögliche Verbesserungen (nur Konzept, keine Änderung)
|
|
||||||
|
|
||||||
- **Hybrid-Pfad:**
|
|
||||||
`autoRenameExtractedVideoFiles` nach dem Hybrid-Extract **nicht** mehr awaiten, sondern (analog zu `runDeferredPostExtraction`) im Hintergrund starten und sofort aus `runHybridExtraction` zurückkehren. Dann wird der Slot direkt nach `extractPackageArchives` freigegeben; Rename läuft parallel.
|
|
||||||
|
|
||||||
- **Final-Pfad / Extractor:**
|
|
||||||
Post-Cleanup (und ggf. Nested-Extraction) **nicht** mehr synchron am Ende von `extractPackageArchives` ausführen, sondern:
|
|
||||||
- Entweder: Extractor gibt nach dem letzten „eigentlichen“ Entpacken sofort zurück und eine andere Komponente (z. B. Download-Manager oder eine Queue) übernimmt Cleanup/Nested im Hintergrund; oder
|
|
||||||
- Extractor bekommt eine Option (z. B. `deferPostCleanup: true`), liefert die nötigen Daten (z. B. Liste der zu löschenden Archive) zurück, und der Aufrufer führt Cleanup/Nested asynchron aus.
|
|
||||||
|
|
||||||
- **Slot-Logik unverändert:**
|
|
||||||
Ein Slot bleibt sinnvoll, um I/O und CPU beim Entpacken zu bündeln. Durch die Entkopplung der „teuren“ Schritte (Rename, Cleanup, Nested) von der Slot-Holding-Zeit verkürzt sich die Pause zwischen zwei Packs ohne Parallel-Entpacken mehrerer Packs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Relevante Stellen im Code (Orientierung)
|
|
||||||
|
|
||||||
- Slot: `acquirePostProcessSlot` / `releasePostProcessSlot` (download-manager.ts, ca. 3761–3804).
|
|
||||||
- Post-Processing-Task: `runPackagePostProcessing` → `handlePackagePostProcessing` (ca. 3806–3854, 6544–6916).
|
|
||||||
- Hybrid: `runHybridExtraction` (ca. 6374–6542), inkl. `await autoRenameExtractedVideoFiles` (6490).
|
|
||||||
- Final: `handlePackagePostProcessing` nach `extractPackageArchives` (6697–6916): `recordPackageHistory`, `void runDeferredPostExtraction`, dann return.
|
|
||||||
- Extractor: `extractPackageArchives` (extractor.ts, ca. 1880–2353), Nested 2208–2284, Post-Cleanup 2286–2328.
|
|
||||||
- Rename: `autoRenameExtractedVideoFiles` (download-manager.ts, 2173–2312), nutzt `collectVideoFiles` (rekursiv).
|
|
||||||
- MKV/Cleanup: `collectMkvFilesToLibrary` (2448), `cleanupRemainingArchiveArtifacts` (2353), `runDeferredPostExtraction` (6922–6965).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Vergleich: JDownloader (jdownloader-source)
|
|
||||||
|
|
||||||
Im JDownloader-Quellcode (z. B. `C:\Users\ploet\Desktop\jdownloader-source`) ist das Entpacken so aufgebaut, dass **pro Pack (3 Parts = 1 Folge) kaum schwere Arbeit nach dem eigentlichen Entpacken** im gleichen Queue-Job läuft – deshalb wirkt es „ohne Pause“.
|
|
||||||
|
|
||||||
### Ablauf bei JDownloader
|
|
||||||
|
|
||||||
- **Ein Archiv = ein Pack** (z. B. 3 RAR-Parts = 1 Archive mit `archive.getArchiveFiles()`).
|
|
||||||
- **Eine Queue** (`ExtractionQueue`), ein Job pro Archiv (`ExtractionController` extends `QueueAction`).
|
|
||||||
- Pro Job passiert in `ExtractionController.run()`:
|
|
||||||
1. `extractor.extract(this)` – reines Entpacken.
|
|
||||||
2. `extractor.close()`.
|
|
||||||
3. Je nach Exit-Code: `fireEvent(ExtractionEvent.Type.FINISHED)` (inkl. `FileCreationEvent(NEW_FILES, files)` – die Dateiliste kommt vom Extractor, **kein** rekursives Scannen).
|
|
||||||
4. Im **finally**: `fireEvent(Type.CLEANUP)` → `archive.onCleanUp()`.
|
|
||||||
5. Listener bei `CLEANUP`: `controller.removeArchiveFiles()`.
|
|
||||||
|
|
||||||
### Was `removeArchiveFiles()` bei JDownloader macht
|
|
||||||
|
|
||||||
- Holt die **bereits bekannten** Archive-Dateien: `archive.getArchiveFiles()` (die 3 Parts sind dem Archiv von Anfang an zugeordnet).
|
|
||||||
- Löscht nur diese Dateien (z. B. `link.deleteFile(remove)` pro Part).
|
|
||||||
- **Kein** rekursives Durchsuchen von Ordnern, **kein** `findArchiveCandidates`, **kein** Scannen des Extract-Ordners.
|
|
||||||
- Aufwand: O(Anzahl Parts) Datei-Löschungen, typisch sehr schnell.
|
|
||||||
|
|
||||||
### Was JDownloader in diesem Pfad nicht macht
|
|
||||||
|
|
||||||
- **Kein** Auto-Rename der entpackten Dateien im Extraction-Queue-Job (LinknameCleaner wird an anderer Stelle für Pfadsegmente genutzt, nicht als Blockierung nach Extract).
|
|
||||||
- **Kein** „Collect MKV to Library“ (rekursives Scannen + Verschieben) im gleichen Job.
|
|
||||||
- **Kein** `removeSampleArtifacts` (rekursiver Scan des Extract-Ordners).
|
|
||||||
- **Kein** `removeEmptyDirectoryTree` (rekursives Auflisten aller Unterordner).
|
|
||||||
- Nested-Archive (Deep-Extraction) werden als **neue** Archive in die Queue gestellt (`addToQueue(..., newArchive, false)`), also **separate Jobs**, die nacheinander laufen – der aktuelle Job ist sofort fertig.
|
|
||||||
|
|
||||||
### Warum es sich „flawless“ anfühlt
|
|
||||||
|
|
||||||
- Der kritische Pfad pro Pack ist: **Entpacken → Event FINISHED → Event CLEANUP → nur die bekannten Archive-Dateien löschen → `run()` endet**.
|
|
||||||
- Keine rechen- oder I/O-intensiven Schritte (keine rekursiven Scans, kein Rename, keine MKV-Sammlung) im gleichen Queue-Job.
|
|
||||||
- Das nächste Pack (nächster `ExtractionController` in der Queue) startet direkt nach `run()` return – die spürbare Pause entfällt.
|
|
||||||
|
|
||||||
### Übertrag auf unser Projekt
|
|
||||||
|
|
||||||
- Um ein ähnlich „flüssiges“ Verhalten zu erreichen, sollten **alle** zeitaufwändigen Schritte (Rename, MKV-Sammlung, Sample-Cleanup, leere Ordner entfernen, ggf. Post-Cleanup im Extractor) **nicht** den Post-Process-Slot blockieren.
|
|
||||||
- Konkret: Sie entweder **nach** `releasePostProcessSlot()` im Hintergrund ausführen (wie beim Final-Pfad bereits für Rename/MKV/Cleanup im Manager) **oder** den Extractor so auslegen, dass er direkt nach dem letzten eigentlichen Entpacken zurückkehrt und Cleanup/Nested in einem separaten, asynchronen Schritt erledigt wird (siehe Abschnitt 7).
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
# Mega-Debrid Multi-Account Support
|
|
||||||
|
|
||||||
> **For agentic workers:** Use superpowers:subagent-driven-development to implement this plan.
|
|
||||||
|
|
||||||
**Goal:** Multiple Mega-Debrid accounts with automatic fallback when an account hits Fair-Use limits or errors.
|
|
||||||
|
|
||||||
**Architecture:** Follow the existing Debrid-Link multi-key pattern. Store credentials as newline-separated `login:password` pairs. Account rotation uses linear iteration with cooldown/disable/daily-limit checks.
|
|
||||||
|
|
||||||
**Tech Stack:** TypeScript, Electron, React
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Create mega-debrid-accounts.ts parser module
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/shared/mega-debrid-accounts.ts`
|
|
||||||
|
|
||||||
- [ ] Create `MegaDebridAccountEntry` interface (id, login, password, index, label, maskedLogin)
|
|
||||||
- [ ] Create `parseMegaDebridAccounts(raw: string): MegaDebridAccountEntry[]` - split by newlines, parse `login:password` pairs, deduplicate by login, generate stable IDs via FNV-1a hash (`mda_` prefix)
|
|
||||||
- [ ] Create `getMegaDebridAccountId(login: string): string`
|
|
||||||
- [ ] Create `maskMegaDebridLogin(login: string): string`
|
|
||||||
- [ ] Create `getMegaDebridAccountLabel(index: number): string` - "Account 1", "Account 2"
|
|
||||||
- [ ] Create `serializeMegaDebridAccounts(accounts: {login: string, password: string}[]): string` - back to newline-separated format
|
|
||||||
- [ ] Backward compat: if raw string has no `:` separator, treat as legacy single-login (use megaPassword from settings)
|
|
||||||
|
|
||||||
### Task 2: Extend AppSettings with multi-account fields
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/shared/types.ts`
|
|
||||||
|
|
||||||
- [ ] Replace `megaLogin: string` → `megaCredentials: string` (newline-separated `login:password` pairs)
|
|
||||||
- [ ] Keep `megaPassword: string` for backward compat (migration reads it once)
|
|
||||||
- [ ] Add `megaDebridDisabledAccountIds: string[]`
|
|
||||||
- [ ] Add `megaDebridAccountDailyLimitBytes: Record<string, number>`
|
|
||||||
- [ ] Add `megaDebridAccountDailyUsageBytes: Record<string, number>`
|
|
||||||
- [ ] Add `megaDebridAccountTotalUsageBytes: Record<string, number>`
|
|
||||||
|
|
||||||
### Task 3: Add per-account daily limit functions
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/shared/provider-daily-limits.ts`
|
|
||||||
|
|
||||||
- [ ] Add `getMegaDebridAccountDailyLimitBytes(settings, accountId)`
|
|
||||||
- [ ] Add `getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs)`
|
|
||||||
- [ ] Add `isMegaDebridAccountDailyLimitReached(settings, accountId, epochMs)`
|
|
||||||
- [ ] Add `addMegaDebridAccountDailyUsageBytes(settings, accountId, bytes, epochMs)`
|
|
||||||
- [ ] Add `addMegaDebridAccountTotalUsageBytes(settings, accountId, bytes)`
|
|
||||||
- [ ] Add `isMegaDebridAccountDisabled(settings, accountId)`
|
|
||||||
|
|
||||||
### Task 4: Migrate storage from single to multi-account
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/main/storage.ts`
|
|
||||||
|
|
||||||
- [ ] In `normalizeSettings`: migrate old `megaLogin`+`megaPassword` → `megaCredentials` format (`login:password`)
|
|
||||||
- [ ] Normalize new fields with defaults
|
|
||||||
|
|
||||||
### Task 5: Implement account rotation in debrid.ts
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/main/debrid.ts`
|
|
||||||
|
|
||||||
- [ ] Add in-memory cooldown cache for Mega accounts (like `debridLinkKeyCooldowns`)
|
|
||||||
- [ ] Update `hasMegaDebridCredentials()` to check `parseMegaDebridAccounts().length > 0`
|
|
||||||
- [ ] Update Mega-Debrid API unrestrict to iterate accounts (skip disabled/limited/cooldown)
|
|
||||||
- [ ] Update Mega-Debrid Web unrestrict to iterate accounts
|
|
||||||
- [ ] Return `sourceAccountId` and `sourceAccountLabel` on success
|
|
||||||
- [ ] On failure: classify error, apply cooldown, try next account
|
|
||||||
|
|
||||||
### Task 6: Update download-manager usage tracking
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/main/download-manager.ts`
|
|
||||||
|
|
||||||
- [ ] Track per-account bytes for Mega-Debrid (like Debrid-Link key tracking)
|
|
||||||
- [ ] Update `isProviderDailyLimited` to check if ANY Mega account is available
|
|
||||||
|
|
||||||
### Task 7: Update UI for multi-account management
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/renderer/App.tsx`
|
|
||||||
|
|
||||||
- [ ] Update Mega-Debrid account dialog: textarea for credentials (`login:password` per line)
|
|
||||||
- [ ] Display account list with masked logins, enable/disable toggle, per-account daily limits
|
|
||||||
- [ ] Update account summary display to show individual accounts
|
|
||||||
|
|
||||||
### Task 8: Tests
|
|
||||||
|
|
||||||
- [ ] Unit tests for `parseMegaDebridAccounts` (parse, deduplicate, legacy compat)
|
|
||||||
- [ ] Unit tests for per-account daily limits
|
|
||||||
- [ ] Run full test suite: `npx vitest run`
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.146",
|
"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",
|
||||||
|
|||||||
79822
rd_downloader.log.old
79822
rd_downloader.log.old
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||||
|
|||||||
@ -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", {
|
||||||
|
|||||||
@ -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,15 +255,13 @@ 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) {
|
||||||
return created.body;
|
return created.body;
|
||||||
}
|
}
|
||||||
// Gitea can return 409/422/500 UNIQUE when the release was already partially created.
|
|
||||||
// Retry the GET — it may now exist.
|
|
||||||
if (created.status === 409 || created.status === 422 || created.status === 500) {
|
if (created.status === 409 || created.status === 422 || created.status === 500) {
|
||||||
const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
|
const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
|
||||||
if (retry.ok) {
|
if (retry.ok) {
|
||||||
@ -276,14 +273,17 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
try {
|
||||||
|
response = await fetch(uploadUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
@ -294,6 +294,15 @@ async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, f
|
|||||||
body: fileStream,
|
body: fileStream,
|
||||||
duplex: "half"
|
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;
|
||||||
@ -305,15 +314,21 @@ async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, f
|
|||||||
|
|
||||||
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) {
|
if (response.status === 409 || response.status === 422) {
|
||||||
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
|
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const rootDir = process.cwd();
|
const rootDir = process.cwd();
|
||||||
@ -363,6 +378,11 @@ async function main() {
|
|||||||
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
197
src/main/account-check.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,14 +1,82 @@
|
|||||||
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 { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
import type { RotationEvent } from "../shared/types";
|
||||||
|
|
||||||
/** Dedicated log file for multi-account/key rotation events:
|
export type RotationItemSink = (event: RotationEvent) => void;
|
||||||
* Mega-Debrid account selection, Debrid-Link key selection, per-attempt
|
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
||||||
* test result, cooldown set, fallback to next account/key, etc.
|
|
||||||
* Separate from rd_downloader.log so the user can see the rotation flow
|
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
||||||
* without the noise of normal download activity. */
|
return rotationItemContext.run(sink, fn);
|
||||||
|
}
|
||||||
|
|
||||||
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
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_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);
|
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
|
||||||
|
|
||||||
@ -51,11 +119,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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,7 +134,6 @@ function cleanupOldBackup(filePath: string): void {
|
|||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,7 +151,7 @@ export function initAccountRotationLog(baseDir: string): void {
|
|||||||
}
|
}
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
rotationLogPath,
|
rotationLogPath,
|
||||||
`=== Account-Rotation Log Start: ${new Date().toISOString()} ===\n`,
|
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@ -94,13 +159,6 @@ export function initAccountRotationLog(baseDir: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record an account/key rotation event. The format is intentionally compact
|
|
||||||
* and grep-friendly: timestamp + level + provider + accountLabel + event + fields.
|
|
||||||
* Example output:
|
|
||||||
* 2026-04-19T20:48:50.000Z [INFO] Mega-Debrid Web | Account 2 (fa**david@...) | TEST | link=https://...
|
|
||||||
* 2026-04-19T20:48:52.000Z [WARN] Mega-Debrid Web | Account 2 (fa**david@...) | FAILED reason="Antwort leer" cooldownSec=30 | link=https://...
|
|
||||||
* 2026-04-19T20:48:53.000Z [INFO] Mega-Debrid Web | Account 3 (am**@example.com) | TEST | link=https://...
|
|
||||||
* 2026-04-19T20:48:55.000Z [INFO] Mega-Debrid Web | Account 3 (am**@example.com) | OK directLink=https://... | link=https://... */
|
|
||||||
export function logAccountRotation(
|
export function logAccountRotation(
|
||||||
level: RotationLevel,
|
level: RotationLevel,
|
||||||
provider: string,
|
provider: string,
|
||||||
@ -108,6 +166,7 @@ export function logAccountRotation(
|
|||||||
event: string,
|
event: string,
|
||||||
fields?: Record<string, unknown>
|
fields?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
|
pushRotationEvent(level, provider, accountLabel, event, fields);
|
||||||
if (!rotationLogPath) {
|
if (!rotationLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -116,10 +175,9 @@ export function logAccountRotation(
|
|||||||
if (!fs.existsSync(rotationLogPath)) {
|
if (!fs.existsSync(rotationLogPath)) {
|
||||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
const head = `${new Date().toISOString()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
||||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,11 +195,10 @@ export function shutdownAccountRotationLog(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
rotationLogPath,
|
rotationLogPath,
|
||||||
`=== Account-Rotation Log Ende: ${new Date().toISOString()} ===\n`,
|
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
rotationLogPath = null;
|
rotationLogPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
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 { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
import {
|
import {
|
||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
|
DebridAccountStatus,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
@ -23,6 +25,8 @@ 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, fetchDebridLinkHostLimits } 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";
|
||||||
@ -36,12 +40,14 @@ import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves,
|
|||||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||||
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
|
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
|
||||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||||
|
import { buildBackupPayload, planBackupImport } from "./backup-payload";
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||||||
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
||||||
import { runStartupHealthCheck } from "./startup-health-check";
|
import { runStartupHealthCheck } from "./startup-health-check";
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
||||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
||||||
|
import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
||||||
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
||||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||||
@ -79,6 +85,7 @@ export class AppController {
|
|||||||
|
|
||||||
private autoResumePending = false;
|
private autoResumePending = false;
|
||||||
private runtimeStatsTimer: NodeJS.Timeout | null = null;
|
private runtimeStatsTimer: NodeJS.Timeout | null = null;
|
||||||
|
private lastMemoryWarnAt = 0;
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
configureLogger(this.storagePaths.baseDir);
|
configureLogger(this.storagePaths.baseDir);
|
||||||
@ -88,6 +95,13 @@ export class AppController {
|
|||||||
initAuditLog(this.storagePaths.baseDir);
|
initAuditLog(this.storagePaths.baseDir);
|
||||||
initAccountRotationLog(this.storagePaths.baseDir);
|
initAccountRotationLog(this.storagePaths.baseDir);
|
||||||
initRenameLog(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);
|
initTraceLog(this.storagePaths.baseDir);
|
||||||
this.settings = loadSettings(this.storagePaths);
|
this.settings = loadSettings(this.storagePaths);
|
||||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||||
@ -100,7 +114,7 @@ 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),
|
||||||
@ -118,10 +132,6 @@ export class AppController {
|
|||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
runtimeDir: this.storagePaths.baseDir
|
runtimeDir: this.storagePaths.baseDir
|
||||||
});
|
});
|
||||||
// Startup Health-Check: surface problematic state early (missing download
|
|
||||||
// dir, low disk space, no provider configured, corrupted state file).
|
|
||||||
// Never blocks startup — findings go into the normal log + audit log so
|
|
||||||
// the user can diagnose issues before hitting them mid-download.
|
|
||||||
try {
|
try {
|
||||||
const report = runStartupHealthCheck(this.settings, this.storagePaths);
|
const report = runStartupHealthCheck(this.settings, this.storagePaths);
|
||||||
if (report.errorCount > 0 || report.warnCount > 0) {
|
if (report.errorCount > 0 || report.warnCount > 0) {
|
||||||
@ -154,6 +164,7 @@ export class AppController {
|
|||||||
this.runtimeStatsTimer = setInterval(() => {
|
this.runtimeStatsTimer = setInterval(() => {
|
||||||
this.manager.persistRuntimeStats();
|
this.manager.persistRuntimeStats();
|
||||||
this.settings = this.manager.getSettings();
|
this.settings = this.manager.getSettings();
|
||||||
|
this.checkMemoryPressure();
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
this.runtimeStatsTimer.unref?.();
|
this.runtimeStatsTimer.unref?.();
|
||||||
|
|
||||||
@ -164,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)}`));
|
||||||
@ -181,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()
|
||||||
@ -208,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -234,6 +270,10 @@ export class AppController {
|
|||||||
return getRenameLogPath();
|
return getRenameLogPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDesktopRenameLogPath(): string | null {
|
||||||
|
return getDesktopRenameLogPath();
|
||||||
|
}
|
||||||
|
|
||||||
public getTraceLogPath(): string | null {
|
public getTraceLogPath(): string | null {
|
||||||
return getTraceLogPath();
|
return getTraceLogPath();
|
||||||
}
|
}
|
||||||
@ -263,6 +303,27 @@ export class AppController {
|
|||||||
return next;
|
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;
|
||||||
@ -275,20 +336,7 @@ export class AppController {
|
|||||||
return previousSettings;
|
return previousSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve the live all-time counters from the download manager
|
this.overlayLiveUsageCounters(nextSettings);
|
||||||
const liveSettings = this.manager.getSettings();
|
|
||||||
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
|
||||||
nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
|
||||||
nextSettings.totalRuntimeAllTimeMs = Math.max(nextSettings.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
|
|
||||||
nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
|
|
||||||
nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
|
|
||||||
nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
|
|
||||||
nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
|
|
||||||
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
|
|
||||||
);
|
|
||||||
nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
|
|
||||||
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
|
|
||||||
);
|
|
||||||
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
|
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
|
||||||
this.settings = nextSettings;
|
this.settings = nextSettings;
|
||||||
if (retentionChanged) {
|
if (retentionChanged) {
|
||||||
@ -374,6 +422,27 @@ export class AppController {
|
|||||||
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
|
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) {
|
||||||
@ -384,13 +453,9 @@ 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 });
|
||||||
}
|
}
|
||||||
// Flush any pending async saves BEFORE the update process starts.
|
|
||||||
// This ensures the queue is fully persisted to disk so it survives the restart.
|
|
||||||
this.manager.persistNowSync();
|
this.manager.persistNowSync();
|
||||||
|
|
||||||
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
|
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
|
||||||
@ -573,23 +638,21 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public exportBackup(): Buffer {
|
public exportBackup(): Buffer {
|
||||||
const settings = { ...this.settings };
|
const includeDownloads = Boolean(this.settings.backupIncludeDownloads);
|
||||||
const session = this.manager.getSession();
|
const payloadObj = buildBackupPayload({
|
||||||
const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
settings: { ...this.settings },
|
||||||
const payload = JSON.stringify({
|
|
||||||
version: 2,
|
|
||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
settings,
|
session: this.manager.getSession(),
|
||||||
session,
|
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode)
|
||||||
history
|
|
||||||
});
|
});
|
||||||
this.audit("INFO", "Backup exportiert", {
|
this.audit("INFO", "Backup exportiert", {
|
||||||
historyEntries: history.length,
|
kind: payloadObj.kind,
|
||||||
sessionItems: Object.keys(session.items).length,
|
historyEntries: payloadObj.history ? payloadObj.history.length : 0,
|
||||||
sessionPackages: Object.keys(session.packages).length
|
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
|
||||||
|
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
|
||||||
});
|
});
|
||||||
return encryptBackup(payload);
|
return encryptBackup(JSON.stringify(payloadObj));
|
||||||
}
|
}
|
||||||
|
|
||||||
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
|
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
|
||||||
@ -608,30 +671,28 @@ export class AppController {
|
|||||||
return getSupportBundleDefaultFileName();
|
return getSupportBundleDefaultFileName();
|
||||||
}
|
}
|
||||||
|
|
||||||
public importBackup(data: Buffer): { restored: boolean; message: string } {
|
public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } {
|
||||||
let parsed: Record<string, unknown>;
|
let parsed: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
// Try encrypted MDD format first
|
|
||||||
const json = decryptBackup(data);
|
const json = decryptBackup(data);
|
||||||
parsed = JSON.parse(json) as Record<string, unknown>;
|
parsed = JSON.parse(json) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: try legacy plaintext JSON (old backups)
|
|
||||||
try {
|
try {
|
||||||
const json = data.toString("utf8");
|
const json = data.toString("utf8");
|
||||||
parsed = JSON.parse(json) as Record<string, unknown>;
|
parsed = JSON.parse(json) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
return { restored: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
|
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;
|
||||||
|
|
||||||
// Restore settings — ALL credentials are included (no more masking)
|
|
||||||
const importedSettings = parsed.settings as AppSettings;
|
const importedSettings = parsed.settings as AppSettings;
|
||||||
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
|
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
|
||||||
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
|
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
|
||||||
// Legacy backup compatibility: if credentials were masked with ***, keep current values
|
|
||||||
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
|
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
|
||||||
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
|
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
|
||||||
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
|
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
|
||||||
@ -644,23 +705,42 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
|
||||||
this.manager.stop();
|
this.manager.stop();
|
||||||
this.manager.abortAllPostProcessing();
|
this.manager.abortAllPostProcessing();
|
||||||
this.manager.clearPersistTimer();
|
this.manager.clearPersistTimer();
|
||||||
cancelPendingAsyncSaves();
|
cancelPendingAsyncSaves();
|
||||||
|
|
||||||
// Restore session
|
|
||||||
const restoredSession = normalizeLoadedSessionTransientFields(
|
const restoredSession = normalizeLoadedSessionTransientFields(
|
||||||
normalizeLoadedSession(parsed.session)
|
normalizeLoadedSession(parsed.session)
|
||||||
);
|
);
|
||||||
saveSession(this.storagePaths, restoredSession);
|
saveSession(this.storagePaths, restoredSession);
|
||||||
|
|
||||||
// Restore history (if present in backup)
|
|
||||||
if (Array.isArray(parsed.history) && parsed.history.length > 0) {
|
if (Array.isArray(parsed.history) && parsed.history.length > 0) {
|
||||||
const normalizedHistory = (parsed.history as unknown[])
|
const normalizedHistory = (parsed.history as unknown[])
|
||||||
.map((raw, idx) => normalizeHistoryEntry(raw, idx))
|
.map((raw, idx) => normalizeHistoryEntry(raw, idx))
|
||||||
@ -673,15 +753,14 @@ export class AppController {
|
|||||||
|
|
||||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||||
|
|
||||||
// Prevent prepareForShutdown from overwriting the restored data
|
|
||||||
this.manager.skipShutdownPersist = true;
|
this.manager.skipShutdownPersist = true;
|
||||||
this.manager.blockAllPersistence = true;
|
this.manager.blockAllPersistence = true;
|
||||||
logger.info("Backup wiederhergestellt (verschlüsseltes Format)");
|
logger.info("Backup wiederhergestellt — App startet automatisch neu");
|
||||||
this.audit("WARN", "Backup importiert", {
|
this.audit("WARN", "Backup importiert", {
|
||||||
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
|
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
|
||||||
accountSummary: buildAccountSummary(this.settings)
|
accountSummary: buildAccountSummary(this.settings)
|
||||||
});
|
});
|
||||||
return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." };
|
return { restored: true, relaunch: true, message: "Backup wiederhergestellt – App startet automatisch neu…" };
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSessionLogPath(): string | null {
|
public getSessionLogPath(): string | null {
|
||||||
@ -712,6 +791,7 @@ export class AppController {
|
|||||||
shutdownPackageLogs();
|
shutdownPackageLogs();
|
||||||
shutdownItemLogs();
|
shutdownItemLogs();
|
||||||
shutdownRenameLog();
|
shutdownRenameLog();
|
||||||
|
shutdownDesktopRenameLog();
|
||||||
this.audit("INFO", "App beendet");
|
this.audit("INFO", "App beendet");
|
||||||
shutdownTraceLog();
|
shutdownTraceLog();
|
||||||
shutdownAccountRotationLog();
|
shutdownAccountRotationLog();
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
||||||
@ -45,11 +46,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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +61,6 @@ function cleanupOldBackup(filePath: string): void {
|
|||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +76,7 @@ export function initAuditLog(baseDir: string): void {
|
|||||||
if (!fs.existsSync(auditLogPath)) {
|
if (!fs.existsSync(auditLogPath)) {
|
||||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
|
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
auditLogPath = null;
|
auditLogPath = null;
|
||||||
}
|
}
|
||||||
@ -95,11 +93,10 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
|
|||||||
}
|
}
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
auditLogPath,
|
auditLogPath,
|
||||||
`${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`,
|
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,9 +112,8 @@ export function shutdownAuditLog(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
|
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
auditLogPath = null;
|
auditLogPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,15 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
// Fixed app key — like JDownloader 2: deterministic, works on any machine.
|
|
||||||
// Not meant to protect against reverse-engineering, just prevents casual
|
|
||||||
// plaintext snooping when someone opens the backup file.
|
|
||||||
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||||
const ALGORITHM = "aes-256-gcm";
|
const ALGORITHM = "aes-256-gcm";
|
||||||
const IV_LENGTH = 12; // 96-bit IV for GCM
|
const IV_LENGTH = 12;
|
||||||
const AUTH_TAG_LENGTH = 16;
|
const AUTH_TAG_LENGTH = 16;
|
||||||
const MAGIC = Buffer.from("MDD1"); // file signature
|
const MAGIC = Buffer.from("MDD1");
|
||||||
|
|
||||||
function deriveKey(): Buffer {
|
function deriveKey(): Buffer {
|
||||||
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a UTF-8 string into an MDD backup buffer.
|
|
||||||
* Format: MAGIC(4) | IV(12) | AUTH_TAG(16) | CIPHERTEXT(…)
|
|
||||||
*/
|
|
||||||
export function encryptBackup(plaintext: string): Buffer {
|
export function encryptBackup(plaintext: string): Buffer {
|
||||||
const key = deriveKey();
|
const key = deriveKey();
|
||||||
const iv = crypto.randomBytes(IV_LENGTH);
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
@ -26,10 +19,6 @@ export function encryptBackup(plaintext: string): Buffer {
|
|||||||
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt an MDD backup buffer back to a UTF-8 string.
|
|
||||||
* Throws on invalid/corrupted data.
|
|
||||||
*/
|
|
||||||
export function decryptBackup(data: Buffer): string {
|
export function decryptBackup(data: Buffer): string {
|
||||||
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
|
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||||
|
|||||||
77
src/main/backup-payload.ts
Normal file
77
src/main/backup-payload.ts
Normal 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -212,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 {
|
||||||
@ -344,7 +341,6 @@ export class BestDebridWebFallback {
|
|||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore cache clear failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -84,7 +83,6 @@ export async function cleanupCancelledPackageArtifactsAsync(
|
|||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +148,6 @@ export async function removeDownloadLinkArtifacts(
|
|||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -240,7 +237,6 @@ export async function removeSampleArtifacts(
|
|||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removedFiles += 1;
|
removedFiles += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -263,7 +259,6 @@ export async function removeSampleArtifacts(
|
|||||||
removedFiles += filesInDir;
|
removedFiles += filesInDir;
|
||||||
removedDirs += 1;
|
removedDirs += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,12 +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; // Internal detection threshold for disk backpressure
|
export const DISK_BUSY_THRESHOLD_MS = 300;
|
||||||
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes
|
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"]);
|
||||||
@ -72,6 +72,8 @@ export function defaultSettings(): AppSettings {
|
|||||||
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"),
|
||||||
@ -104,6 +106,7 @@ export function defaultSettings(): AppSettings {
|
|||||||
autoSkipExtracted: false,
|
autoSkipExtracted: false,
|
||||||
hideExtractedItems: true,
|
hideExtractedItems: true,
|
||||||
confirmDeleteSelection: true,
|
confirmDeleteSelection: true,
|
||||||
|
backupIncludeDownloads: false,
|
||||||
totalDownloadedAllTime: 0,
|
totalDownloadedAllTime: 0,
|
||||||
totalCompletedFilesAllTime: 0,
|
totalCompletedFilesAllTime: 0,
|
||||||
totalRuntimeAllTimeMs: 0,
|
totalRuntimeAllTimeMs: 0,
|
||||||
@ -123,6 +126,7 @@ export function defaultSettings(): AppSettings {
|
|||||||
megaDebridAccountDailyLimitBytes: {},
|
megaDebridAccountDailyLimitBytes: {},
|
||||||
megaDebridAccountDailyUsageBytes: {},
|
megaDebridAccountDailyUsageBytes: {},
|
||||||
megaDebridAccountTotalUsageBytes: {},
|
megaDebridAccountTotalUsageBytes: {},
|
||||||
|
debridAccountStatuses: {},
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
@ -6,6 +6,7 @@ import { APP_VERSION } from "./constants";
|
|||||||
import { getAuditLogPath } from "./audit-log";
|
import { getAuditLogPath } from "./audit-log";
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
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 { getItemLogPath as getPersistedItemLogPath } from "./item-log";
|
||||||
import { getSessionLogPath } from "./session-log";
|
import { getSessionLogPath } from "./session-log";
|
||||||
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
|
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
|
||||||
@ -44,6 +45,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
|||||||
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
|
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
|
||||||
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
|
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
|
||||||
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
|
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
|
||||||
|
{ method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
|
||||||
{ method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
|
{ method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
|
||||||
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
|
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
|
||||||
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
|
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
|
||||||
@ -116,7 +118,6 @@ function getPort(baseDir: string): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return DEFAULT_PORT;
|
return DEFAULT_PORT;
|
||||||
}
|
}
|
||||||
@ -135,7 +136,6 @@ function getHost(baseDir: string): string {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return DEFAULT_HOST;
|
return DEFAULT_HOST;
|
||||||
}
|
}
|
||||||
@ -530,6 +530,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
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") {
|
if (pathname === "/logs/audit") {
|
||||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
||||||
const grep = url.searchParams.get("grep") || "";
|
const grep = url.searchParams.get("grep") || "";
|
||||||
|
|||||||
@ -53,7 +53,6 @@ function readPort(baseDir: string): number {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return DEFAULT_PORT;
|
return DEFAULT_PORT;
|
||||||
}
|
}
|
||||||
@ -71,7 +70,6 @@ function readHost(baseDir: string): string {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return DEFAULT_HOST;
|
return DEFAULT_HOST;
|
||||||
}
|
}
|
||||||
@ -158,7 +156,6 @@ function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): Suppor
|
|||||||
bytes += fs.statSync(fullPath).size;
|
bytes += fs.statSync(fullPath).size;
|
||||||
fileCount += 1;
|
fileCount += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore unreadable files
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
252
src/main/desktop-rename-log.ts
Normal file
252
src/main/desktop-rename-log.ts
Normal 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 });
|
||||||
|
}
|
||||||
@ -127,6 +127,14 @@ export function validateDownloadedFileCompletion(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.plan.source === "stream-end") {
|
if (args.plan.source === "stream-end") {
|
||||||
|
if (actualBytes <= 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
totalBytes: 0,
|
||||||
|
acceptedMetadataMismatch: false,
|
||||||
|
error: "download_underflow:0/0"
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
totalBytes: actualBytes,
|
totalBytes: actualBytes,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
45
src/main/error-ring.ts
Normal file
45
src/main/error-ring.ts
Normal 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();
|
||||||
|
}
|
||||||
@ -1,7 +1,3 @@
|
|||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 1 — Imports & Konstanten
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@ -47,10 +43,6 @@ const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000;
|
|||||||
const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80;
|
const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80;
|
||||||
let currentExtractCpuPriority: string | undefined;
|
let currentExtractCpuPriority: string | undefined;
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 2 — Types & Interfaces
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export interface ExtractOptions {
|
export interface ExtractOptions {
|
||||||
packageDir: string;
|
packageDir: string;
|
||||||
targetDir: string;
|
targetDir: string;
|
||||||
@ -169,20 +161,15 @@ interface DaemonRequest {
|
|||||||
passwordCount: number;
|
passwordCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 3 — Subst Drive Mapping (Windows long-path workaround)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const activeSubstDrives = new Set<string>();
|
const activeSubstDrives = new Set<string>();
|
||||||
|
|
||||||
function findFreeSubstDrive(): string | null {
|
function findFreeSubstDrive(): string | null {
|
||||||
if (process.platform !== "win32") return null;
|
if (process.platform !== "win32") return null;
|
||||||
for (let code = 90; code >= 71; code--) { // Z to G
|
for (let code = 90; code >= 71; code--) {
|
||||||
const letter = String.fromCharCode(code);
|
const letter = String.fromCharCode(code);
|
||||||
if (activeSubstDrives.has(letter)) continue;
|
if (activeSubstDrives.has(letter)) continue;
|
||||||
try {
|
try {
|
||||||
fs.accessSync(`${letter}:\\`);
|
fs.accessSync(`${letter}:\\`);
|
||||||
// Drive exists, skip
|
|
||||||
} catch {
|
} catch {
|
||||||
return letter;
|
return letter;
|
||||||
}
|
}
|
||||||
@ -226,14 +213,9 @@ export function cleanupStaleSubstDrives(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — subst cleanup is best-effort
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 4 — Archiv-Erkennung & Kandidaten
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> {
|
export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> {
|
||||||
let fd: fs.promises.FileHandle | null = null;
|
let fd: fs.promises.FileHandle | null = null;
|
||||||
try {
|
try {
|
||||||
@ -368,7 +350,6 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
|
|||||||
return !fileNamesLower.has(`${fileName}.001`.toLowerCase());
|
return !fileNamesLower.has(`${fileName}.001`.toLowerCase());
|
||||||
});
|
});
|
||||||
const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath));
|
const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath));
|
||||||
// Generic .001 splits (HJSplit etc.) — exclude already-recognized .zip.001 and .7z.001
|
|
||||||
const genericSplit = files.filter((filePath) => {
|
const genericSplit = files.filter((filePath) => {
|
||||||
const fileName = archiveDetectionName(filePath).toLowerCase();
|
const fileName = archiveDetectionName(filePath).toLowerCase();
|
||||||
if (!/\.001$/.test(fileName)) return false;
|
if (!/\.001$/.test(fileName)) return false;
|
||||||
@ -406,10 +387,6 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
|
|||||||
return unique;
|
return unique;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 5 — Cleanup & Dateisystem
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
@ -438,8 +415,6 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Companion metadata files (.sfv, .nfo, .md5, etc.) share the same base stem
|
|
||||||
// as the archive and should be cleaned up together with the archive parts.
|
|
||||||
const COMPANION_EXTS_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|srr)$/i;
|
const COMPANION_EXTS_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|srr)$/i;
|
||||||
const addCompanions = (stemRe: string): void => {
|
const addCompanions = (stemRe: string): void => {
|
||||||
for (const candidate of filesInDir) {
|
for (const candidate of filesInDir) {
|
||||||
@ -504,12 +479,10 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
|
|||||||
return Array.from(targets);
|
return Array.from(targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tar compound archives (.tar.gz, .tar.bz2, .tar.xz, .tgz, .tbz2, .txz)
|
|
||||||
if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) {
|
if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) {
|
||||||
return Array.from(targets);
|
return Array.from(targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic .NNN split files (HJSplit etc.)
|
|
||||||
const genericSplit = fileName.match(/^(.*)\.(\d{3})$/i);
|
const genericSplit = fileName.match(/^(.*)\.(\d{3})$/i);
|
||||||
if (genericSplit) {
|
if (genericSplit) {
|
||||||
const stem = escapeRegex(genericSplit[1]);
|
const stem = escapeRegex(genericSplit[1]);
|
||||||
@ -572,7 +545,6 @@ export async function cleanupArchives(
|
|||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@ -595,7 +567,6 @@ export async function cleanupArchives(
|
|||||||
await fs.promises.rm(filePath, { force: true });
|
await fs.promises.rm(filePath, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
@ -684,16 +655,11 @@ export async function removeEmptyDirectoryTree(rootDir: string): Promise<number>
|
|||||||
removed += 1;
|
removed += 1;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 6 — Passwort-Management (LRU-Cache & Kandidaten)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function packagePasswordCacheKey(packageDir: string, packageId?: string): string {
|
function packagePasswordCacheKey(packageDir: string, packageId?: string): string {
|
||||||
const normalizedPackageId = String(packageId || "").trim();
|
const normalizedPackageId = String(packageId || "").trim();
|
||||||
if (normalizedPackageId) {
|
if (normalizedPackageId) {
|
||||||
@ -715,7 +681,6 @@ function readCachedPackagePassword(cacheKey: string): string {
|
|||||||
if (!cached) {
|
if (!cached) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
// Refresh insertion order to keep recently used package caches alive.
|
|
||||||
packageLearnedPasswords.delete(cacheKey);
|
packageLearnedPasswords.delete(cacheKey);
|
||||||
packageLearnedPasswords.set(cacheKey, cached);
|
packageLearnedPasswords.set(cacheKey, cached);
|
||||||
return cached;
|
return cached;
|
||||||
@ -742,6 +707,17 @@ function clearCachedPackagePassword(cacheKey: string): void {
|
|||||||
packageLearnedPasswords.delete(cacheKey);
|
packageLearnedPasswords.delete(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetExtractorCachesForPasswordChange(): { learnedCleared: number; daemonRestarted: boolean } {
|
||||||
|
const learnedCleared = packageLearnedPasswords.size;
|
||||||
|
packageLearnedPasswords.clear();
|
||||||
|
let daemonRestarted = false;
|
||||||
|
if (daemonProcess && !daemonBusy) {
|
||||||
|
shutdownDaemon();
|
||||||
|
daemonRestarted = true;
|
||||||
|
}
|
||||||
|
return { learnedCleared, daemonRestarted };
|
||||||
|
}
|
||||||
|
|
||||||
export function archiveFilenamePasswords(archiveName: string): string[] {
|
export function archiveFilenamePasswords(archiveName: string): string[] {
|
||||||
const name = String(archiveName || "");
|
const name = String(archiveName || "");
|
||||||
if (!name) return [];
|
if (!name) return [];
|
||||||
@ -791,10 +767,6 @@ function prioritizePassword(passwords: string[], successful: string): string[] {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 7 — Fehler-Klassifizierung
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export function cleanErrorText(text: string): string {
|
export function cleanErrorText(text: string): string {
|
||||||
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
||||||
if (normalized.length <= 500) {
|
if (normalized.length <= 500) {
|
||||||
@ -935,10 +907,6 @@ function isJvmRuntimeMissingError(errorText: string): boolean {
|
|||||||
|| text.includes("enoent");
|
|| text.includes("enoent");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 8 — Backend-Modus (auto / jvm / legacy)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export function resolveExtractorBackendMode(
|
export function resolveExtractorBackendMode(
|
||||||
rawValue?: string | null,
|
rawValue?: string | null,
|
||||||
isVitestEnv = Boolean(process.env.VITEST)
|
isVitestEnv = Boolean(process.env.VITEST)
|
||||||
@ -964,9 +932,6 @@ export function resolveExtractorBackendModeForArchive(
|
|||||||
if (requestedMode !== "auto") {
|
if (requestedMode !== "auto") {
|
||||||
return requestedMode;
|
return requestedMode;
|
||||||
}
|
}
|
||||||
// On Windows, multipart RAR extraction feels significantly snappier with the
|
|
||||||
// native CLI path than with the JVM backend, and we already harden that path
|
|
||||||
// with subst + flat-mode fallback.
|
|
||||||
if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) {
|
if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) {
|
||||||
return "legacy";
|
return "legacy";
|
||||||
}
|
}
|
||||||
@ -985,10 +950,6 @@ function isRarArchivePath(filePath: string): boolean {
|
|||||||
return /\.(?:rar|r\d{2,3})$/i.test(String(filePath || ""));
|
return /\.(?:rar|r\d{2,3})$/i.test(String(filePath || ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 9 — Native Extractor Resolution (7-Zip / WinRAR)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function is7zCommand(command: string): boolean {
|
function is7zCommand(command: string): boolean {
|
||||||
const lower = command.toLowerCase();
|
const lower = command.toLowerCase();
|
||||||
return lower.includes("7z") && !lower.includes("unrar") && !lower.includes("winrar");
|
return lower.includes("7z") && !lower.includes("unrar") && !lower.includes("winrar");
|
||||||
@ -1200,12 +1161,6 @@ async function findAlternativeExtractor(currentCommand: string, archivePath = ""
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 10 — CPU / Thread / Priority
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/** Compute a safe JVM -Xmx value based on available physical RAM.
|
|
||||||
* Reserves 4 GB for Windows + Electron + other processes, caps at 16 GB. */
|
|
||||||
function jvmMaxHeapArg(): string {
|
function jvmMaxHeapArg(): string {
|
||||||
const totalGb = os.totalmem() / (1024 ** 3);
|
const totalGb = os.totalmem() / (1024 ** 3);
|
||||||
const heapGb = Math.max(1, Math.min(Math.floor(totalGb - 4), 16));
|
const heapGb = Math.max(1, Math.min(Math.floor(totalGb - 4), 16));
|
||||||
@ -1272,21 +1227,15 @@ function lowerExtractProcessPriority(childPid: number | undefined, cpuPriority?:
|
|||||||
try {
|
try {
|
||||||
os.setPriority(pid, extractOsPriority(cpuPriority));
|
os.setPriority(pid, extractOsPriority(cpuPriority));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore: priority lowering is best-effort
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 11 — Prozess-Ausführung (spawn, kill, progress parsing)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
||||||
const pid = Number(child.pid || 0);
|
const pid = Number(child.pid || 0);
|
||||||
if (!Number.isFinite(pid) || pid <= 0) {
|
if (!Number.isFinite(pid) || pid <= 0) {
|
||||||
try {
|
try {
|
||||||
child.kill();
|
child.kill();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1301,14 +1250,12 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
|||||||
try {
|
try {
|
||||||
child.kill();
|
child.kill();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
child.kill();
|
child.kill();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -1317,7 +1264,6 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
|||||||
try {
|
try {
|
||||||
child.kill();
|
child.kill();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1474,10 +1420,6 @@ function runExtractCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 12 — JVM Backend & Daemon
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
let cachedJvmLayout: JvmExtractorLayout | null | undefined;
|
let cachedJvmLayout: JvmExtractorLayout | null | undefined;
|
||||||
let cachedJvmLayoutNullSince = 0;
|
let cachedJvmLayoutNullSince = 0;
|
||||||
const JVM_LAYOUT_NULL_TTL_MS = 5 * 60 * 1000;
|
const JVM_LAYOUT_NULL_TTL_MS = 5 * 60 * 1000;
|
||||||
@ -1599,10 +1541,6 @@ function parseJvmLine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistent JVM Daemon ──
|
|
||||||
// Keeps a single JVM process alive across multiple extraction requests,
|
|
||||||
// eliminating the ~5s JVM boot overhead per archive.
|
|
||||||
|
|
||||||
let daemonProcess: ChildProcess | null = null;
|
let daemonProcess: ChildProcess | null = null;
|
||||||
let daemonReady = false;
|
let daemonReady = false;
|
||||||
let daemonBusy = false;
|
let daemonBusy = false;
|
||||||
@ -1616,8 +1554,8 @@ let daemonLayout: JvmExtractorLayout | null = null;
|
|||||||
|
|
||||||
export function shutdownDaemon(): void {
|
export function shutdownDaemon(): void {
|
||||||
if (daemonProcess) {
|
if (daemonProcess) {
|
||||||
try { daemonProcess.stdin?.end(); } catch { /* ignore */ }
|
try { daemonProcess.stdin?.end(); } catch { }
|
||||||
try { killProcessTree(daemonProcess); } catch { /* ignore */ }
|
try { killProcessTree(daemonProcess); } catch { }
|
||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
}
|
}
|
||||||
daemonReady = false;
|
daemonReady = false;
|
||||||
@ -1793,7 +1731,6 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
|
|||||||
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
|
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Clean up tmp dir
|
|
||||||
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
|
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
|
||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
daemonReady = false;
|
daemonReady = false;
|
||||||
@ -1816,7 +1753,6 @@ function isDaemonAvailable(layout: JvmExtractorLayout): boolean {
|
|||||||
return Boolean(daemonProcess && daemonReady && !daemonBusy);
|
return Boolean(daemonProcess && daemonReady && !daemonBusy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wait for the daemon to become ready (boot phase) or free (busy phase), with timeout. */
|
|
||||||
function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> {
|
function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@ -1929,14 +1865,12 @@ async function runJvmExtractCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try persistent daemon first — saves ~5s JVM boot per archive
|
|
||||||
if (isDaemonAvailable(layout)) {
|
if (isDaemonAvailable(layout)) {
|
||||||
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
||||||
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
|
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
|
||||||
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
|
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daemon exists but is still booting or busy — wait up to 15s for it
|
|
||||||
if (daemonProcess) {
|
if (daemonProcess) {
|
||||||
const reason = !daemonReady ? "booting" : "busy";
|
const reason = !daemonReady ? "booting" : "busy";
|
||||||
const waitStartedAt = Date.now();
|
const waitStartedAt = Date.now();
|
||||||
@ -1951,7 +1885,6 @@ async function runJvmExtractCommand(
|
|||||||
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
|
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: spawn a new JVM process (daemon not available after waiting)
|
|
||||||
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`);
|
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`);
|
||||||
|
|
||||||
const mode = effectiveConflictMode(conflictMode);
|
const mode = effectiveConflictMode(conflictMode);
|
||||||
@ -2120,10 +2053,6 @@ async function runJvmExtractCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 13 — Legacy Extraction (buildExternalExtractArgs, runExternalExtract*)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export function buildExternalExtractArgs(
|
export function buildExternalExtractArgs(
|
||||||
command: string,
|
command: string,
|
||||||
archivePath: string,
|
archivePath: string,
|
||||||
@ -2150,7 +2079,6 @@ export function buildExternalExtractArgs(
|
|||||||
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
|
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delay helper for extraction retries
|
|
||||||
const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
async function runExternalExtractInner(
|
async function runExternalExtractInner(
|
||||||
@ -2184,7 +2112,6 @@ async function runExternalExtractInner(
|
|||||||
let createErrorText = "";
|
let createErrorText = "";
|
||||||
let createErrorPassword = "";
|
let createErrorPassword = "";
|
||||||
|
|
||||||
// Skip normal extraction loop if flat mode is already known to be needed for this package
|
|
||||||
if (forceFlatMode) {
|
if (forceFlatMode) {
|
||||||
logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
||||||
onLog?.("INFO", `Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
onLog?.("INFO", `Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
||||||
@ -2301,8 +2228,6 @@ async function runExternalExtractInner(
|
|||||||
lastError = result.errorText;
|
lastError = result.errorText;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some archives store internal paths with a leading \, causing invalid \\ paths.
|
|
||||||
// Retry in flat mode ("e" instead of "x") which strips all archive paths.
|
|
||||||
const pathCreateError = createErrorText || (lastError.includes("Cannot create") ? lastError : "");
|
const pathCreateError = createErrorText || (lastError.includes("Cannot create") ? lastError : "");
|
||||||
if (pathCreateError) {
|
if (pathCreateError) {
|
||||||
const flatPasswords = createErrorPassword
|
const flatPasswords = createErrorPassword
|
||||||
@ -2426,7 +2351,6 @@ async function runExternalExtract(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a short drive mapping for legacy native extractors on Windows.
|
|
||||||
subst = createSubstMapping(targetDir);
|
subst = createSubstMapping(targetDir);
|
||||||
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
|
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
|
||||||
if (subst) {
|
if (subst) {
|
||||||
@ -2479,7 +2403,6 @@ async function runExternalExtract(
|
|||||||
const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password";
|
const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password";
|
||||||
let finalLegacyError: Error;
|
let finalLegacyError: Error;
|
||||||
|
|
||||||
// Retry once after a short delay to let Windows flush freshly completed archive parts.
|
|
||||||
if (isCrcOrWrongPw && !signal?.aborted) {
|
if (isCrcOrWrongPw && !signal?.aborted) {
|
||||||
const retryDelayMs = 2500;
|
const retryDelayMs = 2500;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@ -2605,10 +2528,6 @@ async function runExternalExtract(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 14 – ZIP Extraction (AdmZip)
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function isZipSafetyGuardError(error: unknown): boolean {
|
function isZipSafetyGuardError(error: unknown): boolean {
|
||||||
const text = String(error || "").toLowerCase();
|
const text = String(error || "").toLowerCase();
|
||||||
return text.includes("path traversal")
|
return text.includes("path traversal")
|
||||||
@ -2697,9 +2616,6 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
|
|||||||
let outputKey = pathSetKey(outputPath);
|
let outputKey = pathSetKey(outputPath);
|
||||||
|
|
||||||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
// TOCTOU note: There is a small race between access and writeFile below.
|
|
||||||
// This is acceptable here because zip extraction is single-threaded and we need
|
|
||||||
// the exists check to implement skip/rename conflict resolution semantics.
|
|
||||||
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
|
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
|
||||||
if (outputExists) {
|
if (outputExists) {
|
||||||
if (mode === "skip") {
|
if (mode === "skip") {
|
||||||
@ -2749,10 +2665,6 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 15 – Disk Space, Timeout & Memory Limits
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
async function estimateArchivesTotalBytes(candidates: string[]): Promise<number> {
|
async function estimateArchivesTotalBytes(candidates: string[]): Promise<number> {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const archivePath of candidates) {
|
for (const archivePath of candidates) {
|
||||||
@ -2760,7 +2672,7 @@ async function estimateArchivesTotalBytes(candidates: string[]): Promise<number>
|
|||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
try {
|
try {
|
||||||
total += (await fs.promises.stat(part)).size;
|
total += (await fs.promises.stat(part)).size;
|
||||||
} catch { /* missing part, ignore */ }
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
@ -2823,7 +2735,6 @@ async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
|
|||||||
try {
|
try {
|
||||||
totalBytes += (await fs.promises.stat(filePath)).size;
|
totalBytes += (await fs.promises.stat(filePath)).size;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing parts
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (totalBytes <= 0) {
|
if (totalBytes <= 0) {
|
||||||
@ -2837,10 +2748,6 @@ async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 16 – Resume State
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function extractProgressFilePath(packageDir: string, packageId?: string): string {
|
function extractProgressFilePath(packageDir: string, packageId?: string): string {
|
||||||
if (packageId) {
|
if (packageId) {
|
||||||
return path.join(packageDir, `.rd_extract_progress_${packageId}.json`);
|
return path.join(packageDir, `.rd_extract_progress_${packageId}.json`);
|
||||||
@ -2876,7 +2783,6 @@ async function writeExtractResumeState(packageDir: string, completedArchives: Se
|
|||||||
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
|
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
|
||||||
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
||||||
await fs.promises.rename(tmpPath, progressPath).catch(async () => {
|
await fs.promises.rename(tmpPath, progressPath).catch(async () => {
|
||||||
// rename may fail if another writer renamed tmpPath first (parallel workers)
|
|
||||||
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
|
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -2888,14 +2794,9 @@ export async function clearExtractResumeState(packageDir: string, packageId?: st
|
|||||||
try {
|
try {
|
||||||
await fs.promises.rm(extractProgressFilePath(packageDir, packageId), { force: true });
|
await fs.promises.rm(extractProgressFilePath(packageDir, packageId), { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 17 – Progress & Conflict Helpers
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function emitExtractLog(
|
function emitExtractLog(
|
||||||
onLog: ExtractOptions["onLog"] | undefined,
|
onLog: ExtractOptions["onLog"] | undefined,
|
||||||
level: "INFO" | "WARN" | "ERROR",
|
level: "INFO" | "WARN" | "ERROR",
|
||||||
@ -2921,10 +2822,6 @@ function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip"
|
|||||||
return "skip";
|
return "skip";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 18 – extractPackageArchives (Orchestrierung)
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
|
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
|
||||||
if (options.signal?.aborted) {
|
if (options.signal?.aborted) {
|
||||||
throw new Error("aborted:extract");
|
throw new Error("aborted:extract");
|
||||||
@ -2940,12 +2837,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||||
options.onLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
options.onLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||||
|
|
||||||
// Disk space pre-check
|
|
||||||
if (candidates.length > 0) {
|
if (candidates.length > 0) {
|
||||||
options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" });
|
options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" });
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(options.targetDir, { recursive: true });
|
await fs.promises.mkdir(options.targetDir, { recursive: true });
|
||||||
} catch { /* ignore */ }
|
} catch { }
|
||||||
await checkDiskSpaceForExtraction(options.targetDir, candidates);
|
await checkDiskSpaceForExtraction(options.targetDir, candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2987,6 +2883,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
const resumeCompletedAtStart = resumeCompleted.size;
|
const resumeCompletedAtStart = resumeCompleted.size;
|
||||||
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
|
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
|
||||||
for (const archiveName of Array.from(resumeCompleted.values())) {
|
for (const archiveName of Array.from(resumeCompleted.values())) {
|
||||||
|
// Nested-archive progress (keyed "nested:<name>") has no top-level candidate on
|
||||||
|
// disk to validate against, so it must NOT be pruned here — otherwise every
|
||||||
|
// extractPackageArchives call wiped it and nested archives were re-extracted on
|
||||||
|
// resume. It is cleared together with the rest once the package fully completes.
|
||||||
|
if (archiveName.startsWith("nested:")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!allCandidateNames.has(archiveName)) {
|
if (!allCandidateNames.has(archiveName)) {
|
||||||
resumeCompleted.delete(archiveName);
|
resumeCompleted.delete(archiveName);
|
||||||
}
|
}
|
||||||
@ -3067,9 +2970,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
|
|
||||||
emitProgress(extracted, "", "extracting");
|
emitProgress(extracted, "", "extracting");
|
||||||
|
|
||||||
// Emit "done" progress for archives already completed via resume state
|
|
||||||
// so the caller's onProgress handler can mark their items as "Done" immediately
|
|
||||||
// rather than leaving them as "Entpacken - Ausstehend" until all extraction finishes.
|
|
||||||
for (const archivePath of candidates) {
|
for (const archivePath of candidates) {
|
||||||
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
||||||
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true });
|
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true });
|
||||||
@ -3102,8 +3002,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
}, 1100);
|
}, 1100);
|
||||||
const hybrid = Boolean(options.hybridMode);
|
const hybrid = Boolean(options.hybridMode);
|
||||||
// Before the first successful extraction, filename-derived candidates are useful.
|
|
||||||
// After a known password is learned, try that first to avoid per-archive delays.
|
|
||||||
const filenamePasswords = archiveFilenamePasswords(archiveName);
|
const filenamePasswords = archiveFilenamePasswords(archiveName);
|
||||||
const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== "");
|
const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== "");
|
||||||
const orderedNonEmpty = learnedPassword
|
const orderedNonEmpty = learnedPassword
|
||||||
@ -3121,7 +3019,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate generic .001 splits via file signature before attempting extraction
|
|
||||||
const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName);
|
const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName);
|
||||||
if (isGenericSplit) {
|
if (isGenericSplit) {
|
||||||
const sig = await detectArchiveSignature(archivePath);
|
const sig = await detectArchiveSignature(archivePath);
|
||||||
@ -3156,7 +3053,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
// Set module-level priority before each extract call (race-safe: spawn is synchronous)
|
|
||||||
currentExtractCpuPriority = options.extractCpuPriority;
|
currentExtractCpuPriority = options.extractCpuPriority;
|
||||||
const ext = path.extname(archivePath).toLowerCase();
|
const ext = path.extname(archivePath).toLowerCase();
|
||||||
if (ext === ".zip") {
|
if (ext === ".zip") {
|
||||||
@ -3268,7 +3164,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
if (options.signal?.aborted || noExtractorEncountered) break;
|
if (options.signal?.aborted || noExtractorEncountered) break;
|
||||||
await extractSingleArchive(archivePath);
|
await extractSingleArchive(archivePath);
|
||||||
}
|
}
|
||||||
// Count remaining archives as failed when no extractor was found
|
|
||||||
if (noExtractorEncountered) {
|
if (noExtractorEncountered) {
|
||||||
const remaining = candidates.length - (extracted + failed);
|
const remaining = candidates.length - (extracted + failed);
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
@ -3277,8 +3172,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Password discovery: extract first archive serially to find the correct password,
|
|
||||||
// then run remaining archives in parallel with the promoted password order.
|
|
||||||
let parallelQueue = pendingCandidates;
|
let parallelQueue = pendingCandidates;
|
||||||
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
|
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
|
||||||
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
||||||
@ -3289,7 +3182,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errText = String(err);
|
const errText = String(err);
|
||||||
if (/aborted:extract/i.test(errText)) throw err;
|
if (/aborted:extract/i.test(errText)) throw err;
|
||||||
// noextractor:skipped — handled by noExtractorEncountered flag below
|
|
||||||
}
|
}
|
||||||
parallelQueue = pendingCandidates.slice(1);
|
parallelQueue = pendingCandidates.slice(1);
|
||||||
if (parallelQueue.length > 0) {
|
if (parallelQueue.length > 0) {
|
||||||
@ -3298,7 +3190,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parallelQueue.length > 0 && !options.signal?.aborted && !noExtractorEncountered) {
|
if (parallelQueue.length > 0 && !options.signal?.aborted && !noExtractorEncountered) {
|
||||||
// Parallel extraction pool: N workers pull from a shared queue
|
|
||||||
const queue = [...parallelQueue];
|
const queue = [...parallelQueue];
|
||||||
let nextIdx = 0;
|
let nextIdx = 0;
|
||||||
let abortError: Error | null = null;
|
let abortError: Error | null = null;
|
||||||
@ -3313,24 +3204,20 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errText = String(error);
|
const errText = String(error);
|
||||||
if (errText.includes("noextractor:skipped")) {
|
if (errText.includes("noextractor:skipped")) {
|
||||||
break; // handled by noExtractorEncountered flag after the pool
|
break;
|
||||||
}
|
}
|
||||||
if (isExtractAbortError(errText)) {
|
if (isExtractAbortError(errText)) {
|
||||||
abortError = error instanceof Error ? error : new Error(errText);
|
abortError = error instanceof Error ? error : new Error(errText);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Non-abort errors are already handled inside extractSingleArchive
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const workerCount = Math.min(maxParallel, parallelQueue.length);
|
const workerCount = Math.min(maxParallel, parallelQueue.length);
|
||||||
logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`);
|
logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`);
|
||||||
// Snapshot passwordCandidates before parallel extraction to avoid concurrent mutation.
|
|
||||||
// Each worker reads the same promoted order from the serial password-discovery pass.
|
|
||||||
const frozenPasswords = [...passwordCandidates];
|
const frozenPasswords = [...passwordCandidates];
|
||||||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||||
// Restore passwordCandidates from frozen snapshot (parallel mutations are discarded).
|
|
||||||
passwordCandidates = frozenPasswords;
|
passwordCandidates = frozenPasswords;
|
||||||
|
|
||||||
if (abortError) throw new Error("aborted:extract");
|
if (abortError) throw new Error("aborted:extract");
|
||||||
@ -3362,11 +3249,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Retry failed wrong_password archives serially ──
|
|
||||||
// Parallel UnRAR processes writing to the same target directory can cause
|
|
||||||
// CRC mismatches that are misreported as "Incorrect password".
|
|
||||||
// If any archive succeeded (i.e. the password is known), retry the failed
|
|
||||||
// ones one-at-a-time to eliminate false positives from I/O contention.
|
|
||||||
if (failed > 0 && extracted > 0) {
|
if (failed > 0 && extracted > 0) {
|
||||||
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
|
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
|
||||||
if (failedArchives.length > 0) {
|
if (failedArchives.length > 0) {
|
||||||
@ -3375,14 +3257,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
for (const archivePath of failedArchives) {
|
for (const archivePath of failedArchives) {
|
||||||
if (options.signal?.aborted || noExtractorEncountered) break;
|
if (options.signal?.aborted || noExtractorEncountered) break;
|
||||||
try {
|
try {
|
||||||
// Reset failed count for this archive before retry
|
|
||||||
failed -= 1;
|
failed -= 1;
|
||||||
await extractSingleArchive(archivePath);
|
await extractSingleArchive(archivePath);
|
||||||
retryRecovered += 1;
|
retryRecovered += 1;
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
const errText = String(retryError);
|
const errText = String(retryError);
|
||||||
if (isExtractAbortError(errText)) throw retryError;
|
if (isExtractAbortError(errText)) throw retryError;
|
||||||
// extractSingleArchive already incremented failed and logged the error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (retryRecovered > 0) {
|
if (retryRecovered > 0) {
|
||||||
@ -3401,7 +3281,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Nested extraction: extract archives found inside the output (1 level) ──
|
|
||||||
if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) {
|
if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) {
|
||||||
try {
|
try {
|
||||||
const nestedCandidates = (await findArchiveCandidates(options.targetDir))
|
const nestedCandidates = (await findArchiveCandidates(options.targetDir))
|
||||||
@ -3530,7 +3409,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
await fs.promises.rm(options.targetDir, { recursive: true, force: true });
|
await fs.promises.rm(options.targetDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
56
src/main/fs-error.ts
Normal file
56
src/main/fs-error.ts
Normal 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 ?? "");
|
||||||
|
}
|
||||||
@ -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 crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
@ -93,7 +94,6 @@ function flushPending(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, chunk, "utf8");
|
fs.appendFileSync(logPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -123,11 +123,9 @@ async function cleanupOldItemLogs(dir: string): Promise<void> {
|
|||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore locked/missing files
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing dir
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,7 +164,7 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
|
|||||||
}
|
}
|
||||||
if (!initializedThisProcess.has(normalizedItemId)) {
|
if (!initializedThisProcess.has(normalizedItemId)) {
|
||||||
initializedThisProcess.add(normalizedItemId);
|
initializedThisProcess.add(normalizedItemId);
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = logTimestamp();
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
logPath,
|
||||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
||||||
@ -174,7 +172,7 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
|
|||||||
);
|
);
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
logPath,
|
||||||
`${new Date().toISOString()} [INFO] Item-Kontext initialisiert${formatFields({
|
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
|
||||||
packageId: meta.packageId,
|
packageId: meta.packageId,
|
||||||
packageName: meta.packageName,
|
packageName: meta.packageName,
|
||||||
fileName: meta.fileName,
|
fileName: meta.fileName,
|
||||||
@ -199,7 +197,7 @@ export function logItemEvent(
|
|||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const line = `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`;
|
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||||
appendLine(itemId, line);
|
appendLine(itemId, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,9 +221,8 @@ export function shutdownItemLogs(): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, `=== Item-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
|
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pendingLinesByItem.clear();
|
pendingLinesByItem.clear();
|
||||||
|
|||||||
11
src/main/log-timestamp.ts
Normal file
11
src/main/log-timestamp.ts
Normal 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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -69,7 +87,6 @@ function writeStderr(text: string): void {
|
|||||||
try {
|
try {
|
||||||
process.stderr.write(text);
|
process.stderr.write(text);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore stderr failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,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 {
|
||||||
@ -186,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;
|
||||||
@ -207,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;
|
||||||
|
|
||||||
|
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
||||||
|
// "what failed recently" is answerable even after the file rotates.
|
||||||
|
if (level === "ERROR" || level === "WARN") {
|
||||||
|
recordRecentError(level, message, ts);
|
||||||
|
}
|
||||||
|
|
||||||
for (const listener of logListeners) {
|
for (const listener of logListeners) {
|
||||||
try { listener(line); } catch { /* ignore */ }
|
try { listener(line); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||||
@ -233,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)
|
||||||
|
|||||||
124
src/main/main.ts
124
src/main/main.ts
@ -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`);
|
||||||
@ -44,19 +43,23 @@ 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;
|
||||||
@ -113,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();
|
||||||
@ -127,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 {
|
||||||
@ -137,9 +184,6 @@ function createTray(): void {
|
|||||||
try {
|
try {
|
||||||
tray = new Tray(iconPath);
|
tray = new Tray(iconPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fails on headless servers / Windows Service / RDP-disconnected sessions.
|
|
||||||
// Log so a user running on a non-Administrator/headless server can see
|
|
||||||
// why minimize-to-tray doesn't work, instead of getting an inaccessible window.
|
|
||||||
logger.warn(`Tray-Icon konnte nicht erstellt werden (Headless/RDP/Service?): ${String(error)} - Minimize-to-Tray steht nicht zur Verfuegung, Fenster bleibt sichtbar.`);
|
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;
|
||||||
}
|
}
|
||||||
@ -282,7 +326,6 @@ function registerIpcHandlers(): void {
|
|||||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
updateTray();
|
updateTray();
|
||||||
// Manage scheduled-start timer
|
|
||||||
if (scheduledStartTimer !== null) {
|
if (scheduledStartTimer !== null) {
|
||||||
clearTimeout(scheduledStartTimer);
|
clearTimeout(scheduledStartTimer);
|
||||||
scheduledStartTimer = null;
|
scheduledStartTimer = null;
|
||||||
@ -291,7 +334,6 @@ function registerIpcHandlers(): void {
|
|||||||
if (schedMs > 0) {
|
if (schedMs > 0) {
|
||||||
const delay = schedMs - Date.now();
|
const delay = schedMs - Date.now();
|
||||||
if (delay <= 0) {
|
if (delay <= 0) {
|
||||||
// Time already passed — start immediately and clear setting
|
|
||||||
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
|
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
|
||||||
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
||||||
} else {
|
} else {
|
||||||
@ -345,7 +387,6 @@ function registerIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
||||||
ipcMain.handle(IPC_CHANNELS.START, () => {
|
ipcMain.handle(IPC_CHANNELS.START, () => {
|
||||||
// Cancel any pending scheduled start when the user starts manually
|
|
||||||
if (scheduledStartTimer !== null) {
|
if (scheduledStartTimer !== null) {
|
||||||
clearTimeout(scheduledStartTimer);
|
clearTimeout(scheduledStartTimer);
|
||||||
scheduledStartTimer = null;
|
scheduledStartTimer = null;
|
||||||
@ -644,6 +685,14 @@ function registerIpcHandlers(): void {
|
|||||||
return controller.getDebridLinkHostLimits();
|
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">,
|
||||||
@ -664,7 +713,25 @@ function registerIpcHandlers(): void {
|
|||||||
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 data = await fs.promises.readFile(filePath);
|
const data = await fs.promises.readFile(filePath);
|
||||||
return controller.importBackup(data);
|
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) => {
|
||||||
@ -675,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
129
src/main/mega-public-api.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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);
|
if (!generated) {
|
||||||
}
|
this.sessions.delete(key);
|
||||||
|
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||||
const generated = await this.generate(link, overallSignal);
|
generated = await this.generate(link, cookie, overallSignal);
|
||||||
if (!generated) {
|
if (!generated) {
|
||||||
this.cookie = "";
|
|
||||||
await this.login(creds.login, creds.password, overallSignal);
|
|
||||||
const retry = await this.generate(link, overallSignal);
|
|
||||||
if (!retry) {
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
@ -92,7 +93,6 @@ function flushPending(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, chunk, "utf8");
|
fs.appendFileSync(logPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,11 +122,9 @@ async function cleanupOldPackageLogs(dir: string): Promise<void> {
|
|||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore locked/missing files
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing dir
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,7 +163,7 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
|||||||
}
|
}
|
||||||
if (!initializedThisProcess.has(normalizedPackageId)) {
|
if (!initializedThisProcess.has(normalizedPackageId)) {
|
||||||
initializedThisProcess.add(normalizedPackageId);
|
initializedThisProcess.add(normalizedPackageId);
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = logTimestamp();
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
logPath,
|
||||||
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
|
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
|
||||||
@ -173,7 +171,7 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
|||||||
);
|
);
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
logPath,
|
||||||
`${new Date().toISOString()} [INFO] Paket-Kontext initialisiert${formatFields({
|
`${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({
|
||||||
name: meta.name,
|
name: meta.name,
|
||||||
outputDir: meta.outputDir,
|
outputDir: meta.outputDir,
|
||||||
extractDir: meta.extractDir
|
extractDir: meta.extractDir
|
||||||
@ -197,7 +195,7 @@ export function logPackageEvent(
|
|||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const line = `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`;
|
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||||
appendLine(packageId, line);
|
appendLine(packageId, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,9 +219,8 @@ export function shutdownPackageLogs(): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
|
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pendingLinesByPackage.clear();
|
pendingLinesByPackage.clear();
|
||||||
|
|||||||
@ -158,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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -320,7 +318,6 @@ export class RealDebridWebFallback {
|
|||||||
return this.rememberToken(token);
|
return this.rememberToken(token);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore window scraping errors and fall back to session fetch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -330,14 +327,12 @@ export class RealDebridWebFallback {
|
|||||||
try {
|
try {
|
||||||
await this.extractApiTokenFromWindow(window);
|
await this.extractApiTokenFromWindow(window);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore best-effort token warmup failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@ -399,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" };
|
||||||
|
|||||||
@ -82,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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
type RenameLogLevel = "INFO" | "WARN" | "ERROR";
|
type RenameLogLevel = "INFO" | "WARN" | "ERROR";
|
||||||
@ -45,11 +46,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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +61,6 @@ function cleanupOldBackup(filePath: string): void {
|
|||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +76,7 @@ export function initRenameLog(baseDir: string): void {
|
|||||||
if (!fs.existsSync(renameLogPath)) {
|
if (!fs.existsSync(renameLogPath)) {
|
||||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
fs.writeFileSync(renameLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
|
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
renameLogPath = null;
|
renameLogPath = null;
|
||||||
}
|
}
|
||||||
@ -95,11 +93,10 @@ export function logRenameEvent(level: RenameLogLevel, message: string, fields?:
|
|||||||
}
|
}
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
renameLogPath,
|
renameLogPath,
|
||||||
`${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`,
|
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,9 +112,8 @@ export function shutdownRenameLog(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
|
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
renameLogPath = null;
|
renameLogPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -5,17 +5,6 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
|||||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||||
import { StoragePaths } from "./storage";
|
import { StoragePaths } from "./storage";
|
||||||
|
|
||||||
/** Startup Health-Check: runs once at app boot and surfaces potential problem
|
|
||||||
* states BEFORE the user hits them mid-download.
|
|
||||||
*
|
|
||||||
* Goals:
|
|
||||||
* - Warn on missing / unreachable download directory
|
|
||||||
* - Warn on low disk space (< 5 GB free)
|
|
||||||
* - Warn when no debrid provider is configured (app is effectively offline)
|
|
||||||
* - Warn when state file is suspiciously large (>50 MB → pruning recommended)
|
|
||||||
*
|
|
||||||
* Non-goals: blocking startup. The check only logs — the app continues. */
|
|
||||||
|
|
||||||
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
export interface HealthCheckFinding {
|
export interface HealthCheckFinding {
|
||||||
@ -32,8 +21,8 @@ export interface HealthCheckReport {
|
|||||||
infoCount: number;
|
infoCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
|
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
|
||||||
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; // 50 MB
|
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
function safeExists(p: string): boolean {
|
function safeExists(p: string): boolean {
|
||||||
try {
|
try {
|
||||||
@ -52,9 +41,6 @@ function getFileSizeBytes(p: string): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Attempt a tiny write-probe in the given directory. Returns true on
|
|
||||||
* success, false if the directory isn't writable. We write and immediately
|
|
||||||
* delete a uniquely-named temp file so we never leave garbage behind. */
|
|
||||||
function isWritable(dir: string): boolean {
|
function isWritable(dir: string): boolean {
|
||||||
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||||
try {
|
try {
|
||||||
@ -66,12 +52,8 @@ function isWritable(dir: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Query free disk space for a given path. Returns null if unsupported or
|
|
||||||
* the query fails — callers treat null as "unknown" and skip the check. */
|
|
||||||
function getFreeDiskSpaceBytes(target: string): number | null {
|
function getFreeDiskSpaceBytes(target: string): number | null {
|
||||||
try {
|
try {
|
||||||
// fs.statfsSync is available on Node 18.15+; on Windows it still maps to
|
|
||||||
// the underlying volume so it works for download dirs on any drive.
|
|
||||||
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
||||||
if (typeof statfs !== "function") {
|
if (typeof statfs !== "function") {
|
||||||
return null;
|
return null;
|
||||||
@ -123,12 +105,9 @@ function countConfiguredProviders(settings: AppSettings): { count: number; provi
|
|||||||
return { count: providers.length, providers };
|
return { count: providers.length, providers };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pure check function: takes inputs, returns findings. Kept side-effect-free
|
|
||||||
* so it's trivial to unit-test — the caller handles logging / persistence. */
|
|
||||||
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
||||||
const findings: HealthCheckFinding[] = [];
|
const findings: HealthCheckFinding[] = [];
|
||||||
|
|
||||||
// ── 1. Download directory ───────────────────────────────────────────────
|
|
||||||
const outputDir = String(settings.outputDir || "").trim();
|
const outputDir = String(settings.outputDir || "").trim();
|
||||||
if (!outputDir) {
|
if (!outputDir) {
|
||||||
findings.push({
|
findings.push({
|
||||||
@ -152,7 +131,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
|||||||
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Check available disk space only when the directory is actually usable
|
|
||||||
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
||||||
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
||||||
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
||||||
@ -165,7 +143,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Provider-Credentials ─────────────────────────────────────────────
|
|
||||||
const { count, providers } = countConfiguredProviders(settings);
|
const { count, providers } = countConfiguredProviders(settings);
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
findings.push({
|
findings.push({
|
||||||
@ -182,7 +159,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. State-File-Groesse ──────────────────────────────────────────────
|
|
||||||
if (safeExists(storagePaths.sessionFile)) {
|
if (safeExists(storagePaths.sessionFile)) {
|
||||||
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
||||||
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
||||||
@ -196,7 +172,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. Storage-Basis-Verzeichnis muss beschreibbar sein (fuer Logs) ────
|
|
||||||
if (!safeExists(storagePaths.baseDir)) {
|
if (!safeExists(storagePaths.baseDir)) {
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "ERROR",
|
severity: "ERROR",
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import fsp from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts";
|
||||||
import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
import { AppSettings, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
||||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||||
import { defaultSettings } from "./constants";
|
import { defaultSettings } from "./constants";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
@ -120,7 +120,6 @@ 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");
|
||||||
}
|
}
|
||||||
@ -230,6 +229,39 @@ function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Re
|
|||||||
return result;
|
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[] {
|
function normalizeStringList(raw: unknown, allowedKeys: readonly string[]): string[] {
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
return [];
|
return [];
|
||||||
@ -276,7 +308,6 @@ function normalizeProviderOrder(
|
|||||||
if (Array.isArray(raw) && raw.length > 0) {
|
if (Array.isArray(raw) && raw.length > 0) {
|
||||||
list = raw;
|
list = raw;
|
||||||
} else {
|
} else {
|
||||||
// Migrate from old primary/secondary/tertiary
|
|
||||||
const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter(
|
const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter(
|
||||||
(v) => v && String(v).trim() && String(v).trim() !== "none"
|
(v) => v && String(v).trim() && String(v).trim() !== "none"
|
||||||
);
|
);
|
||||||
@ -314,7 +345,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
const currentUsageDay = getProviderUsageDayKey();
|
const currentUsageDay = getProviderUsageDayKey();
|
||||||
const megaLogin = asText(settings.megaLogin);
|
const megaLogin = asText(settings.megaLogin);
|
||||||
const megaPassword = asText(settings.megaPassword);
|
const megaPassword = asText(settings.megaPassword);
|
||||||
// Migrate legacy single-account to multi-account format
|
|
||||||
let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
|
let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
|
||||||
if (!megaCredentials && megaLogin && megaPassword) {
|
if (!megaCredentials && megaLogin && megaPassword) {
|
||||||
megaCredentials = `${megaLogin}:${megaPassword}`;
|
megaCredentials = `${megaLogin}:${megaPassword}`;
|
||||||
@ -391,6 +421,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
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),
|
||||||
@ -426,6 +458,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
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,
|
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,
|
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
|
||||||
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
||||||
@ -452,6 +485,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
|
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
|
||||||
: {},
|
: {},
|
||||||
megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds),
|
megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds),
|
||||||
|
debridAccountStatuses: normalizeDebridAccountStatuses(settings.debridAccountStatuses, megaDebridAccountIds, debridLinkApiKeyIds),
|
||||||
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
|
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
|
||||||
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
||||||
};
|
};
|
||||||
@ -541,7 +575,6 @@ function ensureBaseDir(baseDir: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** JSON replacer that sanitizes NaN/Infinity to null to prevent file corruption. */
|
|
||||||
function safeJsonReplacer(_key: string, value: unknown): unknown {
|
function safeJsonReplacer(_key: string, value: unknown): unknown {
|
||||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||||
return null;
|
return null;
|
||||||
@ -565,12 +598,8 @@ function readSettingsFile(filePath: string): AppSettings | null {
|
|||||||
});
|
});
|
||||||
return sanitizeCredentialPersistence(merged);
|
return sanitizeCredentialPersistence(merged);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Distinguish permission/access errors from missing/corrupt JSON so a
|
|
||||||
// misconfigured server (e.g. unusual user, restricted AppData) shows a
|
|
||||||
// clear log entry instead of silently falling back to defaults.
|
|
||||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
const code = (error as NodeJS.ErrnoException)?.code || "";
|
||||||
if (code === "ENOENT") {
|
if (code === "ENOENT") {
|
||||||
// file doesn't exist — normal on first run
|
|
||||||
} else if (code === "EACCES" || code === "EPERM") {
|
} 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 || "?"}`);
|
logger.error(`Settings-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
|
||||||
} else {
|
} else {
|
||||||
@ -756,7 +785,6 @@ export function loadSettings(paths: StoragePaths): AppSettings {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@ -787,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)) {
|
||||||
@ -807,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;
|
||||||
|
|
||||||
@ -816,9 +840,6 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
|
|||||||
|
|
||||||
function readSessionFile(filePath: string): SessionState | null {
|
function readSessionFile(filePath: string): SessionState | null {
|
||||||
try {
|
try {
|
||||||
// Inline readFileSync into JSON.parse so the raw string is not bound to a
|
|
||||||
// variable and can be GC'd immediately — avoids holding the full JSON text
|
|
||||||
// and the parsed object graph in memory simultaneously.
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
||||||
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
|
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
|
||||||
const pkgCount = Object.keys(session.packages).length;
|
const pkgCount = Object.keys(session.packages).length;
|
||||||
@ -838,12 +859,10 @@ function readSessionFile(filePath: string): SessionState | 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));
|
||||||
@ -853,7 +872,7 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -920,15 +939,21 @@ 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);
|
||||||
|
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");
|
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
||||||
return emptySession();
|
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;
|
||||||
const backupFile = sessionBackupPath(paths.sessionFile);
|
|
||||||
|
|
||||||
// If primary loaded but is empty, check if backup has packages (safety net)
|
|
||||||
if (primary) {
|
if (primary) {
|
||||||
const primaryPkgCount = Object.keys(primary.packages).length;
|
const primaryPkgCount = Object.keys(primary.packages).length;
|
||||||
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
|
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
|
||||||
@ -943,7 +968,6 @@ export function loadSession(paths: StoragePaths): SessionState {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@ -961,12 +985,10 @@ export function loadSession(paths: StoragePaths): SessionState {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: try to recover from temp files left by interrupted writes
|
|
||||||
for (const kind of ["sync", "async"] as const) {
|
for (const kind of ["sync", "async"] as const) {
|
||||||
const tmpPath = sessionTempPath(paths.sessionFile, kind);
|
const tmpPath = sessionTempPath(paths.sessionFile, kind);
|
||||||
if (fs.existsSync(tmpPath)) {
|
if (fs.existsSync(tmpPath)) {
|
||||||
@ -977,7 +999,6 @@ export function loadSession(paths: StoragePaths): SessionState {
|
|||||||
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
|
||||||
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore restore write failure
|
|
||||||
}
|
}
|
||||||
return tmpSession;
|
return tmpSession;
|
||||||
}
|
}
|
||||||
@ -995,7 +1016,6 @@ 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() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||||
@ -1004,13 +1024,13 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
|
|||||||
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> {
|
||||||
@ -1018,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;
|
||||||
@ -1040,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 {
|
||||||
@ -1056,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1068,8 +1086,9 @@ 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 generation = syncSaveGeneration;
|
||||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
||||||
await saveSessionPayloadAsync(paths, payload);
|
await saveSessionPayloadAsync(paths, payload, generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_HISTORY_ENTRIES = 500;
|
const MAX_HISTORY_ENTRIES = 500;
|
||||||
@ -1127,7 +1146,7 @@ export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void
|
|||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1170,7 +1189,6 @@ export function clearHistory(paths: StoragePaths): void {
|
|||||||
try {
|
try {
|
||||||
fs.unlinkSync(paths.historyFile);
|
fs.unlinkSync(paths.historyFile);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,10 @@ import { APP_VERSION } from "./constants";
|
|||||||
import { getAuditLogPath } from "./audit-log";
|
import { getAuditLogPath } from "./audit-log";
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { getLogFilePath } from "./logger";
|
import { getLogFilePath } from "./logger";
|
||||||
|
import { getRecentErrors } from "./error-ring";
|
||||||
import { getPackageLogPath } from "./package-log";
|
import { getPackageLogPath } from "./package-log";
|
||||||
import { getRenameLogPath } from "./rename-log";
|
import { getRenameLogPath } from "./rename-log";
|
||||||
|
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||||
import { getSessionLogPath } from "./session-log";
|
import { getSessionLogPath } from "./session-log";
|
||||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
||||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
||||||
@ -51,6 +53,26 @@ function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function formatTimestampForFileName(date: Date): string {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
@ -148,6 +170,8 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
|
|||||||
});
|
});
|
||||||
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
||||||
addJson(zip, "overview/trace-config.json", getTraceConfig());
|
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, 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_host.txt"), "runtime/debug_host.txt");
|
||||||
@ -160,13 +184,15 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
|
|||||||
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
||||||
addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
||||||
addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
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, getSessionLogPath(), "logs/session.log");
|
||||||
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
||||||
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
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");
|
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
||||||
addDirectoryIfExists(zip, path.join(baseDir, "package-logs"), "logs/package-logs");
|
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||||
addDirectoryIfExists(zip, path.join(baseDir, "item-logs"), "logs/item-logs");
|
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||||
|
|
||||||
for (const packageId of packageIds) {
|
for (const packageId of packageIds) {
|
||||||
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
||||||
|
|||||||
@ -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 { addLogListener, removeLogListener } from "./logger";
|
import { addLogListener, removeLogListener } from "./logger";
|
||||||
import type { SupportTraceConfig } from "../shared/types";
|
import type { SupportTraceConfig } from "../shared/types";
|
||||||
@ -63,7 +64,6 @@ function flushPending(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,11 +77,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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,7 +92,6 @@ function cleanupOldBackup(filePath: string): void {
|
|||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,7 +159,6 @@ function persistTraceConfig(): void {
|
|||||||
try {
|
try {
|
||||||
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,10 +185,10 @@ function disableTraceDueToExpiry(): void {
|
|||||||
...traceConfig,
|
...traceConfig,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
autoDisableAt: null,
|
autoDisableAt: null,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: logTimestamp()
|
||||||
});
|
});
|
||||||
persistTraceConfig();
|
persistTraceConfig();
|
||||||
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
|
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAutoDisable(): void {
|
function scheduleAutoDisable(): void {
|
||||||
@ -230,7 +226,7 @@ export function initTraceLog(baseDir: string): void {
|
|||||||
}
|
}
|
||||||
traceConfig = loadTraceConfig();
|
traceConfig = loadTraceConfig();
|
||||||
persistTraceConfig();
|
persistTraceConfig();
|
||||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
|
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
traceLogPath = null;
|
traceLogPath = null;
|
||||||
traceConfigPath = null;
|
traceConfigPath = null;
|
||||||
@ -263,11 +259,11 @@ export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTr
|
|||||||
traceConfig = normalizeTraceConfig({
|
traceConfig = normalizeTraceConfig({
|
||||||
...traceConfig,
|
...traceConfig,
|
||||||
...patch,
|
...patch,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: logTimestamp()
|
||||||
});
|
});
|
||||||
persistTraceConfig();
|
persistTraceConfig();
|
||||||
scheduleAutoDisable();
|
scheduleAutoDisable();
|
||||||
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
|
appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
|
||||||
return getTraceConfig();
|
return getTraceConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,7 +272,7 @@ export function setTraceEnabled(enabled: boolean, note = "", durationMs: number
|
|||||||
? new Date(Date.now() + durationMs).toISOString()
|
? new Date(Date.now() + durationMs).toISOString()
|
||||||
: null;
|
: null;
|
||||||
const next = updateTraceConfig({ enabled, autoDisableAt });
|
const next = updateTraceConfig({ enabled, autoDisableAt });
|
||||||
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
|
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,7 +288,7 @@ export function logTraceEvent(
|
|||||||
if (category === "audit" && !traceConfig.includeAudit) {
|
if (category === "audit" && !traceConfig.includeAudit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendTraceLine(`${new Date().toISOString()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
|
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownTraceLog(): void {
|
export function shutdownTraceLog(): void {
|
||||||
@ -307,9 +303,8 @@ export function shutdownTraceLog(): void {
|
|||||||
}
|
}
|
||||||
flushPending();
|
flushPending();
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
|
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
traceLogPath = null;
|
traceLogPath = null;
|
||||||
traceConfigPath = null;
|
traceConfigPath = null;
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import { UpdateCheckResult, UpdateInstallProgress, UpdateInstallResult } from ".
|
|||||||
import { compactErrorText, humanSize } from "./utils";
|
import { compactErrorText, humanSize } from "./utils";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
// ─── Constants ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const RELEASE_FETCH_TIMEOUT_MS = 12_000;
|
const RELEASE_FETCH_TIMEOUT_MS = 12_000;
|
||||||
const CONNECT_TIMEOUT_MS = 30_000;
|
const CONNECT_TIMEOUT_MS = 30_000;
|
||||||
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000;
|
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000;
|
||||||
@ -18,8 +16,6 @@ const RETRY_DELAY_MS = 1_500;
|
|||||||
const MAX_DOWNLOAD_PASSES = 3;
|
const MAX_DOWNLOAD_PASSES = 3;
|
||||||
const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||||
|
|
||||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type UpdateSource = {
|
type UpdateSource = {
|
||||||
name: string;
|
name: string;
|
||||||
webBase: string;
|
webBase: string;
|
||||||
@ -40,8 +36,6 @@ type ExpectedDigest = {
|
|||||||
encoding: "hex" | "base64";
|
encoding: "hex" | "base64";
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Update Sources ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const UPDATE_SOURCES: UpdateSource[] = [
|
const UPDATE_SOURCES: UpdateSource[] = [
|
||||||
{ name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" },
|
{ name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" },
|
||||||
{ name: "codeberg", webBase: "https://codeberg.org", apiBase: "https://codeberg.org/api/v1" },
|
{ name: "codeberg", webBase: "https://codeberg.org", apiBase: "https://codeberg.org/api/v1" },
|
||||||
@ -52,23 +46,16 @@ const PRIMARY_SOURCE = UPDATE_SOURCES[0];
|
|||||||
const WEB_BASE = PRIMARY_SOURCE.webBase;
|
const WEB_BASE = PRIMARY_SOURCE.webBase;
|
||||||
const API_BASE = PRIMARY_SOURCE.apiBase;
|
const API_BASE = PRIMARY_SOURCE.apiBase;
|
||||||
|
|
||||||
// ─── Module State ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let activeAbortController: AbortController | null = null;
|
let activeAbortController: AbortController | null = null;
|
||||||
|
|
||||||
// ─── Progress Helper ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
|
function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
|
||||||
if (!cb) return;
|
if (!cb) return;
|
||||||
try {
|
try {
|
||||||
cb(progress);
|
cb(progress);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore renderer callback errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Version Utilities ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function parseVersionParts(version: string): number[] {
|
export function parseVersionParts(version: string): number[] {
|
||||||
const cleaned = version.replace(/^v/i, "").trim();
|
const cleaned = version.replace(/^v/i, "").trim();
|
||||||
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
|
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
|
||||||
@ -87,8 +74,6 @@ export function isRemoteNewer(currentVersion: string, latestVersion: string): bo
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Repository Normalization ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function isValidRepoPart(value: string): boolean {
|
function isValidRepoPart(value: string): boolean {
|
||||||
const part = String(value || "").trim();
|
const part = String(value || "").trim();
|
||||||
if (!part || part === "." || part === ".." || part.includes("..")) return false;
|
if (!part || part === "." || part === ".." || part.includes("..")) return false;
|
||||||
@ -127,15 +112,12 @@ export function normalizeUpdateRepo(repo: string): string {
|
|||||||
if (result) return result;
|
if (result) return result;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// not a URL, try as plain text
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = extractOwnerRepo(raw);
|
const result = extractOwnerRepo(raw);
|
||||||
return result || DEFAULT_UPDATE_REPO;
|
return result || DEFAULT_UPDATE_REPO;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Network Utilities ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } {
|
function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(new Error(`timeout:${ms}`)), ms);
|
const timer = setTimeout(() => ctrl.abort(new Error(`timeout:${ms}`)), ms);
|
||||||
@ -190,25 +172,18 @@ function getBodyIdleTimeout(): number {
|
|||||||
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
|
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Digest Parsing & Verification ─────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// SHA-256 = 32 bytes → hex: 64 chars, base64: 43-44 chars (+ up to 1 padding =)
|
|
||||||
// SHA-512 = 64 bytes → hex: 128 chars, base64: 86-88 chars (+ up to 2 padding =)
|
|
||||||
|
|
||||||
function normalizeBase64(raw: string): string {
|
function normalizeBase64(raw: string): string {
|
||||||
return String(raw || "")
|
return String(raw || "")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/-/g, "+") // URL-safe → standard
|
.replace(/-/g, "+")
|
||||||
.replace(/_/g, "/") // URL-safe → standard
|
.replace(/_/g, "/")
|
||||||
.replace(/=+$/g, ""); // strip padding for consistent comparison
|
.replace(/=+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
||||||
const text = String(raw || "").trim();
|
const text = String(raw || "").trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
// ── Prefixed: sha256:<value> ──
|
|
||||||
|
|
||||||
const pre256hex = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
|
const pre256hex = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
|
||||||
if (pre256hex) {
|
if (pre256hex) {
|
||||||
return { algorithm: "sha256", digest: pre256hex[1].toLowerCase(), encoding: "hex" };
|
return { algorithm: "sha256", digest: pre256hex[1].toLowerCase(), encoding: "hex" };
|
||||||
@ -219,8 +194,6 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
|||||||
return { algorithm: "sha256", digest: normalizeBase64(pre256b64[1]), encoding: "base64" };
|
return { algorithm: "sha256", digest: normalizeBase64(pre256b64[1]), encoding: "base64" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Prefixed: sha512:<value> ──
|
|
||||||
|
|
||||||
const pre512hex = text.match(/^sha512:([a-fA-F0-9]{128})$/i);
|
const pre512hex = text.match(/^sha512:([a-fA-F0-9]{128})$/i);
|
||||||
if (pre512hex) {
|
if (pre512hex) {
|
||||||
return { algorithm: "sha512", digest: pre512hex[1].toLowerCase(), encoding: "hex" };
|
return { algorithm: "sha512", digest: pre512hex[1].toLowerCase(), encoding: "hex" };
|
||||||
@ -231,8 +204,6 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
|||||||
return { algorithm: "sha512", digest: normalizeBase64(pre512b64[1]), encoding: "base64" };
|
return { algorithm: "sha512", digest: normalizeBase64(pre512b64[1]), encoding: "base64" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plain hex ──
|
|
||||||
|
|
||||||
if (/^[a-fA-F0-9]{64}$/.test(text)) {
|
if (/^[a-fA-F0-9]{64}$/.test(text)) {
|
||||||
return { algorithm: "sha256", digest: text.toLowerCase(), encoding: "hex" };
|
return { algorithm: "sha256", digest: text.toLowerCase(), encoding: "hex" };
|
||||||
}
|
}
|
||||||
@ -240,8 +211,6 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
|||||||
return { algorithm: "sha512", digest: text.toLowerCase(), encoding: "hex" };
|
return { algorithm: "sha512", digest: text.toLowerCase(), encoding: "hex" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plain base64 (SHA-512 first since it's longer → won't accidentally match SHA-256) ──
|
|
||||||
|
|
||||||
const plain512b64 = text.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
const plain512b64 = text.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
||||||
if (plain512b64) {
|
if (plain512b64) {
|
||||||
return { algorithm: "sha512", digest: normalizeBase64(plain512b64[1]), encoding: "base64" };
|
return { algorithm: "sha512", digest: normalizeBase64(plain512b64[1]), encoding: "base64" };
|
||||||
@ -271,8 +240,6 @@ async function hashFile(filePath: string, algorithm: "sha256" | "sha512", encodi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── latest.yml Parsing ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function normalizeNameForMatch(value: string): string {
|
function normalizeNameForMatch(value: string): string {
|
||||||
const name = String(value || "").trim().split(/[\\/]/g).filter(Boolean).pop() || "";
|
const name = String(value || "").trim().split(/[\\/]/g).filter(Boolean).pop() || "";
|
||||||
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
@ -284,10 +251,8 @@ function stripYamlQuotes(raw: string): string {
|
|||||||
|
|
||||||
function extractSha512Value(raw: string): string {
|
function extractSha512Value(raw: string): string {
|
||||||
const stripped = stripYamlQuotes(raw);
|
const stripped = stripYamlQuotes(raw);
|
||||||
// Base64 SHA-512: 86-88 chars + optional padding
|
|
||||||
const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
||||||
if (b64) return b64[1];
|
if (b64) return b64[1];
|
||||||
// Hex SHA-512: exactly 128 hex chars
|
|
||||||
const hex = stripped.match(/^([a-fA-F0-9]{128})$/);
|
const hex = stripped.match(/^([a-fA-F0-9]{128})$/);
|
||||||
if (hex) return hex[1];
|
if (hex) return hex[1];
|
||||||
return "";
|
return "";
|
||||||
@ -305,28 +270,24 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
|
|||||||
for (const rawLine of lines) {
|
for (const rawLine of lines) {
|
||||||
const line = String(rawLine);
|
const line = String(rawLine);
|
||||||
|
|
||||||
// File entry URL (inside files: array)
|
|
||||||
const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i);
|
const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i);
|
||||||
if (fileUrlItem?.[1]) {
|
if (fileUrlItem?.[1]) {
|
||||||
currentFileUrl = stripYamlQuotes(fileUrlItem[1]);
|
currentFileUrl = stripYamlQuotes(fileUrlItem[1]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-level or non-array URL
|
|
||||||
const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i);
|
const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i);
|
||||||
if (urlMatch?.[1]) {
|
if (urlMatch?.[1]) {
|
||||||
currentFileUrl = stripYamlQuotes(urlMatch[1]);
|
currentFileUrl = stripYamlQuotes(urlMatch[1]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-level path
|
|
||||||
const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i);
|
const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i);
|
||||||
if (pathMatch?.[1]) {
|
if (pathMatch?.[1]) {
|
||||||
topLevelPath = stripYamlQuotes(pathMatch[1]);
|
topLevelPath = stripYamlQuotes(pathMatch[1]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SHA-512 value (handles quoted and unquoted)
|
|
||||||
const shaMatch = line.match(/^\s*sha512\s*:\s*(.+)\s*$/i);
|
const shaMatch = line.match(/^\s*sha512\s*:\s*(.+)\s*$/i);
|
||||||
if (!shaMatch?.[1]) continue;
|
if (!shaMatch?.[1]) continue;
|
||||||
|
|
||||||
@ -345,7 +306,6 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
|
|||||||
if (!topLevelSha) topLevelSha = sha;
|
if (!topLevelSha) topLevelSha = sha;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try matching via top-level path
|
|
||||||
if (target && topLevelPath && topLevelSha) {
|
if (target && topLevelPath && topLevelSha) {
|
||||||
if (normalizeNameForMatch(topLevelPath) === target) {
|
if (normalizeNameForMatch(topLevelPath) === target) {
|
||||||
return topLevelSha;
|
return topLevelSha;
|
||||||
@ -355,8 +315,6 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
|
|||||||
return topLevelSha || firstFileSha || "";
|
return topLevelSha || firstFileSha || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Installer Verification ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function verifyBinaryShape(filePath: string): Promise<void> {
|
async function verifyBinaryShape(filePath: string): Promise<void> {
|
||||||
const stats = await fs.promises.stat(filePath);
|
const stats = await fs.promises.stat(filePath);
|
||||||
if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) {
|
if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) {
|
||||||
@ -402,8 +360,6 @@ async function verifyDownloadedInstaller(filePath: string, digestRaw: string): P
|
|||||||
logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`);
|
logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Release API ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function fetchRelease(repo: string, endpoint: string): Promise<{
|
async function fetchRelease(repo: string, endpoint: string): Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
status: number;
|
status: number;
|
||||||
@ -475,8 +431,6 @@ function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: stri
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Download Candidates ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function uniqueStrings(values: string[]): string[] {
|
function uniqueStrings(values: string[]): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
@ -555,8 +509,6 @@ function deriveFileName(check: UpdateCheckResult, url: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Error Classification ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function httpStatusFromError(error: unknown): number {
|
function httpStatusFromError(error: unknown): number {
|
||||||
const match = String(error || "").match(/HTTP\s+(\d{3})/i);
|
const match = String(error || "").match(/HTTP\s+(\d{3})/i);
|
||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
@ -585,8 +537,6 @@ function isIntegrityError(error: unknown): boolean {
|
|||||||
return text.includes("integrit") || text.includes("mismatch");
|
return text.includes("integrit") || text.includes("mismatch");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sleep ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
|
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
if (signal.aborted) throw new Error("aborted:update_shutdown");
|
if (signal.aborted) throw new Error("aborted:update_shutdown");
|
||||||
@ -611,8 +561,6 @@ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Download Engine ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function downloadFile(
|
async function downloadFile(
|
||||||
url: string,
|
url: string,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
@ -623,7 +571,6 @@ async function downloadFile(
|
|||||||
|
|
||||||
logger.info(`Update-Download versucht: ${url}`);
|
logger.info(`Update-Download versucht: ${url}`);
|
||||||
|
|
||||||
// Connect with timeout
|
|
||||||
const tc = timeoutController(CONNECT_TIMEOUT_MS);
|
const tc = timeoutController(CONNECT_TIMEOUT_MS);
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
@ -640,11 +587,9 @@ async function downloadFile(
|
|||||||
throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`);
|
throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse content-length
|
|
||||||
const clRaw = Number(response.headers.get("content-length") || NaN);
|
const clRaw = Number(response.headers.get("content-length") || NaN);
|
||||||
const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null;
|
const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null;
|
||||||
|
|
||||||
// Progress tracking
|
|
||||||
let downloadedBytes = 0;
|
let downloadedBytes = 0;
|
||||||
let lastProgressAt = 0;
|
let lastProgressAt = 0;
|
||||||
|
|
||||||
@ -663,13 +608,11 @@ async function downloadFile(
|
|||||||
|
|
||||||
reportProgress(true);
|
reportProgress(true);
|
||||||
|
|
||||||
// Prepare filesystem
|
|
||||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
const tempPath = `${targetPath}.tmp`;
|
const tempPath = `${targetPath}.tmp`;
|
||||||
const writeStream = fs.createWriteStream(tempPath);
|
const writeStream = fs.createWriteStream(tempPath);
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
|
|
||||||
// Idle timeout tracking
|
|
||||||
const idleMs = getBodyIdleTimeout();
|
const idleMs = getBodyIdleTimeout();
|
||||||
let idleTimer: NodeJS.Timeout | null = null;
|
let idleTimer: NodeJS.Timeout | null = null;
|
||||||
let idleTimedOut = false;
|
let idleTimedOut = false;
|
||||||
@ -691,7 +634,6 @@ async function downloadFile(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stream body to disk
|
|
||||||
try {
|
try {
|
||||||
resetIdle();
|
resetIdle();
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@ -721,25 +663,21 @@ async function downloadFile(
|
|||||||
clearIdle();
|
clearIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush and close write stream
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
writeStream.end(() => resolve());
|
writeStream.end(() => resolve());
|
||||||
writeStream.on("error", reject);
|
writeStream.on("error", reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle idle timeout on clean reader exit
|
|
||||||
if (idleTimedOut) {
|
if (idleTimedOut) {
|
||||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||||
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
|
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify completeness
|
|
||||||
if (totalBytes && downloadedBytes !== totalBytes) {
|
if (totalBytes && downloadedBytes !== totalBytes) {
|
||||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||||
throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`);
|
throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename temp → final
|
|
||||||
await fs.promises.rename(tempPath, targetPath);
|
await fs.promises.rename(tempPath, targetPath);
|
||||||
reportProgress(true);
|
reportProgress(true);
|
||||||
logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`);
|
logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`);
|
||||||
@ -803,8 +741,6 @@ async function downloadFromCandidates(
|
|||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Asset Resolution Helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function resolveAssetFromApi(repo: string, tag: string): Promise<{
|
async function resolveAssetFromApi(repo: string, tag: string): Promise<{
|
||||||
setupAssetUrl: string;
|
setupAssetUrl: string;
|
||||||
setupAssetName: string;
|
setupAssetName: string;
|
||||||
@ -827,7 +763,6 @@ async function resolveAssetFromApi(repo: string, tag: string): Promise<{
|
|||||||
setupAssetDigest: setup.digest,
|
setupAssetDigest: setup.digest,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// try next endpoint
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -863,14 +798,11 @@ async function resolveDigestFromYml(repo: string, tag: string, setupName: string
|
|||||||
const sha = parseSha512FromLatestYml(yamlText, setupName);
|
const sha = parseSha512FromLatestYml(yamlText, setupName);
|
||||||
if (sha) return `sha512:${sha}`;
|
if (sha) return `sha512:${sha}`;
|
||||||
} catch {
|
} catch {
|
||||||
// try next endpoint
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Public API ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function buildInstallerLaunchArgs(): string[] {
|
export function buildInstallerLaunchArgs(): string[] {
|
||||||
return ["/S", "--updated", "--force-run"];
|
return ["/S", "--updated", "--force-run"];
|
||||||
}
|
}
|
||||||
@ -903,7 +835,6 @@ export async function installLatestUpdate(
|
|||||||
prechecked?: UpdateCheckResult,
|
prechecked?: UpdateCheckResult,
|
||||||
onProgress?: UpdateProgressCallback,
|
onProgress?: UpdateProgressCallback,
|
||||||
): Promise<UpdateInstallResult> {
|
): Promise<UpdateInstallResult> {
|
||||||
// Prevent concurrent updates
|
|
||||||
if (activeAbortController && !activeAbortController.signal.aborted) {
|
if (activeAbortController && !activeAbortController.signal.aborted) {
|
||||||
emitProgress(onProgress, {
|
emitProgress(onProgress, {
|
||||||
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
|
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
|
||||||
@ -916,7 +847,6 @@ export async function installLatestUpdate(
|
|||||||
activeAbortController = abortCtrl;
|
activeAbortController = abortCtrl;
|
||||||
const safeRepo = normalizeUpdateRepo(repo);
|
const safeRepo = normalizeUpdateRepo(repo);
|
||||||
|
|
||||||
// Resolve update check
|
|
||||||
const check = prechecked && !prechecked.error
|
const check = prechecked && !prechecked.error
|
||||||
? prechecked
|
? prechecked
|
||||||
: await checkGitHubUpdate(safeRepo);
|
: await checkGitHubUpdate(safeRepo);
|
||||||
@ -939,7 +869,6 @@ export async function installLatestUpdate(
|
|||||||
return { started: false, message: "Kein neues Update verfügbar" };
|
return { started: false, message: "Kein neues Update verfügbar" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mutable effective state for enrichment
|
|
||||||
let effective: UpdateCheckResult = {
|
let effective: UpdateCheckResult = {
|
||||||
...check,
|
...check,
|
||||||
setupAssetUrl: String(check.setupAssetUrl || ""),
|
setupAssetUrl: String(check.setupAssetUrl || ""),
|
||||||
@ -947,7 +876,6 @@ export async function installLatestUpdate(
|
|||||||
setupAssetDigest: String(check.setupAssetDigest || ""),
|
setupAssetDigest: String(check.setupAssetDigest || ""),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enrich: resolve asset from API if needed
|
|
||||||
if (!effective.setupAssetUrl || !effective.setupAssetDigest) {
|
if (!effective.setupAssetUrl || !effective.setupAssetDigest) {
|
||||||
const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag);
|
const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag);
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
@ -960,7 +888,6 @@ export async function installLatestUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich: resolve digest from latest.yml if still missing
|
|
||||||
if (!effective.setupAssetDigest && effective.setupAssetUrl) {
|
if (!effective.setupAssetDigest && effective.setupAssetUrl) {
|
||||||
const digest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || "");
|
const digest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || "");
|
||||||
if (digest) {
|
if (digest) {
|
||||||
@ -969,7 +896,6 @@ export async function installLatestUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build download candidates
|
|
||||||
let candidates = buildCandidates(safeRepo, effective);
|
let candidates = buildCandidates(safeRepo, effective);
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
activeAbortController = null;
|
activeAbortController = null;
|
||||||
@ -991,7 +917,6 @@ export async function installLatestUpdate(
|
|||||||
|
|
||||||
if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");
|
if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");
|
||||||
|
|
||||||
// ── Download + verify with retry passes ──
|
|
||||||
let verified = false;
|
let verified = false;
|
||||||
let lastVerifyError: unknown = null;
|
let lastVerifyError: unknown = null;
|
||||||
let integrityError: unknown = null;
|
let integrityError: unknown = null;
|
||||||
@ -1030,7 +955,6 @@ export async function installLatestUpdate(
|
|||||||
|
|
||||||
if (verified) break;
|
if (verified) break;
|
||||||
|
|
||||||
// Refresh candidates on 404 or integrity mismatch
|
|
||||||
const status = httpStatusFromError(lastVerifyError);
|
const status = httpStatusFromError(lastVerifyError);
|
||||||
const shouldRefresh = pass < MAX_DOWNLOAD_PASSES - 1 && (status === 404 || integrityError !== null);
|
const shouldRefresh = pass < MAX_DOWNLOAD_PASSES - 1 && (status === 404 || integrityError !== null);
|
||||||
if (!shouldRefresh) break;
|
if (!shouldRefresh) break;
|
||||||
@ -1073,7 +997,6 @@ export async function installLatestUpdate(
|
|||||||
throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen");
|
throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Launch installer ──
|
|
||||||
emitProgress(onProgress, {
|
emitProgress(onProgress, {
|
||||||
stage: "launching", percent: 100, downloadedBytes: 0, totalBytes: null,
|
stage: "launching", percent: 100, downloadedBytes: 0, totalBytes: null,
|
||||||
message: "Starte stille Update-Installation",
|
message: "Starte stille Update-Installation",
|
||||||
@ -1099,7 +1022,6 @@ export async function installLatestUpdate(
|
|||||||
try {
|
try {
|
||||||
await fs.promises.rm(targetPath, { force: true });
|
await fs.promises.rm(targetPath, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
const releaseUrl = String(effective.releaseUrl || "").trim();
|
const releaseUrl = String(effective.releaseUrl || "").trim();
|
||||||
const hint = releaseUrl ? ` – Manuell: ${releaseUrl}` : "";
|
const hint = releaseUrl ? ` – Manuell: ${releaseUrl}` : "";
|
||||||
|
|||||||
510
src/main/video-processor.ts
Normal file
510
src/main/video-processor.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -319,7 +319,6 @@ export function hasRecentWindowsMinidumps(): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -3,11 +3,13 @@ import {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
|
DebridAccountStatus,
|
||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
|
RendererErrorReport,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
@ -57,7 +59,7 @@ const api: ElectronApi = {
|
|||||||
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),
|
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),
|
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||||
@ -75,6 +77,8 @@ const api: ElectronApi = {
|
|||||||
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),
|
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),
|
||||||
@ -85,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);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
import { parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
||||||
import type {
|
import type {
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@ -32,6 +32,7 @@ import {
|
|||||||
getProviderUsageDayKey
|
getProviderUsageDayKey
|
||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
||||||
|
import { pruneSelection } from "./selection";
|
||||||
|
|
||||||
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
|
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
|
||||||
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
|
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
|
||||||
@ -115,6 +116,7 @@ interface AccountDialogState {
|
|||||||
megaAccounts: MegaDialogAccount[];
|
megaAccounts: MegaDialogAccount[];
|
||||||
megaNewLogin: string;
|
megaNewLogin: string;
|
||||||
megaNewPassword: string;
|
megaNewPassword: string;
|
||||||
|
megaDisabledIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DebridLinkAccountKeyEntry {
|
interface DebridLinkAccountKeyEntry {
|
||||||
@ -464,17 +466,12 @@ function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[]
|
|||||||
return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p));
|
return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leitet die aktive Provider-Reihenfolge aus providerOrder ab,
|
|
||||||
// gefiltert auf tatsächlich konfigurierte und nicht deaktivierte Provider.
|
|
||||||
// Direkt-Hoster (onefichier, ddownload) werden ausgeschlossen.
|
|
||||||
const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]);
|
const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]);
|
||||||
|
|
||||||
function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] {
|
function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] {
|
||||||
const active = new Set(getActiveProvidersFromSettings(settings).filter((p) => !DIRECT_HOSTERS.has(p)));
|
const active = new Set(getActiveProvidersFromSettings(settings).filter((p) => !DIRECT_HOSTERS.has(p)));
|
||||||
// Behalte bestehende Reihenfolge aus providerOrder, filtere nicht-konfigurierte heraus
|
|
||||||
const ordered = (settings.providerOrder || []).filter((p) => active.has(p));
|
const ordered = (settings.providerOrder || []).filter((p) => active.has(p));
|
||||||
const inOrder = new Set(ordered);
|
const inOrder = new Set(ordered);
|
||||||
// Füge neue Provider hinten an, die noch nicht in der Reihenfolge sind
|
|
||||||
for (const p of active) {
|
for (const p of active) {
|
||||||
if (!inOrder.has(p)) ordered.push(p);
|
if (!inOrder.has(p)) ordered.push(p);
|
||||||
}
|
}
|
||||||
@ -584,7 +581,7 @@ function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState {
|
function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState {
|
||||||
const baseMega: Pick<AccountDialogState, "megaAccounts" | "megaNewLogin" | "megaNewPassword"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "" };
|
const baseMega: Pick<AccountDialogState, "megaAccounts" | "megaNewLogin" | "megaNewPassword" | "megaDisabledIds"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] };
|
||||||
if (!kind) {
|
if (!kind) {
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
@ -606,14 +603,15 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
|
|||||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
case "megadebrid-api":
|
case "megadebrid-api":
|
||||||
case "megadebrid-web": {
|
case "megadebrid-web": {
|
||||||
// Populate megaAccounts from megaCredentials, or build from legacy megaLogin/megaPassword
|
|
||||||
let megaToken = (settings.megaCredentials || "").trim();
|
let megaToken = (settings.megaCredentials || "").trim();
|
||||||
if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) {
|
if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) {
|
||||||
megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`;
|
megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`;
|
||||||
}
|
}
|
||||||
const parsed = parseMegaDebridAccounts(megaToken);
|
const parsed = parseMegaDebridAccounts(megaToken);
|
||||||
const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password }));
|
const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password }));
|
||||||
return { mode, kind, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "" };
|
const loadedIds = new Set(parsed.map((a) => a.id));
|
||||||
|
const megaDisabledIds = (settings.megaDebridDisabledAccountIds || []).filter((id) => loadedIds.has(id));
|
||||||
|
return { mode, kind, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds };
|
||||||
}
|
}
|
||||||
case "bestdebrid-api":
|
case "bestdebrid-api":
|
||||||
return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
@ -678,14 +676,18 @@ function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDial
|
|||||||
const megaParsed = parseMegaDebridAccounts(megaSerialized);
|
const megaParsed = parseMegaDebridAccounts(megaSerialized);
|
||||||
const firstLogin = megaParsed.length > 0 ? megaParsed[0].login : "";
|
const firstLogin = megaParsed.length > 0 ? megaParsed[0].login : "";
|
||||||
const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : "";
|
const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : "";
|
||||||
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridPreferApi: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
const validIds = new Set(megaParsed.map((a) => a.id));
|
||||||
|
const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id));
|
||||||
|
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridPreferApi: true, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
||||||
}
|
}
|
||||||
case "megadebrid-web": {
|
case "megadebrid-web": {
|
||||||
const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts);
|
const megaSerialized = serializeMegaDebridAccounts(dialog.megaAccounts);
|
||||||
const megaParsed = parseMegaDebridAccounts(megaSerialized);
|
const megaParsed = parseMegaDebridAccounts(megaSerialized);
|
||||||
const firstLogin = megaParsed.length > 0 ? megaParsed[0].login : "";
|
const firstLogin = megaParsed.length > 0 ? megaParsed[0].login : "";
|
||||||
const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : "";
|
const firstPassword = megaParsed.length > 0 ? megaParsed[0].password : "";
|
||||||
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridPreferApi: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
const validIds = new Set(megaParsed.map((a) => a.id));
|
||||||
|
const megaDebridDisabledAccountIds = (dialog.megaDisabledIds || []).filter((id) => validIds.has(id));
|
||||||
|
return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridPreferApi: false, megaDebridDisabledAccountIds, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
||||||
}
|
}
|
||||||
case "bestdebrid-api":
|
case "bestdebrid-api":
|
||||||
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
|
||||||
@ -842,14 +844,14 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
archivePasswordList: "",
|
archivePasswordList: "",
|
||||||
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
|
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
|
||||||
providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "",
|
providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "",
|
||||||
autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
|
autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true,
|
||||||
collectMkvToLibrary: false, mkvLibraryDir: "",
|
collectMkvToLibrary: false, mkvLibraryDir: "",
|
||||||
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
|
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
|
||||||
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
|
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
|
||||||
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
||||||
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
||||||
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
||||||
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true,
|
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false,
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
accountListShowDetailedDebridLinkKeys: false,
|
||||||
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
||||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||||
@ -866,6 +868,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
megaDebridAccountDailyLimitBytes: {},
|
megaDebridAccountDailyLimitBytes: {},
|
||||||
megaDebridAccountDailyUsageBytes: {},
|
megaDebridAccountDailyUsageBytes: {},
|
||||||
megaDebridAccountTotalUsageBytes: {},
|
megaDebridAccountTotalUsageBytes: {},
|
||||||
|
debridAccountStatuses: {},
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
},
|
},
|
||||||
@ -1121,6 +1124,46 @@ function formatDebridLinkCountQuota(info: DebridLinkHostLimitInfo | null | undef
|
|||||||
return info.note || "Nicht verfügbar";
|
return info.note || "Nicht verfügbar";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCheckedAgo(checkedAt: number): string {
|
||||||
|
const deltaMs = Date.now() - checkedAt;
|
||||||
|
if (!Number.isFinite(deltaMs) || deltaMs < 0) return "gerade eben";
|
||||||
|
const min = Math.floor(deltaMs / 60000);
|
||||||
|
if (min < 1) return "gerade eben";
|
||||||
|
if (min < 60) return `vor ${min} Min`;
|
||||||
|
const hours = Math.floor(min / 60);
|
||||||
|
if (hours < 24) return `vor ${hours} Std`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `vor ${days} Tag${days === 1 ? "" : "en"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string; reason?: string }): string {
|
||||||
|
const untilRestart = /bis Neustart gesperrt/i.test(ev.reason || "");
|
||||||
|
switch (ev.event) {
|
||||||
|
case "OK": return "erfolgreich";
|
||||||
|
case "FAILED": {
|
||||||
|
if (untilRestart) {
|
||||||
|
const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : "";
|
||||||
|
return `Tageslimit erreicht, bis Neustart gesperrt${nx}`;
|
||||||
|
}
|
||||||
|
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
|
||||||
|
const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : "";
|
||||||
|
return `fehlgeschlagen${cd}${nx}`;
|
||||||
|
}
|
||||||
|
case "FATAL": return "abgebrochen (fataler Fehler)";
|
||||||
|
case "TIMEOUT_COOLDOWN": {
|
||||||
|
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
|
||||||
|
return `Timeout/Abbruch${cd} → nächster Account beim Retry`;
|
||||||
|
}
|
||||||
|
case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis Neustart gesperrt)" : "übersprungen (Cooldown aktiv)";
|
||||||
|
case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
|
||||||
|
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
|
||||||
|
case "SKIP_HOST_COOLDOWN": return "übersprungen (Host-Cooldown)";
|
||||||
|
case "PROVIDER_WIDE": return "Provider-weiter Fehler, restliche Keys übersprungen";
|
||||||
|
case "TRANSPORT_CASCADE": return "Netzwerk-Kaskade, restliche Keys übersprungen";
|
||||||
|
default: return ev.event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getDebridLinkKeyStatusDisplay(
|
function getDebridLinkKeyStatusDisplay(
|
||||||
key: DebridLinkAccountKeyEntry,
|
key: DebridLinkAccountKeyEntry,
|
||||||
info: DebridLinkHostLimitInfo | null | undefined
|
info: DebridLinkHostLimitInfo | null | undefined
|
||||||
@ -1227,8 +1270,8 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
|
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
|
||||||
const gridColor = isDark ? "rgba(35, 57, 84, 0.5)" : "rgba(199, 213, 234, 0.5)";
|
const gridColor = isDark ? "rgba(35, 57, 84, 0.5)" : "rgba(199, 213, 234, 0.5)";
|
||||||
const textColor = isDark ? "#90a4bf" : "#4e6482";
|
const textColor = isDark ? "#90a4bf" : "#4e6482";
|
||||||
const accentColor = isDark ? "#38bdf8" : "#1168d9";
|
const accentColor = isDark ? "#f2942d" : "#c2701a";
|
||||||
const fillColor = isDark ? "rgba(56, 189, 248, 0.15)" : "rgba(17, 104, 217, 0.15)";
|
const fillColor = isDark ? "rgba(242, 148, 45, 0.15)" : "rgba(194, 112, 26, 0.15)";
|
||||||
|
|
||||||
const history = speedHistoryRef.current;
|
const history = speedHistoryRef.current;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -1242,7 +1285,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
maxSpeed = Math.max(maxSpeed, 1024 * 1024);
|
maxSpeed = Math.max(maxSpeed, 1024 * 1024);
|
||||||
const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed)));
|
const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed)));
|
||||||
|
|
||||||
// Measure widest label to set dynamic left padding
|
|
||||||
ctx.font = "11px 'Manrope', sans-serif";
|
ctx.font = "11px 'Manrope', sans-serif";
|
||||||
let maxLabelWidth = 0;
|
let maxLabelWidth = 0;
|
||||||
for (let i = 0; i <= 5; i += 1) {
|
for (let i = 0; i <= 5; i += 1) {
|
||||||
@ -1325,12 +1367,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
}, [running, paused]);
|
}, [running, paused]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Always draw once on mount / when running/paused state changes so the
|
|
||||||
// chart shows the latest history.
|
|
||||||
drawChart();
|
drawChart();
|
||||||
// Only schedule periodic redraws while actively downloading — when
|
|
||||||
// stopped or paused the speed history doesn't change, so polling
|
|
||||||
// every 250ms would just burn CPU on the renderer process.
|
|
||||||
if (!running || paused) {
|
if (!running || paused) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1341,7 +1378,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
}, [drawChart, running, paused]);
|
}, [drawChart, running, paused]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only record samples while the session is running and not paused
|
|
||||||
if (!running || paused) return;
|
if (!running || paused) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -1397,7 +1433,6 @@ function createScheduleId(): string {
|
|||||||
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function sortPackageOrderBySize(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
|
function sortPackageOrderBySize(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
|
||||||
const sorted = [...order];
|
const sorted = [...order];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
@ -1532,10 +1567,6 @@ export function App(): ReactElement {
|
|||||||
const settingsDraftRevisionRef = useRef(0);
|
const settingsDraftRevisionRef = useRef(0);
|
||||||
const panelDirtyRevisionRef = useRef(0);
|
const panelDirtyRevisionRef = useRef(0);
|
||||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||||
// Master state used to apply incoming delta payloads. The wire format from
|
|
||||||
// the main process sends only changed items/packages (with payloadKind="delta")
|
|
||||||
// most of the time and a full snapshot every 30s for safety. Without this
|
|
||||||
// master, we'd only see the changed slice each emit.
|
|
||||||
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
||||||
const snapshotRef = useRef(snapshot);
|
const snapshotRef = useRef(snapshot);
|
||||||
snapshotRef.current = snapshot;
|
snapshotRef.current = snapshot;
|
||||||
@ -1569,6 +1600,8 @@ export function App(): ReactElement {
|
|||||||
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
||||||
const [showAllPackages, setShowAllPackages] = useState(false);
|
const [showAllPackages, setShowAllPackages] = useState(false);
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
||||||
|
const [megaCheckingIds, setMegaCheckingIds] = useState<Set<string>>(() => new Set());
|
||||||
const actionBusyRef = useRef(false);
|
const actionBusyRef = useRef(false);
|
||||||
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
@ -1642,7 +1675,6 @@ export function App(): ReactElement {
|
|||||||
window.addEventListener("mouseup", stopAccountColumnResize);
|
window.addEventListener("mouseup", stopAccountColumnResize);
|
||||||
}, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]);
|
}, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]);
|
||||||
|
|
||||||
// Load history when tab changes to history
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== "history") return;
|
if (tab !== "history") return;
|
||||||
const loadHistory = async (): Promise<void> => {
|
const loadHistory = async (): Promise<void> => {
|
||||||
@ -1664,7 +1696,6 @@ export function App(): ReactElement {
|
|||||||
try {
|
try {
|
||||||
window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths));
|
window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore local persistence failures for optional UI state.
|
|
||||||
}
|
}
|
||||||
}, [accountColumnWidths]);
|
}, [accountColumnWidths]);
|
||||||
|
|
||||||
@ -1673,16 +1704,10 @@ export function App(): ReactElement {
|
|||||||
try {
|
try {
|
||||||
window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY);
|
window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore local persistence failures for optional UI state.
|
|
||||||
}
|
}
|
||||||
showToast("Accounts-Spalten zurückgesetzt", 1800);
|
showToast("Accounts-Spalten zurückgesetzt", 1800);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync column order from settings. Avoid JSON.stringify on every render
|
|
||||||
// (which was a 7-element array stringify per snapshot tick). A simple
|
|
||||||
// join() is one O(n) string concat without Object/Array allocation overhead,
|
|
||||||
// and useMemo caches the resulting key so React only sees a new dep when the
|
|
||||||
// contents actually changed.
|
|
||||||
const columnOrderKey = useMemo(
|
const columnOrderKey = useMemo(
|
||||||
() => (snapshot.settings.columnOrder || []).join("|"),
|
() => (snapshot.settings.columnOrder || []).join("|"),
|
||||||
[snapshot.settings.columnOrder]
|
[snapshot.settings.columnOrder]
|
||||||
@ -1692,7 +1717,6 @@ export function App(): ReactElement {
|
|||||||
if (order && order.length > 0) {
|
if (order && order.length > 0) {
|
||||||
setColumnOrder(order);
|
setColumnOrder(order);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [columnOrderKey]);
|
}, [columnOrderKey]);
|
||||||
|
|
||||||
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
||||||
@ -1868,7 +1892,6 @@ export function App(): ReactElement {
|
|||||||
if (!mountedRef.current) {
|
if (!mountedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Seed the master snapshot — incoming delta payloads will merge into this.
|
|
||||||
masterSnapshotRef.current = state;
|
masterSnapshotRef.current = state;
|
||||||
setSnapshot(state);
|
setSnapshot(state);
|
||||||
if (state.settings.columnOrder?.length > 0) {
|
if (state.settings.columnOrder?.length > 0) {
|
||||||
@ -1891,8 +1914,6 @@ export function App(): ReactElement {
|
|||||||
showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800);
|
showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800);
|
||||||
});
|
});
|
||||||
unsubscribe = window.rd.onStateUpdate((wireState) => {
|
unsubscribe = window.rd.onStateUpdate((wireState) => {
|
||||||
// Merge delta payloads into the master snapshot. Full payloads replace
|
|
||||||
// the master entirely (initial sync + periodic 30s resync).
|
|
||||||
let merged: UiSnapshot;
|
let merged: UiSnapshot;
|
||||||
const master = masterSnapshotRef.current;
|
const master = masterSnapshotRef.current;
|
||||||
if (wireState.payloadKind === "delta" && master) {
|
if (wireState.payloadKind === "delta" && master) {
|
||||||
@ -2093,14 +2114,16 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
||||||
|
|
||||||
|
// Prune selection when its packages/items disappear (e.g. via delta-removal or
|
||||||
|
// a backup-driven session swap). selectedIds holds BOTH package and item ids;
|
||||||
|
// a stale id would otherwise inflate the selection count and the "(N)" labels.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds((prev) => pruneSelection(prev, snapshot.session));
|
||||||
|
}, [snapshot.session.packages, snapshot.session.items]);
|
||||||
|
|
||||||
const hiddenPackageCount = shouldLimitPackageRendering
|
const hiddenPackageCount = shouldLimitPackageRendering
|
||||||
? Math.max(0, totalPackageCount - packages.length)
|
? Math.max(0, totalPackageCount - packages.length)
|
||||||
: 0;
|
: 0;
|
||||||
// The sort-by-progress logic only runs when the session is running AND auto-sort
|
|
||||||
// is enabled AND there's more than one package. When any of those isn't true,
|
|
||||||
// the items reference is irrelevant — passing null here makes useMemo skip the
|
|
||||||
// re-evaluation that previously fired on EVERY item update (progress, status,
|
|
||||||
// speed) even when the sort would have returned the original `packages` array.
|
|
||||||
const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1)
|
const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1)
|
||||||
? snapshot.session.items
|
? snapshot.session.items
|
||||||
: null;
|
: null;
|
||||||
@ -2138,7 +2161,6 @@ export function App(): ReactElement {
|
|||||||
void loadAllDebridHostInfo(true);
|
void loadAllDebridHostInfo(true);
|
||||||
}, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]);
|
}, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]);
|
||||||
|
|
||||||
// Auto-expand packages that are currently extracting (only once per extraction cycle)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const extractingPkgIds: string[] = [];
|
const extractingPkgIds: string[] = [];
|
||||||
const currentlyExtracting = new Set<string>();
|
const currentlyExtracting = new Set<string>();
|
||||||
@ -2155,7 +2177,6 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reset tracking for packages no longer extracting
|
|
||||||
for (const id of autoExpandedPkgsRef.current) {
|
for (const id of autoExpandedPkgsRef.current) {
|
||||||
if (!currentlyExtracting.has(id)) {
|
if (!currentlyExtracting.has(id)) {
|
||||||
autoExpandedPkgsRef.current.delete(id);
|
autoExpandedPkgsRef.current.delete(id);
|
||||||
@ -2176,9 +2197,6 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]);
|
const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]);
|
||||||
|
|
||||||
// DDownload is a direct file hoster (not a debrid service) and is used automatically
|
|
||||||
// for ddownload.com/ddl.to URLs. It counts as a configured account but does not
|
|
||||||
// appear in the primary/secondary/tertiary provider dropdowns.
|
|
||||||
const hasDdownloadAccount = useMemo(() =>
|
const hasDdownloadAccount = useMemo(() =>
|
||||||
Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()),
|
Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()),
|
||||||
[settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]);
|
[settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]);
|
||||||
@ -2189,10 +2207,8 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0);
|
const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0);
|
||||||
|
|
||||||
// Dynamische Provider-Reihenfolge (ersetzt altes primary/secondary/tertiary)
|
|
||||||
const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]);
|
const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]);
|
||||||
|
|
||||||
// Setzt providerOrder + backwards-kompatible Felder synchron
|
|
||||||
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
|
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
|
||||||
settingsDraftRevisionRef.current += 1;
|
settingsDraftRevisionRef.current += 1;
|
||||||
panelDirtyRevisionRef.current += 1;
|
panelDirtyRevisionRef.current += 1;
|
||||||
@ -2597,6 +2613,42 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkAllAccounts = useCallback(async (): Promise<void> => {
|
||||||
|
setAccountCheckBusy(true);
|
||||||
|
try {
|
||||||
|
const statuses = await window.rd.checkDebridAccounts();
|
||||||
|
if (!statuses || statuses.length === 0) {
|
||||||
|
showToast("Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", 3200);
|
||||||
|
} else {
|
||||||
|
const valid = statuses.filter((st) => st.valid).length;
|
||||||
|
const premium = statuses.filter((st) => st.isPremium).length;
|
||||||
|
showToast(`Account-Check: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3600);
|
||||||
|
} finally {
|
||||||
|
setAccountCheckBusy(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const runMegaAccountCheck = useCallback(async (login: string, password: string): Promise<void> => {
|
||||||
|
const trimmedLogin = login.trim();
|
||||||
|
const trimmedPassword = password.trim();
|
||||||
|
if (!trimmedLogin || !trimmedPassword) return;
|
||||||
|
const accId = getMegaDebridAccountId(trimmedLogin);
|
||||||
|
setMegaCheckingIds((prev) => { const next = new Set(prev); next.add(accId); return next; });
|
||||||
|
try {
|
||||||
|
const status = await window.rd.checkMegaDebridAccount(trimmedLogin, trimmedPassword);
|
||||||
|
if (status) {
|
||||||
|
showToast(status.valid ? `Account geprüft — ${status.message}` : `Account ungültig — ${status.message}`, 3200);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3200);
|
||||||
|
} finally {
|
||||||
|
setMegaCheckingIds((prev) => { const next = new Set(prev); next.delete(accId); return next; });
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
const openCreateAccountDialog = (): void => {
|
const openCreateAccountDialog = (): void => {
|
||||||
setAccountDialogSearch("");
|
setAccountDialogSearch("");
|
||||||
setAccountDialog(createAccountDialogState("create", null, settingsDraft));
|
setAccountDialog(createAccountDialogState("create", null, settingsDraft));
|
||||||
@ -3241,7 +3293,7 @@ export function App(): ReactElement {
|
|||||||
const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => {
|
const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => {
|
||||||
let shouldRename = false;
|
let shouldRename = false;
|
||||||
setEditingPackageId((prev) => {
|
setEditingPackageId((prev) => {
|
||||||
if (prev !== packageId) return prev; // already finished (e.g. blur after Enter key)
|
if (prev !== packageId) return prev;
|
||||||
shouldRename = true;
|
shouldRename = true;
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@ -3327,8 +3379,6 @@ export function App(): ReactElement {
|
|||||||
pendingPackageOrderRef.current = [...order];
|
pendingPackageOrderRef.current = [...order];
|
||||||
pendingPackageOrderAtRef.current = Date.now();
|
pendingPackageOrderAtRef.current = Date.now();
|
||||||
packageOrderRef.current = [...order];
|
packageOrderRef.current = [...order];
|
||||||
// Optimistic UI update ? apply the new order immediately so the user
|
|
||||||
// sees the change without waiting for the backend round-trip.
|
|
||||||
setSnapshot((prev) => {
|
setSnapshot((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
|
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
|
||||||
@ -3337,7 +3387,6 @@ export function App(): ReactElement {
|
|||||||
pendingPackageOrderRef.current = null;
|
pendingPackageOrderRef.current = null;
|
||||||
pendingPackageOrderAtRef.current = 0;
|
pendingPackageOrderAtRef.current = 0;
|
||||||
packageOrderRef.current = serverPackageOrderRef.current;
|
packageOrderRef.current = serverPackageOrderRef.current;
|
||||||
// Rollback: restore original order from server
|
|
||||||
setSnapshot((prev) => {
|
setSnapshot((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
||||||
@ -3487,7 +3536,6 @@ export function App(): ReactElement {
|
|||||||
const dragDidMoveRef = useRef(false);
|
const dragDidMoveRef = useRef(false);
|
||||||
const lastClickedIdRef = useRef<string | null>(null);
|
const lastClickedIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Flat list of all visible IDs (package headers + their visible items) in display order
|
|
||||||
const visibleOrderIds = useMemo(() => {
|
const visibleOrderIds = useMemo(() => {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const pkg of visiblePackages) {
|
for (const pkg of visiblePackages) {
|
||||||
@ -3503,8 +3551,13 @@ export function App(): ReactElement {
|
|||||||
return ids;
|
return ids;
|
||||||
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]);
|
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]);
|
||||||
|
|
||||||
|
// Keep a ref of the currently VISIBLE ids so the (deps-[]) Ctrl+A keyboard
|
||||||
|
// handler can select exactly what the user sees — not the whole unfiltered map.
|
||||||
|
const visibleOrderIdsRef = useRef<string[]>(visibleOrderIds);
|
||||||
|
visibleOrderIdsRef.current = visibleOrderIds;
|
||||||
|
|
||||||
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
|
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
|
||||||
if (dragDidMoveRef.current) return; // drag handled it, skip click
|
if (dragDidMoveRef.current) return;
|
||||||
if (shiftKey && lastClickedIdRef.current) {
|
if (shiftKey && lastClickedIdRef.current) {
|
||||||
const anchorIdx = visibleOrderIds.indexOf(lastClickedIdRef.current);
|
const anchorIdx = visibleOrderIds.indexOf(lastClickedIdRef.current);
|
||||||
const targetIdx = visibleOrderIds.indexOf(id);
|
const targetIdx = visibleOrderIds.indexOf(id);
|
||||||
@ -3551,7 +3604,6 @@ export function App(): ReactElement {
|
|||||||
if (!dragSelectRef.current) return;
|
if (!dragSelectRef.current) return;
|
||||||
if (!dragDidMoveRef.current) {
|
if (!dragDidMoveRef.current) {
|
||||||
dragDidMoveRef.current = true;
|
dragDidMoveRef.current = true;
|
||||||
// Add anchor item now that we know it's a drag
|
|
||||||
const anchor = dragAnchorRef.current;
|
const anchor = dragAnchorRef.current;
|
||||||
if (anchor) {
|
if (anchor) {
|
||||||
setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; });
|
setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; });
|
||||||
@ -3564,7 +3616,6 @@ export function App(): ReactElement {
|
|||||||
const sel = selectedIds;
|
const sel = selectedIds;
|
||||||
const currentPackages = snapshotRef.current.session.packages;
|
const currentPackages = snapshotRef.current.session.packages;
|
||||||
const currentItems = snapshotRef.current.session.items;
|
const currentItems = snapshotRef.current.session.items;
|
||||||
// Multi-select: collect links from all selected packages/items
|
|
||||||
if (sel.size > 1) {
|
if (sel.size > 1) {
|
||||||
const allLinks: { name: string; url: string }[] = [];
|
const allLinks: { name: string; url: string }[] = [];
|
||||||
for (const id of sel) {
|
for (const id of sel) {
|
||||||
@ -3694,7 +3745,6 @@ export function App(): ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!colHeaderCtx) return;
|
if (!colHeaderCtx) return;
|
||||||
const close = (e: MouseEvent): void => {
|
const close = (e: MouseEvent): void => {
|
||||||
// Don't close if click is inside the menu or on the header bar (re-position instead)
|
|
||||||
if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return;
|
if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return;
|
||||||
if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return;
|
if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return;
|
||||||
setColHeaderCtx(null);
|
setColHeaderCtx(null);
|
||||||
@ -3765,7 +3815,6 @@ export function App(): ReactElement {
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
||||||
// Don't clear selection if an overlay is open ? let the overlay close first
|
|
||||||
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
||||||
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
||||||
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
||||||
@ -3806,6 +3855,13 @@ export function App(): ReactElement {
|
|||||||
const result = await window.rd.importBackup();
|
const result = await window.rd.importBackup();
|
||||||
if (result.restored) {
|
if (result.restored) {
|
||||||
showToast(result.message, 4000);
|
showToast(result.message, 4000);
|
||||||
|
// A settings-only import applies live without a relaunch, so the editable
|
||||||
|
// settings form would otherwise keep showing the old values. Pull the
|
||||||
|
// fresh settings and re-seed the draft so the UI reflects the import.
|
||||||
|
if (!result.relaunch) {
|
||||||
|
const fresh = await window.rd.getSnapshot();
|
||||||
|
applyPersistedSettings(fresh.settings);
|
||||||
|
}
|
||||||
} else if (result.message !== "Abgebrochen") {
|
} else if (result.message !== "Abgebrochen") {
|
||||||
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
|
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
|
||||||
}
|
}
|
||||||
@ -3929,7 +3985,10 @@ export function App(): ReactElement {
|
|||||||
if (inInput) return;
|
if (inInput) return;
|
||||||
if (tabRef.current === "downloads") {
|
if (tabRef.current === "downloads") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIds(new Set(Object.keys(snapshotRef.current.session.packages)));
|
// Select exactly the VISIBLE rows (packages + their items), honouring
|
||||||
|
// the active search / collapse / hide-extracted filters — selecting
|
||||||
|
// the unfiltered package map would let a later delete hit hidden ones.
|
||||||
|
setSelectedIds(new Set(visibleOrderIdsRef.current));
|
||||||
} else if (tabRef.current === "history") {
|
} else if (tabRef.current === "history") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id)));
|
setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id)));
|
||||||
@ -4433,7 +4492,7 @@ export function App(): ReactElement {
|
|||||||
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
|
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Action buttons moved to footer */}
|
{}
|
||||||
<div ref={colHeaderBarRef} className="pkg-column-header" style={{ gridTemplateColumns: gridTemplate }} onContextMenu={(e) => { e.preventDefault(); setColHeaderCtx({ x: e.clientX, y: e.clientY }); }}>
|
<div ref={colHeaderBarRef} className="pkg-column-header" style={{ gridTemplateColumns: gridTemplate }} onContextMenu={(e) => { e.preventDefault(); setColHeaderCtx({ x: e.clientX, y: e.clientY }); }}>
|
||||||
{columnOrder.map((col) => {
|
{columnOrder.map((col) => {
|
||||||
const def = COLUMN_DEFS[col];
|
const def = COLUMN_DEFS[col];
|
||||||
@ -4849,6 +4908,7 @@ export function App(): ReactElement {
|
|||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
|
||||||
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste in Sicherung mitsichern (Standard: nur Einstellungen)</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
|
||||||
const next = e.target.checked ? "light" : "dark";
|
const next = e.target.checked ? "light" : "dark";
|
||||||
settingsDraftRevisionRef.current += 1;
|
settingsDraftRevisionRef.current += 1;
|
||||||
@ -4868,10 +4928,15 @@ export function App(): ReactElement {
|
|||||||
<h3>Accounts</h3>
|
<h3>Accounts</h3>
|
||||||
<div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div>
|
<div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="account-board-header-actions">
|
||||||
|
<button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts">
|
||||||
|
{accountCheckBusy ? "Prüfe Accounts…" : "Alle prüfen"}
|
||||||
|
</button>
|
||||||
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
|
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
|
||||||
Account hinzufügen
|
Account hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="account-board-summary">
|
<div className="account-board-summary">
|
||||||
<span className="account-inline-stat">{configuredAccounts.length} aktiv</span>
|
<span className="account-inline-stat">{configuredAccounts.length} aktiv</span>
|
||||||
@ -5035,6 +5100,31 @@ export function App(): ReactElement {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section card">
|
||||||
|
<div className="account-board-header">
|
||||||
|
<div>
|
||||||
|
<h3>Rotations-Verlauf</h3>
|
||||||
|
<div className="hint">Zeigt, welcher Account/Key zuletzt für die Link-Umwandlung versucht wurde und warum gewechselt wurde.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rotation-panel">
|
||||||
|
{(!snapshot?.rotationEvents || snapshot.rotationEvents.length === 0) ? (
|
||||||
|
<div className="rotation-empty">Noch keine Rotations-Ereignisse. Sobald ein Account/Key bei der Link-Umwandlung fehlschlägt oder gewechselt wird, erscheint es hier.</div>
|
||||||
|
) : (
|
||||||
|
snapshot.rotationEvents.map((ev) => (
|
||||||
|
<div key={ev.id} className={`rotation-event ${ev.level}`}>
|
||||||
|
<span className="rotation-time">{new Date(ev.at).toLocaleTimeString()}</span>
|
||||||
|
<span className="rotation-body">
|
||||||
|
<strong>{ev.provider} · {ev.accountLabel}</strong>{" "}
|
||||||
|
{rotationEventText(ev)}
|
||||||
|
{ev.reason ? <span className="rotation-reason"> ({ev.reason})</span> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-section card">
|
<div className="settings-section card">
|
||||||
<h3>Hoster-Reihenfolge</h3>
|
<h3>Hoster-Reihenfolge</h3>
|
||||||
<div className="hint">
|
<div className="hint">
|
||||||
@ -5286,6 +5376,11 @@ export function App(): ReactElement {
|
|||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes beim Start überspringen</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes beim Start überspringen</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Items in Paketliste ausblenden</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Items in Paketliste ausblenden</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label>
|
||||||
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.keepGermanAudioOnly} onChange={(e) => setBool("keepGermanAudioOnly", e.target.checked)} /> Nur deutsche Tonspur behalten (.DL.-Dateien, braucht ffmpeg)</label>
|
||||||
|
<div><label>Tonspur-Auswahl</label><select value={settingsDraft.germanAudioMode} disabled={!settingsDraft.keepGermanAudioOnly} onChange={(e) => setText("germanAudioMode", e.target.value)}>
|
||||||
|
<option value="tag">Deutsche Spur per Sprach-Tag (empfohlen)</option>
|
||||||
|
<option value="first">Immer erste Tonspur (wie Script)</option>
|
||||||
|
</select></div>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
|
||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label>
|
||||||
@ -5581,13 +5676,21 @@ export function App(): ReactElement {
|
|||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
disabled={!accountDialog.megaNewLogin.trim() || !accountDialog.megaNewPassword.trim()}
|
disabled={!accountDialog.megaNewLogin.trim() || !accountDialog.megaNewPassword.trim()}
|
||||||
onClick={() => setAccountDialog((prev) => {
|
onClick={() => {
|
||||||
|
const login = accountDialog.megaNewLogin.trim();
|
||||||
|
const password = accountDialog.megaNewPassword.trim();
|
||||||
|
if (!login || !password) return;
|
||||||
|
const exists = accountDialog.megaAccounts.some((a) => a.login.trim().toLowerCase() === login.toLowerCase());
|
||||||
|
setAccountDialog((prev) => {
|
||||||
if (!prev || !prev.megaNewLogin.trim() || !prev.megaNewPassword.trim()) return prev;
|
if (!prev || !prev.megaNewLogin.trim() || !prev.megaNewPassword.trim()) return prev;
|
||||||
const exists = prev.megaAccounts.some((a) => a.login.trim().toLowerCase() === prev.megaNewLogin.trim().toLowerCase());
|
if (prev.megaAccounts.some((a) => a.login.trim().toLowerCase() === login.toLowerCase())) return prev;
|
||||||
if (exists) return prev;
|
const nextAccounts = [...prev.megaAccounts, { login, password }];
|
||||||
const nextAccounts = [...prev.megaAccounts, { login: prev.megaNewLogin.trim(), password: prev.megaNewPassword.trim() }];
|
|
||||||
return { ...prev, megaAccounts: nextAccounts, megaNewLogin: "", megaNewPassword: "", token: serializeMegaDebridAccounts(nextAccounts) };
|
return { ...prev, megaAccounts: nextAccounts, megaNewLogin: "", megaNewPassword: "", token: serializeMegaDebridAccounts(nextAccounts) };
|
||||||
})}
|
});
|
||||||
|
if (!exists) {
|
||||||
|
void runMegaAccountCheck(login, password);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Hinzufügen
|
Hinzufügen
|
||||||
</button>
|
</button>
|
||||||
@ -5595,24 +5698,56 @@ export function App(): ReactElement {
|
|||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<label>Konfigurierte Accounts ({accountDialog.megaAccounts.length})</label>
|
<label>Konfigurierte Accounts ({accountDialog.megaAccounts.length})</label>
|
||||||
<div className="account-dl-key-limit-list">
|
<div className="account-dl-key-limit-list">
|
||||||
{accountDialog.megaAccounts.map((account, index) => (
|
{accountDialog.megaAccounts.map((account, index) => {
|
||||||
<div key={index} className="account-dl-key-limit-row">
|
const accId = getMegaDebridAccountId(account.login);
|
||||||
|
const accDisabled = (accountDialog.megaDisabledIds || []).includes(accId);
|
||||||
|
return (
|
||||||
|
<div key={index} className={`account-dl-key-limit-row${accDisabled ? " disabled" : ""}`}>
|
||||||
<div className="account-dl-key-meta">
|
<div className="account-dl-key-meta">
|
||||||
<strong>Account {index + 1}</strong>
|
<strong>Account {index + 1}</strong>
|
||||||
<span>{maskMegaDebridLogin(account.login)}</span>
|
<span>{maskMegaDebridLogin(account.login)}</span>
|
||||||
|
{accDisabled && <span className="account-validity-badge invalid" title="Dieser Account wird beim Download übersprungen, bleibt aber gespeichert.">Deaktiviert</span>}
|
||||||
|
{megaCheckingIds.has(accId)
|
||||||
|
? <span className="account-validity-badge unknown" title="Account wird gerade geprüft…">Prüfe…</span>
|
||||||
|
: (() => {
|
||||||
|
const st = snapshot?.settings?.debridAccountStatuses?.[accId];
|
||||||
|
if (!st) return <span className="account-validity-badge unknown" title="Noch nicht geprüft – auf „Alle prüfen“ klicken">Noch nicht geprüft</span>;
|
||||||
|
const checkedAgo = formatCheckedAgo(st.checkedAt);
|
||||||
|
const tip = `${st.message}${st.email ? ` · ${st.email}` : ""} · geprüft ${checkedAgo}`;
|
||||||
|
if (!st.valid) return <span className="account-validity-badge invalid" title={tip}>Login ungültig</span>;
|
||||||
|
if (!st.isPremium) return <span className="account-validity-badge free" title={tip}>Login OK · kein Premium</span>;
|
||||||
|
return <span className="account-validity-badge ok" title={tip}>{st.message}</span>;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
className={accDisabled ? "btn success" : "btn"}
|
||||||
|
title={accDisabled ? "Account wieder aktivieren" : "Account temporär deaktivieren — wird beim Download übersprungen, aber nicht gelöscht"}
|
||||||
|
onClick={() => setAccountDialog((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const cur = prev.megaDisabledIds || [];
|
||||||
|
const nextDisabled = cur.includes(accId)
|
||||||
|
? cur.filter((id) => id !== accId)
|
||||||
|
: [...cur, accId];
|
||||||
|
return { ...prev, megaDisabledIds: nextDisabled };
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{accDisabled ? "Aktivieren" : "Deaktivieren"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn danger"
|
className="btn danger"
|
||||||
onClick={() => setAccountDialog((prev) => {
|
onClick={() => setAccountDialog((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
const nextAccounts = prev.megaAccounts.filter((_, i) => i !== index);
|
const nextAccounts = prev.megaAccounts.filter((_, i) => i !== index);
|
||||||
return { ...prev, megaAccounts: nextAccounts, token: serializeMegaDebridAccounts(nextAccounts) };
|
return { ...prev, megaAccounts: nextAccounts, megaDisabledIds: (prev.megaDisabledIds || []).filter((id) => id !== accId), token: serializeMegaDebridAccounts(nextAccounts) };
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
Entfernen
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -5672,6 +5807,14 @@ export function App(): ReactElement {
|
|||||||
<div className="account-dl-key-meta">
|
<div className="account-dl-key-meta">
|
||||||
<strong>{key.label}</strong>
|
<strong>{key.label}</strong>
|
||||||
<span>{key.masked}</span>
|
<span>{key.masked}</span>
|
||||||
|
{(() => {
|
||||||
|
const st = snapshot?.settings?.debridAccountStatuses?.[key.id];
|
||||||
|
if (!st) return <span className="account-validity-badge unknown" title="Noch nicht geprüft – auf „Alle prüfen“ klicken">Noch nicht geprüft</span>;
|
||||||
|
const tip = `${st.message}${st.email ? ` · ${st.email}` : ""} · geprüft ${formatCheckedAgo(st.checkedAt)}`;
|
||||||
|
if (!st.valid) return <span className="account-validity-badge invalid" title={tip}>Key ungültig</span>;
|
||||||
|
if (!st.isPremium) return <span className="account-validity-badge free" title={tip}>Key OK · kein Premium</span>;
|
||||||
|
return <span className="account-validity-badge ok" title={tip}>{st.message}</span>;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
@ -5968,7 +6111,6 @@ export function App(): ReactElement {
|
|||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
newOrder = columnOrder.filter((c) => c !== col);
|
newOrder = columnOrder.filter((c) => c !== col);
|
||||||
} else {
|
} else {
|
||||||
// Insert at original default position relative to existing columns
|
|
||||||
newOrder = [...columnOrder];
|
newOrder = [...columnOrder];
|
||||||
const defaultIdx = ALL_COLUMN_KEYS.indexOf(col);
|
const defaultIdx = ALL_COLUMN_KEYS.indexOf(col);
|
||||||
let insertAt = newOrder.length;
|
let insertAt = newOrder.length;
|
||||||
@ -6167,8 +6309,6 @@ export function App(): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Computes the user-facing status text for an item, applying business rules
|
|
||||||
* about which states are visible while the session is stopped. */
|
|
||||||
function computeDisplayedItemStatus(item: DownloadItem, sessionRunning: boolean): string {
|
function computeDisplayedItemStatus(item: DownloadItem, sessionRunning: boolean): string {
|
||||||
const statusText = String(item.fullStatus || "").trim();
|
const statusText = String(item.fullStatus || "").trim();
|
||||||
if (statusText === "Wartet") return "";
|
if (statusText === "Wartet") return "";
|
||||||
@ -6194,9 +6334,6 @@ interface ItemRowProps {
|
|||||||
onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void;
|
onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-item row, memoized so a status update on one item doesn't re-render
|
|
||||||
* every other item in the same package (the bottleneck on packages with
|
|
||||||
* many episodes). Custom equality only checks the fields actually rendered. */
|
|
||||||
const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunning, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onContextMenu }: ItemRowProps): ReactElement {
|
const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunning, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onContextMenu }: ItemRowProps): ReactElement {
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -6214,10 +6351,7 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onContextMenu(packageId, item.id, e.clientX, e.clientY);
|
onContextMenu(packageId, item.id, e.clientX, e.clientY);
|
||||||
}, [packageId, item.id, onContextMenu]);
|
}, [packageId, item.id, onContextMenu]);
|
||||||
// Memoize the date string so it doesn't get re-formatted on every re-render
|
|
||||||
// when only progress/speed changed but createdAt is stable.
|
|
||||||
const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]);
|
const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]);
|
||||||
// Memoize the displayed status so we don't compute it twice (title + body)
|
|
||||||
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
|
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
|
||||||
const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : "";
|
const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : "";
|
||||||
|
|
||||||
@ -6234,7 +6368,10 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
|
|||||||
switch (col) {
|
switch (col) {
|
||||||
case "name": return (
|
case "name": return (
|
||||||
<span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
|
<span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
|
||||||
{item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />}
|
<span
|
||||||
|
className={item.onlineStatus ? `link-status-dot ${item.onlineStatus}` : "link-status-dot link-status-dot-empty"}
|
||||||
|
title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Wird geprüft..." : undefined}
|
||||||
|
/>
|
||||||
{item.fileName}
|
{item.fileName}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -6282,7 +6419,6 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, (prev, next) => {
|
}, (prev, next) => {
|
||||||
// Skip re-render unless something visible actually changed for THIS item.
|
|
||||||
if (prev.item !== next.item) {
|
if (prev.item !== next.item) {
|
||||||
const a = prev.item;
|
const a = prev.item;
|
||||||
const b = next.item;
|
const b = next.item;
|
||||||
@ -6350,8 +6486,6 @@ interface PackageCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, sessionRunning, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, sessionRunning, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
||||||
// Single-pass aggregation: replaces 5 separate filter()/some() + 2 reduce() calls.
|
|
||||||
// For a package with N items this is O(N) instead of O(7N) per render.
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
let done = 0;
|
let done = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@ -6517,10 +6651,6 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
|
|||||||
|| prev.gridTemplate !== next.gridTemplate) {
|
|| prev.gridTemplate !== next.gridTemplate) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// selectedIds is a Set that gets a new reference on every selection change
|
|
||||||
// anywhere in the app. Only re-render this card if the selection state
|
|
||||||
// changed for an item that ACTUALLY belongs to this package — that way
|
|
||||||
// selecting an item in a different package doesn't re-render all 200+ cards.
|
|
||||||
if (prev.selectedIds !== next.selectedIds) {
|
if (prev.selectedIds !== next.selectedIds) {
|
||||||
for (const itemId of next.pkg.itemIds) {
|
for (const itemId of next.pkg.itemIds) {
|
||||||
if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) {
|
if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) {
|
||||||
|
|||||||
94
src/renderer/error-boundary.tsx
Normal file
94
src/renderer/error-boundary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -36,38 +36,26 @@ export function sortPackagesForDisplay(
|
|||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
const active: Array<{ pkg: PackageEntry; index: number; completedRatio: number; downloadedBytes: number }> = [];
|
const active: PackageEntry[] = [];
|
||||||
const rest: PackageEntry[] = [];
|
const rest: PackageEntry[] = [];
|
||||||
|
|
||||||
packages.forEach((pkg, index) => {
|
// Float packages that have an active item to the top, but keep BOTH groups in
|
||||||
const items = pkg.itemIds
|
// their original (queue) order. Earlier this sorted the active group by live
|
||||||
.map((id) => itemsById[id])
|
// completedRatio/downloadedBytes — which change on every progress tick (every
|
||||||
.filter((item): item is DownloadItem => Boolean(item));
|
// 150-700ms), so active packages visibly reshuffled the whole time. A package
|
||||||
const hasActive = items.some((item) => ACTIVE_PACKAGE_STATUSES.has(item.status));
|
// entering/leaving the active bucket is a real, discrete event (start/finish);
|
||||||
if (!hasActive) {
|
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
|
||||||
rest.push(pkg);
|
for (const pkg of packages) {
|
||||||
return;
|
const hasActive = pkg.itemIds.some((id) => {
|
||||||
}
|
const item = itemsById[id];
|
||||||
const completedRatio = items.length > 0
|
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
|
||||||
? items.filter((item) => item.status === "completed").length / items.length
|
|
||||||
: 0;
|
|
||||||
const downloadedBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
|
|
||||||
active.push({ pkg, index, completedRatio, downloadedBytes });
|
|
||||||
});
|
});
|
||||||
|
(hasActive ? active : rest).push(pkg);
|
||||||
|
}
|
||||||
|
|
||||||
if (active.length === 0 || active.length === packages.length) {
|
if (active.length === 0 || active.length === packages.length) {
|
||||||
return packages;
|
return packages;
|
||||||
}
|
}
|
||||||
|
|
||||||
active.sort((a, b) => {
|
return [...active, ...rest];
|
||||||
if (a.completedRatio !== b.completedRatio) {
|
|
||||||
return b.completedRatio - a.completedRatio;
|
|
||||||
}
|
|
||||||
if (a.downloadedBytes !== b.downloadedBytes) {
|
|
||||||
return b.downloadedBytes - a.downloadedBytes;
|
|
||||||
}
|
|
||||||
return a.index - b.index;
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...active.map((entry) => entry.pkg), ...rest];
|
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/renderer/selection.ts
Normal file
27
src/renderer/selection.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,15 +1,16 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif;
|
font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif;
|
||||||
--bg: #040912;
|
--bg: #050810;
|
||||||
--bg-glow: #10203b;
|
--bg-glow: #1e1730;
|
||||||
--surface: #0b1424;
|
--surface: #0c1322;
|
||||||
--card: #101d31;
|
--card: #12192c;
|
||||||
--field: #081120;
|
--field: #090f1c;
|
||||||
--border: #233954;
|
--border: #283447;
|
||||||
--text: #e2e8f0;
|
--text: #ece6dd;
|
||||||
--muted: #90a4bf;
|
--muted: #a59c8e;
|
||||||
--accent: #38bdf8;
|
--accent: #f2942d;
|
||||||
--danger: #f43f5e;
|
--accent-2: #ff7a5c;
|
||||||
|
--danger: #f4564b;
|
||||||
--button-bg: #0d1a2c;
|
--button-bg: #0d1a2c;
|
||||||
--button-bg-hover: #12243d;
|
--button-bg-hover: #12243d;
|
||||||
--tab-bg: #0b1321;
|
--tab-bg: #0b1321;
|
||||||
@ -33,8 +34,9 @@
|
|||||||
--field: #ffffff;
|
--field: #ffffff;
|
||||||
--border: #c7d5ea;
|
--border: #c7d5ea;
|
||||||
--text: #0f223d;
|
--text: #0f223d;
|
||||||
--muted: #4e6482;
|
--muted: #6b6052;
|
||||||
--accent: #1168d9;
|
--accent: #c2701a;
|
||||||
|
--accent-2: #d9542b;
|
||||||
--danger: #c0392b;
|
--danger: #c0392b;
|
||||||
--button-bg: #f3f7ff;
|
--button-bg: #f3f7ff;
|
||||||
--button-bg-hover: #e6efff;
|
--button-bg-hover: #e6efff;
|
||||||
@ -68,8 +70,6 @@ body,
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Menu Bar ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.menu-bar {
|
.menu-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -276,8 +276,6 @@ body,
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.control-strip {
|
.control-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -472,9 +470,9 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn.accent {
|
.btn.accent {
|
||||||
background: linear-gradient(180deg, #56d6ff, #33b9f4);
|
background: linear-gradient(180deg, #f9a948, #e9881f);
|
||||||
color: #07111c;
|
color: #1c1206;
|
||||||
border-color: #41c6f9;
|
border-color: #f0982f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.danger {
|
.btn.danger {
|
||||||
@ -658,7 +656,7 @@ body,
|
|||||||
|
|
||||||
.pkg-column-header {
|
.pkg-column-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* grid-template-columns set via inline style from columnOrder */
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
background: color-mix(in srgb, var(--card) 58%, transparent);
|
background: color-mix(in srgb, var(--card) 58%, transparent);
|
||||||
@ -697,7 +695,7 @@ body,
|
|||||||
|
|
||||||
.pkg-columns {
|
.pkg-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* grid-template-columns set via inline style from columnOrder */
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -753,7 +751,7 @@ body,
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
|
background: linear-gradient(90deg, #f2942d, #ff7a5c);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: width 0.15s ease;
|
transition: width 0.15s ease;
|
||||||
}
|
}
|
||||||
@ -1489,7 +1487,6 @@ body,
|
|||||||
margin: -2px 0 10px;
|
margin: -2px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.key-stats-popup {
|
.key-stats-popup {
|
||||||
width: min(1360px, calc(100vw - 20px));
|
width: min(1360px, calc(100vw - 20px));
|
||||||
max-width: min(1360px, calc(100vw - 20px));
|
max-width: min(1360px, calc(100vw - 20px));
|
||||||
@ -2183,7 +2180,6 @@ body,
|
|||||||
color: #0a0f1a;
|
color: #0a0f1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.pkg-toggle {
|
.pkg-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -2268,7 +2264,7 @@ body,
|
|||||||
|
|
||||||
.progress-dl {
|
.progress-dl {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
|
background: linear-gradient(90deg, #f2942d, #ff7a5c);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-ex {
|
.progress-ex {
|
||||||
@ -2276,7 +2272,6 @@ body,
|
|||||||
background: linear-gradient(90deg, #22c55e, #4ade80);
|
background: linear-gradient(90deg, #22c55e, #4ade80);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* History Tab */
|
|
||||||
.history-view {
|
.history-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -2377,7 +2372,7 @@ td {
|
|||||||
|
|
||||||
.item-row {
|
.item-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* grid-template-columns set via inline style from columnOrder */
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0 -10px;
|
margin: 0 -10px;
|
||||||
@ -2439,6 +2434,12 @@ td {
|
|||||||
background: #f59e0b;
|
background: #f59e0b;
|
||||||
box-shadow: 0 0 4px #f59e0b80;
|
box-shadow: 0 0 4px #f59e0b80;
|
||||||
}
|
}
|
||||||
|
/* Reserve the dot's footprint even before a status exists, so the filename does
|
||||||
|
not shift ~14px right when the online/offline/checking dot first appears. */
|
||||||
|
.link-status-dot-empty {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.prio-high {
|
.prio-high {
|
||||||
color: #f59e0b !important;
|
color: #f59e0b !important;
|
||||||
@ -2709,7 +2710,7 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bar-fill.completed {
|
.bar-fill.completed {
|
||||||
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
|
background: linear-gradient(90deg, #f2942d, #ff7a5c);
|
||||||
}
|
}
|
||||||
|
|
||||||
.provider-detail {
|
.provider-detail {
|
||||||
@ -3136,3 +3137,40 @@ td {
|
|||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-board-header-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.account-validity-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.account-validity-badge.ok { color: #1c1206; background: linear-gradient(90deg, #7bd88f, #4fb96a); border-color: #4fb96a; }
|
||||||
|
.account-validity-badge.free { color: #2a2113; background: #f2c14e; border-color: #d9a72f; }
|
||||||
|
.account-validity-badge.invalid { color: #fff; background: #d9534f; border-color: #c0392b; }
|
||||||
|
.account-validity-badge.unknown { color: var(--muted, #a59c8e); background: transparent; border-color: var(--line, #4a4032); }
|
||||||
|
|
||||||
|
.rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; }
|
||||||
|
.rotation-empty { color: var(--muted, #a59c8e); font-size: 12px; }
|
||||||
|
.rotation-event {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-left: 3px solid var(--line, #4a4032);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
.rotation-event.WARN { border-left-color: #f2c14e; }
|
||||||
|
.rotation-event.ERROR { border-left-color: #d9534f; }
|
||||||
|
.rotation-event.INFO { border-left-color: #4fb96a; }
|
||||||
|
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
|
||||||
|
.rotation-event .rotation-body strong { font-weight: 600; }
|
||||||
|
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); }
|
||||||
|
|||||||
2
src/renderer/vite-env.d.ts
vendored
2
src/renderer/vite-env.d.ts
vendored
@ -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 {
|
||||||
|
|||||||
@ -55,6 +55,8 @@ export const IPC_CHANNELS = {
|
|||||||
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",
|
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",
|
||||||
@ -64,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;
|
||||||
|
|||||||
@ -39,11 +39,6 @@ export function getMegaDebridAccountLabel(index: number): string {
|
|||||||
return `Account ${index + 1}`;
|
return `Account ${index + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse newline-separated "login:password" pairs.
|
|
||||||
* Falls back to treating the entire string as a single login if no colon
|
|
||||||
* is found (backward compat with old megaLogin field).
|
|
||||||
*/
|
|
||||||
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const lines = String(raw || "")
|
const lines = String(raw || "")
|
||||||
@ -60,7 +55,6 @@ export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaD
|
|||||||
login = line.slice(0, colonIdx).trim();
|
login = line.slice(0, colonIdx).trim();
|
||||||
password = line.slice(colonIdx + 1).trim();
|
password = line.slice(colonIdx + 1).trim();
|
||||||
} else {
|
} else {
|
||||||
// Legacy format: just a login, use the provided fallback password
|
|
||||||
login = line;
|
login = line;
|
||||||
password = legacyPassword;
|
password = legacyPassword;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,14 @@ import type {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
|
DebridAccountStatus,
|
||||||
DebugSetupCheckResult,
|
DebugSetupCheckResult,
|
||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
|
RendererErrorReport,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
@ -54,7 +56,7 @@ export interface ElectronApi {
|
|||||||
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 }>;
|
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
||||||
openLog: () => Promise<void>;
|
openLog: () => Promise<void>;
|
||||||
openAuditLog: () => Promise<void>;
|
openAuditLog: () => Promise<void>;
|
||||||
@ -72,6 +74,8 @@ export interface ElectronApi {
|
|||||||
importBestDebridCookies: () => Promise<number>;
|
importBestDebridCookies: () => Promise<number>;
|
||||||
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
||||||
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
|
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>;
|
||||||
@ -82,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;
|
||||||
|
|||||||
@ -250,8 +250,6 @@ export function addDebridLinkApiKeyTotalUsageBytes(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mega-Debrid per-account limits ──
|
|
||||||
|
|
||||||
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
||||||
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,6 +53,19 @@ export interface DownloadStats {
|
|||||||
runtimeMeasuredAt: 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 {
|
||||||
token: string;
|
token: string;
|
||||||
realDebridUseWebLogin: boolean;
|
realDebridUseWebLogin: boolean;
|
||||||
@ -84,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;
|
||||||
@ -116,6 +131,7 @@ export interface AppSettings {
|
|||||||
autoSkipExtracted: boolean;
|
autoSkipExtracted: boolean;
|
||||||
hideExtractedItems: boolean;
|
hideExtractedItems: boolean;
|
||||||
confirmDeleteSelection: boolean;
|
confirmDeleteSelection: boolean;
|
||||||
|
backupIncludeDownloads: boolean;
|
||||||
totalDownloadedAllTime: number;
|
totalDownloadedAllTime: number;
|
||||||
totalCompletedFilesAllTime: number;
|
totalCompletedFilesAllTime: number;
|
||||||
totalRuntimeAllTimeMs: number;
|
totalRuntimeAllTimeMs: number;
|
||||||
@ -135,6 +151,7 @@ export interface AppSettings {
|
|||||||
megaDebridAccountDailyLimitBytes: Record<string, number>;
|
megaDebridAccountDailyLimitBytes: Record<string, number>;
|
||||||
megaDebridAccountDailyUsageBytes: Record<string, number>;
|
megaDebridAccountDailyUsageBytes: Record<string, number>;
|
||||||
megaDebridAccountTotalUsageBytes: Record<string, number>;
|
megaDebridAccountTotalUsageBytes: Record<string, number>;
|
||||||
|
debridAccountStatuses: Record<string, DebridAccountStatus>;
|
||||||
providerDailyUsageDay: string;
|
providerDailyUsageDay: string;
|
||||||
scheduledStartEpochMs: number;
|
scheduledStartEpochMs: number;
|
||||||
}
|
}
|
||||||
@ -217,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;
|
||||||
@ -230,15 +260,10 @@ export interface UiSnapshot {
|
|||||||
clipboardActive: boolean;
|
clipboardActive: boolean;
|
||||||
reconnectSeconds: number;
|
reconnectSeconds: number;
|
||||||
packageSpeedBps: Record<string, number>;
|
packageSpeedBps: Record<string, number>;
|
||||||
/** When set to "delta", session.items contains ONLY items that changed since
|
|
||||||
* the last emit, and removedItemIds lists items that were removed. The
|
|
||||||
* renderer must merge these into its master state. When undefined or "full",
|
|
||||||
* session.items is the complete set (initial sync or periodic resync). */
|
|
||||||
payloadKind?: "full" | "delta";
|
payloadKind?: "full" | "delta";
|
||||||
/** Item IDs to remove from the renderer's master state when payloadKind="delta". */
|
|
||||||
removedItemIds?: string[];
|
removedItemIds?: string[];
|
||||||
/** Package IDs to remove from the renderer's master state when payloadKind="delta". */
|
|
||||||
removedPackageIds?: string[];
|
removedPackageIds?: string[];
|
||||||
|
rotationEvents?: RotationEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddLinksPayload {
|
export interface AddLinksPayload {
|
||||||
@ -476,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
335
tasks/lessons.md
Normal 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.
|
||||||
104
tasks/plan-german-audio-track.md
Normal file
104
tasks/plan-german-audio-track.md
Normal 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 **togglebarer 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
164
tasks/todo.md
Normal 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:45–11: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) — S–M. 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) — S–M. 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** — S–M. 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.164–168 (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
161
tests/account-check.test.ts
Normal 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}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
57
tests/account-rotation-log.test.ts
Normal file
57
tests/account-rotation-log.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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");
|
||||||
@ -253,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");
|
||||||
@ -271,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",
|
||||||
@ -341,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");
|
||||||
});
|
});
|
||||||
@ -370,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(":");
|
||||||
});
|
});
|
||||||
@ -650,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"],
|
||||||
@ -678,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");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -764,11 +964,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("documents malformed package name (S01GERMAN) limitation", () => {
|
it("documents malformed package name (S01GERMAN) limitation", () => {
|
||||||
// Real-world: "Drei.Meter.ueber.dem.Himmel.S01GERMAN.DL.720P.WEB.X264-WAYNE"
|
|
||||||
// is malformed (no separator between S01 and GERMAN). SCENE_SEASON_ONLY_RE
|
|
||||||
// doesn't match this, so the helper falls back to the package name as-is.
|
|
||||||
// The download-manager autoRenameExtractedVideoFiles safety net repairs
|
|
||||||
// this at runtime by inserting the source's episode token.
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||||
[
|
[
|
||||||
"3MH.web.7p-101",
|
"3MH.web.7p-101",
|
||||||
@ -777,10 +972,118 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
"Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE",
|
"Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE",
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
{ forceEpisodeForSeasonFolder: true }
|
||||||
);
|
);
|
||||||
// Helper limitation: returns the malformed folder name unchanged.
|
|
||||||
// The download-manager safety net catches this at runtime.
|
|
||||||
if (result !== null) {
|
if (result !== null) {
|
||||||
expect(typeof result).toBe("string");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -20,7 +20,6 @@ describe("backup-crypto", () => {
|
|||||||
const plaintext = JSON.stringify({ settings: { token: secret } });
|
const plaintext = JSON.stringify({ settings: { token: secret } });
|
||||||
const encrypted = encryptBackup(plaintext);
|
const encrypted = encryptBackup(plaintext);
|
||||||
|
|
||||||
// The encrypted buffer should NOT contain the secret in plaintext
|
|
||||||
expect(encrypted.toString("utf8")).not.toContain(secret);
|
expect(encrypted.toString("utf8")).not.toContain(secret);
|
||||||
expect(encrypted.toString("latin1")).not.toContain(secret);
|
expect(encrypted.toString("latin1")).not.toContain(secret);
|
||||||
});
|
});
|
||||||
@ -34,9 +33,7 @@ describe("backup-crypto", () => {
|
|||||||
const plaintext = "same input data";
|
const plaintext = "same input data";
|
||||||
const a = encryptBackup(plaintext);
|
const a = encryptBackup(plaintext);
|
||||||
const b = encryptBackup(plaintext);
|
const b = encryptBackup(plaintext);
|
||||||
// IVs are different, so full buffers must differ
|
|
||||||
expect(a.equals(b)).toBe(false);
|
expect(a.equals(b)).toBe(false);
|
||||||
// But both decrypt to the same plaintext
|
|
||||||
expect(decryptBackup(a)).toBe(plaintext);
|
expect(decryptBackup(a)).toBe(plaintext);
|
||||||
expect(decryptBackup(b)).toBe(plaintext);
|
expect(decryptBackup(b)).toBe(plaintext);
|
||||||
});
|
});
|
||||||
@ -49,7 +46,6 @@ describe("backup-crypto", () => {
|
|||||||
|
|
||||||
it("throws on corrupted ciphertext", () => {
|
it("throws on corrupted ciphertext", () => {
|
||||||
const encrypted = encryptBackup("test data");
|
const encrypted = encryptBackup("test data");
|
||||||
// Flip a byte in the ciphertext area
|
|
||||||
const corrupted = Buffer.from(encrypted);
|
const corrupted = Buffer.from(encrypted);
|
||||||
corrupted[corrupted.length - 1] ^= 0xff;
|
corrupted[corrupted.length - 1] ^= 0xff;
|
||||||
expect(() => decryptBackup(corrupted)).toThrow();
|
expect(() => decryptBackup(corrupted)).toThrow();
|
||||||
|
|||||||
81
tests/backup-payload.test.ts
Normal file
81
tests/backup-payload.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -73,7 +73,6 @@ describe("bestdebrid-web", () => {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(filePath, { force: true });
|
fs.rmSync(filePath, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore temp cleanup failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
|
||||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||||
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
import { DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, normalizeResolvedFilename, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
import { clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@ -10,6 +11,7 @@ afterEach(() => {
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
resetDebridLinkRuntimeStateForTests();
|
resetDebridLinkRuntimeStateForTests();
|
||||||
resetMegaDebridRuntimeStateForTests();
|
resetMegaDebridRuntimeStateForTests();
|
||||||
|
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -423,14 +425,10 @@ describe("debrid service", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only count calls to /downloader/add (the unrestrict endpoint)
|
|
||||||
if (url.includes("/downloader/add")) {
|
if (url.includes("/downloader/add")) {
|
||||||
unrestrictAuthHeaders.push(authHeader);
|
unrestrictAuthHeaders.push(authHeader);
|
||||||
// Read the body to know which link is being unrestricted
|
|
||||||
const bodyText = init?.body ? String(init.body) : "";
|
const bodyText = init?.body ? String(init.body) : "";
|
||||||
const isRapidgator = /rapidgator/i.test(bodyText);
|
const isRapidgator = /rapidgator/i.test(bodyText);
|
||||||
// Only key-one + rapidgator returns maxDataHost. All other (key, host)
|
|
||||||
// combinations succeed.
|
|
||||||
if (authHeader === "Bearer dl-key-one" && isRapidgator) {
|
if (authHeader === "Bearer dl-key-one" && isRapidgator) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
@ -453,19 +451,14 @@ describe("debrid service", () => {
|
|||||||
|
|
||||||
const service = new DebridService(settings);
|
const service = new DebridService(settings);
|
||||||
|
|
||||||
// 1) First rapidgator: key-one hits maxDataHost → key-two succeeds.
|
|
||||||
const r1 = await service.unrestrictLink("https://rapidgator.net/file/first");
|
const r1 = await service.unrestrictLink("https://rapidgator.net/file/first");
|
||||||
expect(r1.providerLabel).toContain("Key 2");
|
expect(r1.providerLabel).toContain("Key 2");
|
||||||
|
|
||||||
// 2) Second rapidgator request: key-one MUST be skipped (host cooldown
|
|
||||||
// on (key1, rapidgator)), only key-two should be tried.
|
|
||||||
unrestrictAuthHeaders.length = 0;
|
unrestrictAuthHeaders.length = 0;
|
||||||
const r2 = await service.unrestrictLink("https://rapidgator.net/file/second");
|
const r2 = await service.unrestrictLink("https://rapidgator.net/file/second");
|
||||||
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-two"]);
|
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-two"]);
|
||||||
expect(r2.providerLabel).toContain("Key 2");
|
expect(r2.providerLabel).toContain("Key 2");
|
||||||
|
|
||||||
// 3) Different host: key-one must NOT be skipped — its host-cooldown is
|
|
||||||
// only for rapidgator, not for uploaded.net.
|
|
||||||
unrestrictAuthHeaders.length = 0;
|
unrestrictAuthHeaders.length = 0;
|
||||||
const r3 = await service.unrestrictLink("https://uploaded.net/file/third");
|
const r3 = await service.unrestrictLink("https://uploaded.net/file/third");
|
||||||
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-one"]);
|
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-one"]);
|
||||||
@ -522,10 +515,7 @@ describe("debrid service", () => {
|
|||||||
const result = await service.unrestrictLink("https://rapidgator.net/file/example");
|
const result = await service.unrestrictLink("https://rapidgator.net/file/example");
|
||||||
expect(result.providerLabel).toContain("Key 2");
|
expect(result.providerLabel).toContain("Key 2");
|
||||||
|
|
||||||
// Key-one responded normally — just that the link was unavailable on the
|
|
||||||
// hoster side. Key-one is NOT broken and must not be flagged as "error".
|
|
||||||
expect(getDebridLinkKeyRuntimeStateForTests(key1Id)).not.toBe("error");
|
expect(getDebridLinkKeyRuntimeStateForTests(key1Id)).not.toBe("error");
|
||||||
// Key-two served the link successfully, so it's "ready".
|
|
||||||
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready");
|
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -693,7 +683,6 @@ describe("debrid service", () => {
|
|||||||
|
|
||||||
const service = new DebridService(settings);
|
const service = new DebridService(settings);
|
||||||
await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/debrid_link_cooldown.*notDebrid/);
|
await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/debrid_link_cooldown.*notDebrid/);
|
||||||
// notDebrid is a host-level issue — only Key 1 should be tried, Key 2 must NOT be burned
|
|
||||||
expect(authHeaders).toEqual(["Bearer dl-key-one"]);
|
expect(authHeaders).toEqual(["Bearer dl-key-one"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1266,7 +1255,6 @@ describe("debrid service", () => {
|
|||||||
autoProviderFallback: true
|
autoProviderFallback: true
|
||||||
};
|
};
|
||||||
|
|
||||||
// API returns 404 for connectUser → API fails, falls back to web
|
|
||||||
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
|
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
|
||||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||||
|
|
||||||
@ -1365,7 +1353,6 @@ describe("debrid service", () => {
|
|||||||
autoProviderFallback: false
|
autoProviderFallback: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// API connect fails fast → falls through to web fallback
|
|
||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<never> => new Promise((_, reject) => {
|
const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<never> => new Promise((_, reject) => {
|
||||||
@ -1392,6 +1379,340 @@ describe("debrid service", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
|
let webCalls = 0;
|
||||||
|
const megaWeb = vi.fn(async (_link: string, _signal?: AbortSignal) => {
|
||||||
|
webCalls += 1;
|
||||||
|
if (webCalls <= 3) {
|
||||||
|
throw new Error("Mega-Web: daily limit reached (Tageslimit erreicht)");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fileName: "rotated-to-acc2.rar",
|
||||||
|
directUrl: "https://mega-web.example/rotated-to-acc2.rar",
|
||||||
|
fileSize: null,
|
||||||
|
retriesUsed: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
const result = await service.unrestrictLink("https://rapidgator.net/file/limit-rotation-test");
|
||||||
|
|
||||||
|
expect(result.directUrl).toBe("https://mega-web.example/rotated-to-acc2.rar");
|
||||||
|
expect(webCalls).toBeGreaterThanOrEqual(4);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
it("skips a manually disabled Mega-Debrid account and uses the next one", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
|
megaDebridDisabledAccountIds: [getMegaDebridAccountId("user1")],
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
|
const megaWeb = vi.fn(async () => ({
|
||||||
|
fileName: "from-acc2.rar",
|
||||||
|
directUrl: "https://mega-web.example/from-acc2.rar",
|
||||||
|
fileSize: null,
|
||||||
|
retriesUsed: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
const result = await service.unrestrictLink("https://rapidgator.net/file/disabled-acc-test");
|
||||||
|
|
||||||
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||||
|
expect(result.directUrl).toBe("https://mega-web.example/from-acc2.rar");
|
||||||
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
it("fails fast on Mega-Debrid hoster quota ('Kein Server') and rotates to the next account", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
|
let calls = 0;
|
||||||
|
const megaWeb = vi.fn(async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) {
|
||||||
|
throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.");
|
||||||
|
}
|
||||||
|
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
const result = await service.unrestrictLink("https://rapidgator.net/file/quota-rotate-test");
|
||||||
|
|
||||||
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||||
|
expect(result.directUrl).toBe("https://mega-web.example/acc2.rar");
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
it("passes each account's OWN credentials to the Mega web unrestrict during rotation", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
|
const accountsSeen: Array<string | undefined> = [];
|
||||||
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
||||||
|
accountsSeen.push(account?.login);
|
||||||
|
if (account?.login === "user1") {
|
||||||
|
throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.");
|
||||||
|
}
|
||||||
|
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
const result = await service.unrestrictLink("https://rapidgator.net/file/per-account-creds");
|
||||||
|
|
||||||
|
expect(accountsSeen).toContain("user1");
|
||||||
|
expect(accountsSeen).toContain("user2");
|
||||||
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||||
|
expect(result.directUrl).toBe("https://mega-web.example/ok.rar");
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
|
||||||
|
const key = `${getMegaDebridAccountId("user1")}:web`;
|
||||||
|
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3);
|
||||||
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
||||||
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
|
||||||
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
|
||||||
|
clearMegaDebridEmptyResponseStreak(key);
|
||||||
|
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an 'until restart' park active forever (never expires until process restart)", () => {
|
||||||
|
const key = `${getMegaDebridAccountId("user1")}:api`;
|
||||||
|
primeMegaDebridUntilRestartForTests(key);
|
||||||
|
const now = getMegaDebridAccountCooldownState(key);
|
||||||
|
expect(now?.untilRestart).toBe(true);
|
||||||
|
const farFuture = Date.now() + 100 * 24 * 60 * 60 * 1000;
|
||||||
|
expect(getMegaDebridAccountCooldownState(key, farFuture)?.untilRestart).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
|
const user1 = getMegaDebridAccountId("user1");
|
||||||
|
primeMegaDebridUntilRestartForTests(`${user1}:api`);
|
||||||
|
primeMegaDebridUntilRestartForTests(`${user1}:web`);
|
||||||
|
|
||||||
|
const loginsSeen: Array<string | undefined> = [];
|
||||||
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
||||||
|
loginsSeen.push(account?.login);
|
||||||
|
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
const result = await service.unrestrictLink("https://rapidgator.net/file/parked-skip-test");
|
||||||
|
|
||||||
|
expect(loginsSeen).not.toContain("user1");
|
||||||
|
expect(loginsSeen).toContain("user2");
|
||||||
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
it("fails terminally (no retry timer) when ALL Mega-Debrid accounts are parked until restart", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
|
for (const login of ["user1", "user2"]) {
|
||||||
|
const id = getMegaDebridAccountId(login);
|
||||||
|
primeMegaDebridUntilRestartForTests(`${id}:api`);
|
||||||
|
primeMegaDebridUntilRestartForTests(`${id}:web`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const megaWeb = vi.fn(async () => ({ fileName: "x.rar", directUrl: "https://mega-web.example/x.rar", fileSize: null, retriesUsed: 0 }));
|
||||||
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
|
||||||
|
await expect(service.unrestrictLink("https://rapidgator.net/file/all-parked-test")).rejects.toThrow(/bis Neustart gesperrt/i);
|
||||||
|
expect(megaWeb).not.toHaveBeenCalled();
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
it("drives a real empty response through the full rotation into an until-restart park (wiring test)", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1",
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
|
const key = `${getMegaDebridAccountId("user1")}:web`;
|
||||||
|
recordMegaDebridEmptyResponseStreak(key);
|
||||||
|
recordMegaDebridEmptyResponseStreak(key);
|
||||||
|
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
|
||||||
|
|
||||||
|
const megaWeb = vi.fn(async () => null);
|
||||||
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
await service.unrestrictLink("https://rapidgator.net/file/wiring").catch(() => undefined);
|
||||||
|
|
||||||
|
expect(megaWeb).toHaveBeenCalled();
|
||||||
|
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
|
||||||
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
|
const loginsSeen: Array<string | undefined> = [];
|
||||||
|
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
|
||||||
|
loginsSeen.push(account?.login);
|
||||||
|
if (account?.login === "user1") {
|
||||||
|
throw new Error("aborted:debrid");
|
||||||
|
}
|
||||||
|
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
|
||||||
|
});
|
||||||
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
||||||
|
|
||||||
|
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down.
|
||||||
|
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow();
|
||||||
|
expect(loginsSeen).toContain("user1");
|
||||||
|
expect(loginsSeen).not.toContain("user2");
|
||||||
|
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
|
||||||
|
|
||||||
|
// Call 2 (the retry, same state): account 1 is on cooldown -> skipped -> account 2 served.
|
||||||
|
loginsSeen.length = 0;
|
||||||
|
const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-2");
|
||||||
|
expect(loginsSeen).not.toContain("user1");
|
||||||
|
expect(loginsSeen).toContain("user2");
|
||||||
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
|
||||||
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
|
const megaWeb = vi.fn(async () => { throw new Error("aborted:debrid"); });
|
||||||
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
||||||
|
|
||||||
|
await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow();
|
||||||
|
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
it("respects provider selection and does not append hidden providers", async () => {
|
it("respects provider selection and does not append hidden providers", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@ -1762,11 +2083,9 @@ describe("normalizeResolvedFilename", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("strips HTML tags and collapses whitespace", () => {
|
it("strips HTML tags and collapses whitespace", () => {
|
||||||
// Tags are replaced by spaces, then multiple spaces collapsed
|
|
||||||
const result = normalizeResolvedFilename("<b>Show.S01E01</b>.part01.rar");
|
const result = normalizeResolvedFilename("<b>Show.S01E01</b>.part01.rar");
|
||||||
expect(result).toBe("Show.S01E01 .part01.rar");
|
expect(result).toBe("Show.S01E01 .part01.rar");
|
||||||
|
|
||||||
// Entity decoding happens before tag removal, so <...> becomes <...> then gets stripped
|
|
||||||
const entityTagResult = normalizeResolvedFilename("File<Tag>.part1.rar");
|
const entityTagResult = normalizeResolvedFilename("File<Tag>.part1.rar");
|
||||||
expect(entityTagResult).toBe("File .part1.rar");
|
expect(entityTagResult).toBe("File .part1.rar");
|
||||||
});
|
});
|
||||||
@ -1789,7 +2108,6 @@ describe("normalizeResolvedFilename", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles combined transforms", () => {
|
it("handles combined transforms", () => {
|
||||||
// "Download file" prefix stripped, & decoded to &, "- Rapidgator" suffix stripped
|
|
||||||
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar - Rapidgator"))
|
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar - Rapidgator"))
|
||||||
.toBe("Show.S01E01.part01.rar");
|
.toBe("Show.S01E01.part01.rar");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -78,7 +78,6 @@ async function waitForReady(url: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// retry
|
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
}
|
}
|
||||||
@ -314,7 +313,6 @@ afterEach(() => {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore cleanup failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
124
tests/desktop-rename-log.test.ts
Normal file
124
tests/desktop-rename-log.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
tests/download-completion.test.ts
Normal file
61
tests/download-completion.test.ts
Normal 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
44
tests/error-ring.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -865,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");
|
||||||
@ -942,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");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -957,7 +955,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));
|
||||||
// .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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1100,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));
|
||||||
@ -1127,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");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1144,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,
|
||||||
|
|||||||
49
tests/fs-error.test.ts
Normal file
49
tests/fs-error.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
150
tests/german-audio-integration.test.ts
Normal file
150
tests/german-audio-integration.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,7 +30,6 @@ 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"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -67,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");
|
||||||
@ -76,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"
|
||||||
|
|||||||
23
tests/log-timestamp.test.ts
Normal file
23
tests/log-timestamp.test.ts
Normal 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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
178
tests/mega-public-api.test.ts
Normal file
178
tests/mega-public-api.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -44,23 +44,50 @@ function createItem(id: string, packageId: string, status: DownloadItem["status"
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("sortPackagesForDisplay", () => {
|
describe("sortPackagesForDisplay", () => {
|
||||||
it("moves active packages with more progress to the top when auto sort is enabled", () => {
|
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 = [
|
const packages = [
|
||||||
createPackage("pkg-a", ["a1", "a2"]),
|
createPackage("pkg-a", ["a1", "a2"]),
|
||||||
createPackage("pkg-b", ["b1", "b2"]),
|
createPackage("pkg-c", ["c1"]),
|
||||||
createPackage("pkg-c", ["c1"])
|
createPackage("pkg-b", ["b1", "b2"])
|
||||||
];
|
];
|
||||||
const items: Record<string, DownloadItem> = {
|
const items: Record<string, DownloadItem> = {
|
||||||
a1: createItem("a1", "pkg-a", "downloading", 250),
|
a1: createItem("a1", "pkg-a", "downloading", 250),
|
||||||
a2: createItem("a2", "pkg-a", "completed", 500),
|
a2: createItem("a2", "pkg-a", "completed", 500),
|
||||||
|
c1: createItem("c1", "pkg-c", "queued", 0),
|
||||||
b1: createItem("b1", "pkg-b", "downloading", 800),
|
b1: createItem("b1", "pkg-b", "downloading", 800),
|
||||||
b2: createItem("b2", "pkg-b", "completed", 900),
|
b2: createItem("b2", "pkg-b", "completed", 900)
|
||||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
||||||
|
|
||||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-a", "pkg-c"]);
|
// 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", () => {
|
it("keeps package order untouched when auto sort is disabled", () => {
|
||||||
|
|||||||
@ -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
44
tests/selection.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
|||||||
132
tests/session-restart-loss.test.ts
Normal file
132
tests/session-restart-loss.test.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { DownloadItem, PackageEntry, SessionState } from "../src/shared/types";
|
||||||
|
import {
|
||||||
|
cancelPendingAsyncSaves,
|
||||||
|
createStoragePaths,
|
||||||
|
emptySession,
|
||||||
|
loadSession,
|
||||||
|
saveSession,
|
||||||
|
saveSessionAsync
|
||||||
|
} from "../src/main/storage";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function makePackage(id: string, itemId: string): PackageEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Package ${id}`,
|
||||||
|
outputDir: "C:/tmp/out",
|
||||||
|
extractDir: "C:/tmp/extract",
|
||||||
|
status: "queued",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
downloadStartedAt: 0,
|
||||||
|
downloadCompletedAt: 0,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeItem(id: string, packageId: string): DownloadItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
packageId,
|
||||||
|
url: `https://example.com/${id}`,
|
||||||
|
provider: null,
|
||||||
|
status: "queued",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progressPercent: 0,
|
||||||
|
fileName: `${id}.rar`,
|
||||||
|
targetPath: "",
|
||||||
|
resumable: true,
|
||||||
|
attempts: 0,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Wartet",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionWith(ids: string[]): SessionState {
|
||||||
|
const s = emptySession();
|
||||||
|
for (const id of ids) {
|
||||||
|
const itemId = `${id}-item`;
|
||||||
|
s.packageOrder.push(id);
|
||||||
|
s.packages[id] = makePackage(id, itemId);
|
||||||
|
s.items[itemId] = makeItem(itemId, id);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settle = (ms = 250): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
describe("session restart loss", () => {
|
||||||
|
it("does not let a queued stale async save clobber a newer synchronous save", async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
|
||||||
|
cancelPendingAsyncSaves();
|
||||||
|
await settle(50);
|
||||||
|
|
||||||
|
saveSession(paths, sessionWith(["A", "B"]));
|
||||||
|
|
||||||
|
const inflight = saveSessionAsync(paths, sessionWith(["A", "B"]));
|
||||||
|
const queued = saveSessionAsync(paths, sessionWith(["A", "B"]));
|
||||||
|
saveSession(paths, sessionWith(["A", "B", "C"]));
|
||||||
|
|
||||||
|
await inflight;
|
||||||
|
await queued;
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const loaded = loadSession(paths);
|
||||||
|
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B", "C"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers packages from the backup when the primary session file is absent", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
|
||||||
|
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
|
||||||
|
expect(fs.existsSync(paths.sessionFile)).toBe(false);
|
||||||
|
|
||||||
|
const loaded = loadSession(paths);
|
||||||
|
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still treats a truly fresh install (no primary, no backup, no temp) as empty", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
|
||||||
|
const loaded = loadSession(paths);
|
||||||
|
expect(Object.keys(loaded.packages)).toEqual([]);
|
||||||
|
expect(Object.keys(loaded.items)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers from the backup when the primary exists but is empty", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const paths = createStoragePaths(dir);
|
||||||
|
|
||||||
|
fs.writeFileSync(paths.sessionFile, JSON.stringify(emptySession()), "utf8");
|
||||||
|
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
|
||||||
|
|
||||||
|
const loaded = loadSession(paths);
|
||||||
|
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -13,7 +13,6 @@ afterEach(() => {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -88,7 +87,6 @@ describe("runStartupHealthCheck", () => {
|
|||||||
it("flags large state files", () => {
|
it("flags large state files", () => {
|
||||||
const { outputDir, paths } = makeTempBase();
|
const { outputDir, paths } = makeTempBase();
|
||||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||||
// 60 MB dummy state file, threshold is 50 MB
|
|
||||||
fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0));
|
fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0));
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
@ -103,7 +101,6 @@ describe("runStartupHealthCheck", () => {
|
|||||||
|
|
||||||
it("flags missing base dir as ERROR", () => {
|
it("flags missing base dir as ERROR", () => {
|
||||||
const { outputDir, paths } = makeTempBase();
|
const { outputDir, paths } = makeTempBase();
|
||||||
// Intentionally DON'T create baseDir.
|
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
|
|||||||
@ -453,19 +453,13 @@ describe("settings storage", () => {
|
|||||||
saveSession(paths, session);
|
saveSession(paths, session);
|
||||||
const loaded = loadSession(paths);
|
const loaded = loadSession(paths);
|
||||||
|
|
||||||
// Active statuses (downloading, paused) should be reset to "queued"
|
|
||||||
expect(loaded.items["item1"].status).toBe("queued");
|
expect(loaded.items["item1"].status).toBe("queued");
|
||||||
expect(loaded.items["item2"].status).toBe("queued");
|
expect(loaded.items["item2"].status).toBe("queued");
|
||||||
// Speed should be cleared
|
|
||||||
expect(loaded.items["item1"].speedBps).toBe(0);
|
expect(loaded.items["item1"].speedBps).toBe(0);
|
||||||
// lastError should be cleared for reset items
|
|
||||||
expect(loaded.items["item1"].lastError).toBe("");
|
expect(loaded.items["item1"].lastError).toBe("");
|
||||||
// Completed and queued statuses should be preserved
|
|
||||||
expect(loaded.items["item3"].status).toBe("completed");
|
expect(loaded.items["item3"].status).toBe("completed");
|
||||||
expect(loaded.items["item4"].status).toBe("queued");
|
expect(loaded.items["item4"].status).toBe("queued");
|
||||||
// Downloaded bytes should be preserved
|
|
||||||
expect(loaded.items["item1"].downloadedBytes).toBe(5000);
|
expect(loaded.items["item1"].downloadedBytes).toBe(5000);
|
||||||
// Package data should be preserved
|
|
||||||
expect(loaded.packages["pkg1"].name).toBe("Test Package");
|
expect(loaded.packages["pkg1"].name).toBe("Test Package");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -542,7 +536,6 @@ describe("settings storage", () => {
|
|||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const paths = createStoragePaths(dir);
|
const paths = createStoragePaths(dir);
|
||||||
|
|
||||||
// Write invalid JSON to the config file
|
|
||||||
fs.writeFileSync(paths.configFile, "{{{{not valid json!!!}", "utf8");
|
fs.writeFileSync(paths.configFile, "{{{{not valid json!!!}", "utf8");
|
||||||
|
|
||||||
const loaded = loadSettings(paths);
|
const loaded = loadSettings(paths);
|
||||||
@ -728,7 +721,6 @@ describe("settings storage", () => {
|
|||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const paths = createStoragePaths(dir);
|
const paths = createStoragePaths(dir);
|
||||||
|
|
||||||
// Write a minimal config that simulates an old version missing newer fields
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
paths.configFile,
|
paths.configFile,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@ -742,11 +734,9 @@ describe("settings storage", () => {
|
|||||||
const loaded = loadSettings(paths);
|
const loaded = loadSettings(paths);
|
||||||
const defaults = defaultSettings();
|
const defaults = defaultSettings();
|
||||||
|
|
||||||
// Old fields should be preserved
|
|
||||||
expect(loaded.token).toBe("my-token");
|
expect(loaded.token).toBe("my-token");
|
||||||
expect(loaded.outputDir).toBe(path.resolve("/custom/output"));
|
expect(loaded.outputDir).toBe(path.resolve("/custom/output"));
|
||||||
|
|
||||||
// Missing new fields should get default values
|
|
||||||
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);
|
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);
|
||||||
expect(loaded.hybridExtract).toBe(defaults.hybridExtract);
|
expect(loaded.hybridExtract).toBe(defaults.hybridExtract);
|
||||||
expect(loaded.completedCleanupPolicy).toBe(defaults.completedCleanupPolicy);
|
expect(loaded.completedCleanupPolicy).toBe(defaults.completedCleanupPolicy);
|
||||||
|
|||||||
172
tests/update-restart-resume.test.ts
Normal file
172
tests/update-restart-resume.test.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import http from "node:http";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { DownloadManager } from "../src/main/download-manager";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import { createStoragePaths, emptySession, loadSession } from "../src/main/storage";
|
||||||
|
import { shutdownItemLogs } from "../src/main/item-log";
|
||||||
|
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
shutdownItemLogs();
|
||||||
|
shutdownPackageLogs();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise<void> {
|
||||||
|
const started = Date.now();
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() - started > timeoutMs) {
|
||||||
|
throw new Error("waitFor timeout");
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTricklingServer(): Promise<{ directUrl: string; stop: () => Promise<void> }> {
|
||||||
|
const openTimers = new Set<NodeJS.Timeout>();
|
||||||
|
const openResponses = new Set<http.ServerResponse>();
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if ((req.url || "") !== "/direct") {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not-found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(64 * 1024 * 1024));
|
||||||
|
openResponses.add(res);
|
||||||
|
res.write(Buffer.alloc(64 * 1024, 7));
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(Buffer.alloc(16 * 1024, 9));
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
openTimers.add(timer);
|
||||||
|
res.on("close", () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
openTimers.delete(timer);
|
||||||
|
openResponses.delete(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const directUrl = `http://127.0.0.1:${address.port}/direct`;
|
||||||
|
const stop = async (): Promise<void> => {
|
||||||
|
for (const timer of openTimers) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
openTimers.clear();
|
||||||
|
for (const res of openResponses) {
|
||||||
|
try {
|
||||||
|
res.destroy();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openResponses.clear();
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
};
|
||||||
|
return { directUrl, stop };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockUnrestrict(directUrl: string): void {
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ download: directUrl, filename: "episode.mkv", filesize: 64 * 1024 * 1024 }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function driveActiveDownload(root: string): Promise<{ manager: DownloadManager; paths: ReturnType<typeof createStoragePaths>; serverStop: () => Promise<void> }> {
|
||||||
|
const { directUrl, stop: serverStop } = await startTricklingServer();
|
||||||
|
mockUnrestrict(directUrl);
|
||||||
|
const paths = createStoragePaths(path.join(root, "state"));
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false,
|
||||||
|
retryLimit: 0
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
paths
|
||||||
|
);
|
||||||
|
manager.addPackages([{ name: "park", links: ["https://dummy/park"] }]);
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => {
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
return item?.status === "downloading" && (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size > 0;
|
||||||
|
});
|
||||||
|
return { manager, paths, serverStop };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("update restart resume", () => {
|
||||||
|
it("characterization: a plain stop() leaves an in-flight item cancelled across a restart", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, paths, serverStop } = await driveActiveDownload(root);
|
||||||
|
try {
|
||||||
|
manager.stop();
|
||||||
|
manager.persistNowSync();
|
||||||
|
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
|
||||||
|
manager.prepareForShutdown();
|
||||||
|
|
||||||
|
const reloaded = loadSession(paths);
|
||||||
|
const item = Object.values(reloaded.items)[0];
|
||||||
|
expect(item).toBeTruthy();
|
||||||
|
expect(item.status).toBe("cancelled");
|
||||||
|
} finally {
|
||||||
|
await serverStop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parks an in-flight item as queued for an update restart so it auto-resumes", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const { manager, paths, serverStop } = await driveActiveDownload(root);
|
||||||
|
try {
|
||||||
|
manager.stop({ parkForRestart: true });
|
||||||
|
manager.persistNowSync();
|
||||||
|
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
|
||||||
|
manager.prepareForShutdown();
|
||||||
|
|
||||||
|
const reloaded = loadSession(paths);
|
||||||
|
const item = Object.values(reloaded.items)[0];
|
||||||
|
expect(item).toBeTruthy();
|
||||||
|
expect(Object.keys(reloaded.packages).length).toBe(1);
|
||||||
|
expect(item.status).toBe("queued");
|
||||||
|
} finally {
|
||||||
|
await serverStop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -614,7 +614,6 @@ describe("parseVersionParts", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles version with pre-release suffix", () => {
|
it("handles version with pre-release suffix", () => {
|
||||||
// Non-numeric suffixes are stripped per part
|
|
||||||
expect(parseVersionParts("1.2.3-beta")).toEqual([1, 2, 3]);
|
expect(parseVersionParts("1.2.3-beta")).toEqual([1, 2, 3]);
|
||||||
expect(parseVersionParts("1.2.3rc1")).toEqual([1, 2, 3]);
|
expect(parseVersionParts("1.2.3rc1")).toEqual([1, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -92,7 +92,6 @@ describe("utils", () => {
|
|||||||
const result = sanitizeFilename(longName);
|
const result = sanitizeFilename(longName);
|
||||||
expect(typeof result).toBe("string");
|
expect(typeof result).toBe("string");
|
||||||
expect(result.length).toBeGreaterThan(0);
|
expect(result.length).toBeGreaterThan(0);
|
||||||
// The function should return a non-empty string and not crash
|
|
||||||
expect(result).toBe(longName);
|
expect(result).toBe(longName);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -100,7 +99,6 @@ describe("utils", () => {
|
|||||||
const result = formatEta(999999);
|
const result = formatEta(999999);
|
||||||
expect(typeof result).toBe("string");
|
expect(typeof result).toBe("string");
|
||||||
expect(result.length).toBeGreaterThan(0);
|
expect(result.length).toBeGreaterThan(0);
|
||||||
// 999999 seconds = 277h 46m 39s
|
|
||||||
expect(result).toBe("277:46:39");
|
expect(result).toBe("277:46:39");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,28 +111,22 @@ describe("utils", () => {
|
|||||||
|
|
||||||
it("extracts filenames from URLs with encoded characters", () => {
|
it("extracts filenames from URLs with encoded characters", () => {
|
||||||
expect(filenameFromUrl("https://example.com/file%20with%20spaces.rar")).toBe("file with spaces.rar");
|
expect(filenameFromUrl("https://example.com/file%20with%20spaces.rar")).toBe("file with spaces.rar");
|
||||||
// %C3%A9 decodes to e-acute (UTF-8), which is preserved
|
|
||||||
expect(filenameFromUrl("https://example.com/t%C3%A9st%20file.zip")).toBe("t\u00e9st file.zip");
|
expect(filenameFromUrl("https://example.com/t%C3%A9st%20file.zip")).toBe("t\u00e9st file.zip");
|
||||||
expect(filenameFromUrl("https://example.com/dl?filename=Movie%20Name%20S01E01.mkv")).toBe("Movie Name S01E01.mkv");
|
expect(filenameFromUrl("https://example.com/dl?filename=Movie%20Name%20S01E01.mkv")).toBe("Movie Name S01E01.mkv");
|
||||||
// Malformed percent-encoding should not crash
|
|
||||||
const result = filenameFromUrl("https://example.com/%ZZ%invalid");
|
const result = filenameFromUrl("https://example.com/%ZZ%invalid");
|
||||||
expect(typeof result).toBe("string");
|
expect(typeof result).toBe("string");
|
||||||
expect(result.length).toBeGreaterThan(0);
|
expect(result.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles looksLikeOpaqueFilename edge cases", () => {
|
it("handles looksLikeOpaqueFilename edge cases", () => {
|
||||||
// Empty string -> sanitizeFilename returns "Paket" which is not opaque
|
|
||||||
expect(looksLikeOpaqueFilename("")).toBe(false);
|
expect(looksLikeOpaqueFilename("")).toBe(false);
|
||||||
expect(looksLikeOpaqueFilename("a")).toBe(false);
|
expect(looksLikeOpaqueFilename("a")).toBe(false);
|
||||||
expect(looksLikeOpaqueFilename("ab")).toBe(false);
|
expect(looksLikeOpaqueFilename("ab")).toBe(false);
|
||||||
expect(looksLikeOpaqueFilename("abc")).toBe(false);
|
expect(looksLikeOpaqueFilename("abc")).toBe(false);
|
||||||
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
|
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
|
||||||
// 24-char hex string is opaque (matches /^[a-f0-9]{24,}$/)
|
|
||||||
expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true);
|
expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true);
|
||||||
expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true);
|
expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true);
|
||||||
// Short hex strings (< 24 chars) are NOT considered opaque
|
|
||||||
expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false);
|
expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false);
|
||||||
// Real filename with extension
|
|
||||||
expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false);
|
expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
348
tests/video-processor.test.ts
Normal file
348
tests/video-processor.test.ts
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
stripDualLangMarker,
|
||||||
|
hasDualLangMarker,
|
||||||
|
isRemuxableVideoFile,
|
||||||
|
looksLikeGermanRelease,
|
||||||
|
pickAudioTrack,
|
||||||
|
parseFfprobeAudioStreams,
|
||||||
|
buildFfprobeArgs,
|
||||||
|
buildFfmpegRemuxArgs,
|
||||||
|
computeRemuxTimeoutMs,
|
||||||
|
processVideoFile,
|
||||||
|
renameWithRetry,
|
||||||
|
type VideoSpawnResult
|
||||||
|
} from "../src/main/video-processor";
|
||||||
|
|
||||||
|
describe("stripDualLangMarker", () => {
|
||||||
|
it("strips a mid-name .DL. token", () => {
|
||||||
|
expect(stripDualLangMarker("Show.S01E01.German.DL.720p.WEB.x264.mkv")).toBe("Show.S01E01.German.720p.WEB.x264.mkv");
|
||||||
|
});
|
||||||
|
it("strips a .DL. directly before the extension", () => {
|
||||||
|
expect(stripDualLangMarker("Movie.DL.mkv")).toBe("Movie.mkv");
|
||||||
|
});
|
||||||
|
it("strips a trailing .DL token before extension", () => {
|
||||||
|
expect(stripDualLangMarker("Movie.German.DL.mp4")).toBe("Movie.German.mp4");
|
||||||
|
});
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
expect(stripDualLangMarker("Show.dl.1080p.mkv")).toBe("Show.1080p.mkv");
|
||||||
|
});
|
||||||
|
it("leaves files without the marker unchanged", () => {
|
||||||
|
expect(stripDualLangMarker("Show.S01E01.German.1080p.mkv")).toBe("Show.S01E01.German.1080p.mkv");
|
||||||
|
});
|
||||||
|
it("does not strip unrelated tokens containing DL", () => {
|
||||||
|
expect(stripDualLangMarker("Show.HANDLES.1080p.mkv")).toBe("Show.HANDLES.1080p.mkv");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasDualLangMarker", () => {
|
||||||
|
it("detects the marker", () => {
|
||||||
|
expect(hasDualLangMarker("X.German.DL.720p.mkv")).toBe(true);
|
||||||
|
expect(hasDualLangMarker("X.DL.mkv")).toBe(true);
|
||||||
|
});
|
||||||
|
it("returns false without the marker", () => {
|
||||||
|
expect(hasDualLangMarker("X.German.720p.mkv")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isRemuxableVideoFile", () => {
|
||||||
|
it("accepts mkv/mp4 only", () => {
|
||||||
|
expect(isRemuxableVideoFile("a.mkv")).toBe(true);
|
||||||
|
expect(isRemuxableVideoFile("a.MP4")).toBe(true);
|
||||||
|
expect(isRemuxableVideoFile("a.avi")).toBe(false);
|
||||||
|
expect(isRemuxableVideoFile("a.srt")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickAudioTrack", () => {
|
||||||
|
const ger = { language: "ger", title: "" };
|
||||||
|
const eng = { language: "eng", title: "" };
|
||||||
|
const untagged = { language: "", title: "" };
|
||||||
|
|
||||||
|
it("no audio -> skip", () => {
|
||||||
|
expect(pickAudioTrack([], "tag").action).toBe("skip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first mode keeps first of many", () => {
|
||||||
|
const d = pickAudioTrack([eng, ger], "first");
|
||||||
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first mode with single audio -> single (no remux)", () => {
|
||||||
|
expect(pickAudioTrack([eng], "first")).toMatchObject({ action: "single" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode picks the German track even if not first", () => {
|
||||||
|
const d = pickAudioTrack([eng, ger], "tag");
|
||||||
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode picks German via title when language untagged", () => {
|
||||||
|
const d = pickAudioTrack([{ language: "", title: "Englisch" }, { language: "", title: "Deutsch" }], "tag");
|
||||||
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode does NOT treat an ambiguous 3-letter title code as German (no false-positive pick)", () => {
|
||||||
|
// Two untagged tracks whose titles are only "Ger"/"Deu" must not be mistaken
|
||||||
|
// for a German track; with no real German signal this falls back to first.
|
||||||
|
const d = pickAudioTrack([{ language: "", title: "Ger" }, { language: "", title: "Deu" }], "tag");
|
||||||
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode with single German -> single (no remux)", () => {
|
||||||
|
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode, fully untagged multi -> fallback to first", () => {
|
||||||
|
const d = pickAudioTrack([untagged, untagged], "tag");
|
||||||
|
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode, tagged but no German -> SKIP (never delete the only usable audio)", () => {
|
||||||
|
expect(pickAudioTrack([eng, { language: "fre", title: "" }], "tag")).toMatchObject({ action: "skip", reason: "no-german-track" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode, no German tag but GERMAN release -> fall back to first track (mislabeled dub)", () => {
|
||||||
|
expect(pickAudioTrack([eng, eng], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode, single mislabeled track on a German release -> keep it (no remux)", () => {
|
||||||
|
expect(pickAudioTrack([eng], "tag", true)).toMatchObject({ action: "single", reason: "single-german-mislabeled" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag mode, no German tag and NOT flagged German -> still SKIP (safety preserved)", () => {
|
||||||
|
expect(pickAudioTrack([eng, eng], "tag", false)).toMatchObject({ action: "skip", reason: "no-german-track" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correctly tagged German still wins even on a German release (fallback not needed)", () => {
|
||||||
|
expect(pickAudioTrack([eng, ger], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("looksLikeGermanRelease", () => {
|
||||||
|
it("detects German/Dubbed release names", () => {
|
||||||
|
expect(looksLikeGermanRelease("Desperate.Housewives.S02E01.German.DD51.Dubbed.DL.720p.WEB-DL.x264.mkv")).toBe(true);
|
||||||
|
expect(looksLikeGermanRelease("1899.S01E01.German.DL.720p.WEB-x264-WvF.mkv")).toBe(true);
|
||||||
|
expect(looksLikeGermanRelease("Show.S01E01.Deutsch.1080p.mkv")).toBe(true);
|
||||||
|
});
|
||||||
|
it("does not flag a bare .DL. name without an explicit German token", () => {
|
||||||
|
expect(looksLikeGermanRelease("Show.S01E01.DL.720p.x264.mkv")).toBe(false);
|
||||||
|
expect(looksLikeGermanRelease("Show.S01E01.MULTi.1080p.mkv")).toBe(false);
|
||||||
|
});
|
||||||
|
it("does not flag a non-German dub as a German release (bare 'Dubbed' is ambiguous)", () => {
|
||||||
|
expect(looksLikeGermanRelease("Movie.2020.ITALIAN.Dubbed.DL.1080p.mkv")).toBe(false);
|
||||||
|
expect(looksLikeGermanRelease("Movie.2020.FRENCH.DUBBED.DL.720p.mkv")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseFfprobeAudioStreams", () => {
|
||||||
|
it("parses language/title tags", () => {
|
||||||
|
const json = JSON.stringify({ streams: [{ index: 1, tags: { language: "ger", title: "Deutsch" } }, { index: 2, tags: { language: "eng" } }] });
|
||||||
|
expect(parseFfprobeAudioStreams(json)).toEqual([{ language: "ger", title: "Deutsch" }, { language: "eng", title: "" }]);
|
||||||
|
});
|
||||||
|
it("returns [] on invalid json", () => {
|
||||||
|
expect(parseFfprobeAudioStreams("not json")).toEqual([]);
|
||||||
|
});
|
||||||
|
it("returns [] when streams missing", () => {
|
||||||
|
expect(parseFfprobeAudioStreams("{}")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildFfprobeArgs", () => {
|
||||||
|
it("requests audio streams as json", () => {
|
||||||
|
const args = buildFfprobeArgs("in.mkv");
|
||||||
|
expect(args).toContain("-select_streams");
|
||||||
|
expect(args).toContain("a");
|
||||||
|
expect(args[args.length - 1]).toBe("in.mkv");
|
||||||
|
expect(args).toContain("json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildFfmpegRemuxArgs", () => {
|
||||||
|
it("maps video + chosen audio, stream-copy, keeps metadata (language tag), no subs by default", () => {
|
||||||
|
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 1 });
|
||||||
|
expect(args).toEqual([
|
||||||
|
"-i", "in.mkv", "-map", "0:v:0", "-map", "0:a:1",
|
||||||
|
"-c", "copy", "-disposition:a:0", "default", "-y", "out.mkv"
|
||||||
|
]);
|
||||||
|
expect(args).not.toContain("-map_metadata"); // language tag of kept track must survive
|
||||||
|
});
|
||||||
|
it("adds optional German subtitle maps when keepSubs", () => {
|
||||||
|
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 0, keepSubs: true });
|
||||||
|
expect(args.join(" ")).toContain("0:s:m:language:ger?");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeRemuxTimeoutMs", () => {
|
||||||
|
it("has a floor", () => {
|
||||||
|
expect(computeRemuxTimeoutMs(0)).toBe(120_000);
|
||||||
|
});
|
||||||
|
it("scales with size and caps at 60 min", () => {
|
||||||
|
expect(computeRemuxTimeoutMs(50 * 1024 * 1024 * 1024)).toBe(60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exercises the REAL file-mutating body (temp -> replace -> utimes -> rm) with a
|
||||||
|
// fake ffmpeg/ffprobe runner. This is the irreversible-overwrite path that the
|
||||||
|
// download-manager integration test (which mocks processVideoFile wholesale)
|
||||||
|
// cannot cover.
|
||||||
|
describe("processVideoFile (real fs body, fake runner)", () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of tempDirs.splice(0)) {
|
||||||
|
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeFile(content: string, name = "Show.S01E01.German.DL.720p.mkv"): string {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-vp-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const file = path.join(dir, name);
|
||||||
|
fs.writeFileSync(file, content);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeRunner(opts: { probeJson: string; ffmpegOk?: boolean }): typeof import("../src/main/video-processor").runVideoProcess {
|
||||||
|
return async (_command: string, args: string[]): Promise<VideoSpawnResult> => {
|
||||||
|
const base = { aborted: false, timedOut: false, missing: false } as const;
|
||||||
|
if (args.includes("-show_entries")) {
|
||||||
|
return { ...base, ok: true, exitCode: 0, stdout: opts.probeJson, stderr: "" };
|
||||||
|
}
|
||||||
|
const output = args[args.length - 1];
|
||||||
|
if (opts.ffmpegOk !== false) {
|
||||||
|
fs.writeFileSync(output, "REMUXED-GERMAN-ONLY");
|
||||||
|
return { ...base, ok: true, exitCode: 0, stdout: "", stderr: "" };
|
||||||
|
}
|
||||||
|
return { ...base, ok: false, exitCode: 1, stdout: "", stderr: "ffmpeg boom" };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any sidecar the replace machinery may leave behind (unique "~rd…" temp names).
|
||||||
|
function leftoverTemps(file: string): string[] {
|
||||||
|
return fs.readdirSync(path.dirname(file)).filter((n) => n.startsWith("~rd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooling = async (): Promise<{ ffmpeg: string; ffprobe: string }> => ({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
|
||||||
|
const twoTracksGerSecond = JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "ger" } }] });
|
||||||
|
|
||||||
|
it("replaces the original in place and preserves mtime on success", async () => {
|
||||||
|
const file = makeFile("ORIGINAL");
|
||||||
|
const oldTime = new Date(Date.now() - 5 * 60 * 1000);
|
||||||
|
fs.utimesSync(file, oldTime, oldTime);
|
||||||
|
const beforeMtime = fs.statSync(file).mtimeMs;
|
||||||
|
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: twoTracksGerSecond })
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.action).toBe("remuxed");
|
||||||
|
expect(result.keptTrackIndex).toBe(1); // German was second
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); // original overwritten
|
||||||
|
expect(Math.abs(fs.statSync(file).mtimeMs - beforeMtime)).toBeLessThan(1500); // mtime preserved
|
||||||
|
expect(leftoverTemps(file)).toEqual([]); // unique temp cleaned up
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the original intact and removes temp when ffmpeg fails", async () => {
|
||||||
|
const file = makeFile("ORIGINAL");
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: twoTracksGerSecond, ffmpegOk: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.action).toBe("error");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // never lost
|
||||||
|
expect(leftoverTemps(file)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the original intact and cleans the temp when the atomic replace rename fails (no zero-copy window)", async () => {
|
||||||
|
// Simulate a Windows file lock that defeats the replace even after retries.
|
||||||
|
// The original must survive: the old rm-then-rename fallback could leave the
|
||||||
|
// file with NEITHER the original nor the remux on disk.
|
||||||
|
const file = makeFile("ORIGINAL");
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: twoTracksGerSecond }),
|
||||||
|
rename: async () => { throw Object.assign(new Error("locked"), { code: "EBUSY" }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.action).toBe("error");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // original never destroyed
|
||||||
|
expect(leftoverTemps(file)).toEqual([]); // remux temp removed
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch a single-audio file (no remux)", async () => {
|
||||||
|
const file = makeFile("ORIGINAL");
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "ger" } }] }) })
|
||||||
|
});
|
||||||
|
expect(result.action).toBe("kept-single");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remuxes a German-named release with MISLABELED audio tags (fallback to first track)", async () => {
|
||||||
|
// Name says German, but both audio tracks are tagged eng/fre (the dub is
|
||||||
|
// mislabeled). The fallback keeps the first track instead of skipping.
|
||||||
|
const file = makeFile("ORIGINAL"); // name contains "German"
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
|
||||||
|
});
|
||||||
|
expect(result.action).toBe("remuxed");
|
||||||
|
expect(result.keptTrackIndex).toBe(0);
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a NON-German-named file untouched when tagged but no German track (safety preserved)", async () => {
|
||||||
|
const file = makeFile("ORIGINAL", "Show.S01E01.MULTi.DL.720p.mkv");
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||||
|
resolveTooling: tooling,
|
||||||
|
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
|
||||||
|
});
|
||||||
|
expect(result.action).toBe("skipped-no-german");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns skipped-no-tool when ffmpeg/ffprobe are absent", async () => {
|
||||||
|
const file = makeFile("ORIGINAL");
|
||||||
|
const result = await processVideoFile(file, { mode: "tag" }, { resolveTooling: async () => null });
|
||||||
|
expect(result.action).toBe("skipped-no-tool");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renameWithRetry", () => {
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); });
|
||||||
|
const busy = (): NodeJS.ErrnoException => Object.assign(new Error("locked"), { code: "EBUSY" });
|
||||||
|
|
||||||
|
it("retries a transient EBUSY and then succeeds", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls <= 2) { throw busy(); }
|
||||||
|
});
|
||||||
|
await expect(renameWithRetry("a", "b")).resolves.toBeUndefined();
|
||||||
|
expect(calls).toBe(3); // failed twice, succeeded on the third attempt
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up after exhausting retries on a persistent lock", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
vi.spyOn(fs.promises, "rename").mockImplementation(async () => { calls += 1; throw busy(); });
|
||||||
|
await expect(renameWithRetry("a", "b")).rejects.toThrow("locked");
|
||||||
|
expect(calls).toBe(4); // initial attempt + 3 backoff retries
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry a non-retryable error (e.g. EXDEV) — fails fast", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
|
||||||
|
calls += 1;
|
||||||
|
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
|
||||||
|
});
|
||||||
|
await expect(renameWithRetry("a", "b")).rejects.toThrow("cross-device");
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user