Compare commits

..

No commits in common. "main" and "v1.7.146" have entirely different histories.

98 changed files with 107299 additions and 29108 deletions

13
.gitignore vendored
View File

@ -19,6 +19,7 @@ apply_update.cmd
.claude/ .claude/
.github/ .github/
docs/plans/
CHANGELOG.md CHANGELOG.md
node_modules/ node_modules/
@ -28,6 +29,7 @@ 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/
@ -36,14 +38,3 @@ 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

View File

@ -0,0 +1,183 @@
# Intensive Analyse: Pausen zwischen Pack-Entpackungen (1015 Sekunden)
**Nur Analyse keine Code-Änderungen.**
---
## 1. Problem
Nach dem Entpacken eines Packs (z.B. 3 Parts einer Serie) passiert ca. 1015 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 1015 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 22082284)
- 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 22862328)
- 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 (1015 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 1015 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. 37613804).
- Post-Processing-Task: `runPackagePostProcessing``handlePackagePostProcessing` (ca. 38063854, 65446916).
- Hybrid: `runHybridExtraction` (ca. 63746542), inkl. `await autoRenameExtractedVideoFiles` (6490).
- Final: `handlePackagePostProcessing` nach `extractPackageArchives` (66976916): `recordPackageHistory`, `void runDeferredPostExtraction`, dann return.
- Extractor: `extractPackageArchives` (extractor.ts, ca. 18802353), Nested 22082284, Post-Cleanup 22862328.
- Rename: `autoRenameExtractedVideoFiles` (download-manager.ts, 21732312), nutzt `collectVideoFiles` (rekursiv).
- MKV/Cleanup: `collectMkvFilesToLibrary` (2448), `cleanupRemainingArchiveArtifacts` (2353), `runDeferredPostExtraction` (69226965).
---
## 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).

View File

@ -0,0 +1,91 @@
# 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`

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.188", "version": "1.7.146",
"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 Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,18 @@
const path = require("path"); const path = require("path");
const { rcedit } = require("rcedit"); const { rcedit } = require("rcedit");
module.exports = async function afterPack(context) { module.exports = async function afterPack(context) {
const productFilename = context.packager?.appInfo?.productFilename; const productFilename = context.packager?.appInfo?.productFilename;
if (!productFilename) { if (!productFilename) {
console.warn(" • rcedit: skipped — productFilename not available"); console.warn(" • rcedit: skipped — productFilename not available");
return; return;
} }
const exePath = path.join(context.appOutDir, `${productFilename}.exe`); const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico"); const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
console.log(` • rcedit: patching icon → ${exePath}`); console.log(` • rcedit: patching icon → ${exePath}`);
try { try {
await rcedit(exePath, { icon: iconPath }); await rcedit(exePath, { icon: iconPath });
} catch (error) { } catch (error) {
console.warn(` • rcedit: failed — ${String(error)}`); console.warn(` • rcedit: failed — ${String(error)}`);
} }
}; };

View File

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

View File

@ -116,6 +116,7 @@ function getGiteaRepo() {
} }
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` }; return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
} catch { } catch {
// try next remote
} }
} }
@ -255,13 +256,15 @@ 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: true, draft: false,
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) {
@ -273,60 +276,42 @@ 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)}`;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { // Stream large files instead of loading them entirely into memory
const fileStream = fs.createReadStream(filePath); const fileStream = fs.createReadStream(filePath);
let response; const response = await fetch(uploadUrl, {
try { method: "POST",
response = await fetch(uploadUrl, { headers: {
method: "POST", Accept: "application/json",
headers: { Authorization: authHeader,
Accept: "application/json", "Content-Type": "application/octet-stream",
Authorization: authHeader, "Content-Length": String(fileSize)
"Content-Type": "application/octet-stream", },
"Content-Length": String(fileSize) body: fileStream,
}, duplex: "half"
body: fileStream, });
duplex: "half"
});
} catch (error) {
fileStream.destroy();
if (attempt < MAX_ATTEMPTS) {
process.stdout.write(`Upload ${fileName} abgebrochen (Netzwerk, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
continue;
}
throw new Error(`Asset upload failed for ${fileName} after ${MAX_ATTEMPTS} attempts: ${String(error?.message || error)}`);
}
const text = await response.text(); const text = await response.text();
let parsed; let parsed;
try { try {
parsed = text ? JSON.parse(text) : null; parsed = text ? JSON.parse(text) : null;
} catch { } catch {
parsed = text; parsed = text;
}
if (response.ok) {
process.stdout.write(`Uploaded: ${fileName}\n`);
break;
}
if (response.status === 409 || response.status === 422) {
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
break;
}
if (response.status >= 500 && attempt < MAX_ATTEMPTS) {
process.stdout.write(`Upload ${fileName} fehlgeschlagen (${response.status}, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
continue;
}
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
} }
if (response.ok) {
process.stdout.write(`Uploaded: ${fileName}\n`);
continue;
}
if (response.status === 409 || response.status === 422) {
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
continue;
}
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
} }
} }
@ -378,11 +363,6 @@ 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`);
} }

View File

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

View File

@ -1,82 +1,14 @@
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";
export type RotationItemSink = (event: RotationEvent) => void; /** Dedicated log file for multi-account/key rotation events:
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>(); * Mega-Debrid account selection, Debrid-Link key selection, per-attempt
* test result, cooldown set, fallback to next account/key, etc.
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> { * Separate from rd_downloader.log so the user can see the rotation flow
return rotationItemContext.run(sink, fn); * without the noise of normal download activity. */
}
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);
@ -119,9 +51,11 @@ 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
} }
} }
@ -134,6 +68,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} }
} catch { } catch {
// ignore
} }
} }
@ -151,7 +86,7 @@ export function initAccountRotationLog(baseDir: string): void {
} }
fs.appendFileSync( fs.appendFileSync(
rotationLogPath, rotationLogPath,
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`, `=== Account-Rotation Log Start: ${new Date().toISOString()} ===\n`,
"utf8" "utf8"
); );
} catch { } catch {
@ -159,6 +94,13 @@ 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,
@ -166,7 +108,6 @@ 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;
} }
@ -175,9 +116,10 @@ export function logAccountRotation(
if (!fs.existsSync(rotationLogPath)) { if (!fs.existsSync(rotationLogPath)) {
fs.writeFileSync(rotationLogPath, "", "utf8"); fs.writeFileSync(rotationLogPath, "", "utf8");
} }
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`; const head = `${new Date().toISOString()} [${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
} }
} }
@ -195,10 +137,11 @@ export function shutdownAccountRotationLog(): void {
try { try {
fs.appendFileSync( fs.appendFileSync(
rotationLogPath, rotationLogPath,
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`, `=== Account-Rotation Log Ende: ${new Date().toISOString()} ===\n`,
"utf8" "utf8"
); );
} catch { } catch {
// ignore
} }
rotationLogPath = null; rotationLogPath = null;
} }

View File

@ -243,10 +243,12 @@ 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
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
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";
@ -46,9 +45,11 @@ 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
} }
} }
@ -61,6 +62,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} }
} catch { } catch {
// ignore
} }
} }
@ -76,7 +78,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: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
} catch { } catch {
auditLogPath = null; auditLogPath = null;
} }
@ -93,10 +95,11 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
} }
fs.appendFileSync( fs.appendFileSync(
auditLogPath, auditLogPath,
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`,
"utf8" "utf8"
); );
} catch { } catch {
// ignore write errors
} }
} }
@ -112,8 +115,9 @@ export function shutdownAuditLog(): void {
return; return;
} }
try { try {
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
auditLogPath = null; auditLogPath = null;
} }

View File

@ -1,15 +1,22 @@
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; const IV_LENGTH = 12; // 96-bit IV for GCM
const AUTH_TAG_LENGTH = 16; const AUTH_TAG_LENGTH = 16;
const MAGIC = Buffer.from("MDD1"); const MAGIC = Buffer.from("MDD1"); // file signature
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);
@ -19,6 +26,10 @@ 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");

View File

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

View File

@ -212,15 +212,18 @@ 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 {
@ -341,6 +344,7 @@ export class BestDebridWebFallback {
try { try {
await currentSession.clearCache(); await currentSession.clearCache();
} catch { } catch {
// ignore cache clear failures
} }
} }
} }

View File

@ -39,6 +39,7 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
fs.rmSync(full, { force: true }); fs.rmSync(full, { force: true });
removed += 1; removed += 1;
} catch { } catch {
// ignore
} }
} }
} }
@ -83,6 +84,7 @@ 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
} }
} }
@ -148,6 +150,7 @@ 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
} }
} }
} }
@ -237,6 +240,7 @@ 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
} }
} }
} }
@ -259,6 +263,7 @@ export async function removeSampleArtifacts(
removedFiles += filesInDir; removedFiles += filesInDir;
removedDirs += 1; removedDirs += 1;
} catch { } catch {
// ignore
} }
} }

View File

@ -1,133 +1,129 @@
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import { AppSettings } from "../shared/types"; import { AppSettings } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import packageJson from "../../package.json"; import packageJson from "../../package.json";
export const APP_NAME = "Multi Debrid Downloader"; export const APP_NAME = "Multi Debrid Downloader";
export const APP_VERSION: string = packageJson.version; export const APP_VERSION: string = packageJson.version;
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste"; export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste";
export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}"; export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8"); export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8"); 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; export const WRITE_BUFFER_SIZE = 512 * 1024; // 512 KB write buffer (JDownloader: 500 KB)
export const WRITE_FLUSH_TIMEOUT_MS = 2000; export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout
export const ALLOCATION_UNIT_SIZE = 4096; export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment
export const STREAM_HIGH_WATER_MARK = 512 * 1024; 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 DISK_BUSY_THRESHOLD_MS = 300; export const DISK_BUSY_THRESHOLD_MS = 300; // Internal detection threshold for disk backpressure
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes
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"]);
export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]); export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i; export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz", ".rev"]); export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz", ".rev"]);
export const RAR_SPLIT_RE = /\.r\d{2,3}$/i; export const RAR_SPLIT_RE = /\.r\d{2,3}$/i;
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024; export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024; export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
export const SPEED_WINDOW_SECONDS = 1; export const SPEED_WINDOW_SECONDS = 1;
export const CLIPBOARD_POLL_INTERVAL_MS = 2000; export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader"; export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader";
export function defaultSettings(): AppSettings { export function defaultSettings(): AppSettings {
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid"); const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
return { return {
token: "", token: "",
realDebridUseWebLogin: false, realDebridUseWebLogin: false,
megaLogin: "", megaLogin: "",
megaPassword: "", megaPassword: "",
megaCredentials: "", megaCredentials: "",
megaDebridApiEnabled: false, megaDebridApiEnabled: false,
megaDebridWebEnabled: false, megaDebridWebEnabled: false,
megaDebridPreferApi: true, megaDebridPreferApi: true,
bestToken: "", bestToken: "",
bestDebridUseWebLogin: false, bestDebridUseWebLogin: false,
allDebridToken: "", allDebridToken: "",
allDebridUseWebLogin: false, allDebridUseWebLogin: false,
ddownloadLogin: "", ddownloadLogin: "",
ddownloadPassword: "", ddownloadPassword: "",
oneFichierApiKey: "", oneFichierApiKey: "",
debridLinkApiKeys: "", debridLinkApiKeys: "",
debridLinkDisabledKeyIds: [], debridLinkDisabledKeyIds: [],
linkSnappyLogin: "", linkSnappyLogin: "",
linkSnappyPassword: "", linkSnappyPassword: "",
archivePasswordList: "", archivePasswordList: "",
rememberToken: true, rememberToken: true,
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"], providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
providerPrimary: "realdebrid", providerPrimary: "realdebrid",
providerSecondary: "megadebrid-api", providerSecondary: "megadebrid-api",
providerTertiary: "bestdebrid", providerTertiary: "bestdebrid",
autoProviderFallback: true, autoProviderFallback: true,
outputDir: baseDir, outputDir: baseDir,
packageName: "", packageName: "",
autoExtract: true, autoExtract: true,
autoRename4sf4sj: false, autoRename4sf4sj: false,
keepGermanAudioOnly: false, extractDir: path.join(baseDir, "_entpackt"),
germanAudioMode: "tag", collectMkvToLibrary: false,
extractDir: path.join(baseDir, "_entpackt"), mkvLibraryDir: path.join(baseDir, "_mkv"),
collectMkvToLibrary: false, createExtractSubfolder: true,
mkvLibraryDir: path.join(baseDir, "_mkv"), hybridExtract: true,
createExtractSubfolder: true, cleanupMode: "none",
hybridExtract: true, extractConflictMode: "overwrite",
cleanupMode: "none", removeLinkFilesAfterExtract: false,
extractConflictMode: "overwrite", removeSamplesAfterExtract: false,
removeLinkFilesAfterExtract: false, enableIntegrityCheck: true,
removeSamplesAfterExtract: false, autoResumeOnStart: true,
enableIntegrityCheck: true, autoReconnect: false,
autoResumeOnStart: true, reconnectWaitSeconds: 45,
autoReconnect: false, completedCleanupPolicy: "never",
reconnectWaitSeconds: 45, maxParallel: 4,
completedCleanupPolicy: "never", maxParallelExtract: 2,
maxParallel: 4, retryLimit: 0,
maxParallelExtract: 2, speedLimitEnabled: false,
retryLimit: 0, speedLimitKbps: 0,
speedLimitEnabled: false, speedLimitMode: "global",
speedLimitKbps: 0, updateRepo: DEFAULT_UPDATE_REPO,
speedLimitMode: "global", autoUpdateCheck: true,
updateRepo: DEFAULT_UPDATE_REPO, clipboardWatch: false,
autoUpdateCheck: true, minimizeToTray: false,
clipboardWatch: false, theme: "dark" as const,
minimizeToTray: false, collapseNewPackages: true,
theme: "dark" as const, historyRetentionMode: "permanent",
collapseNewPackages: true, accountListShowDetailedDebridLinkKeys: false,
historyRetentionMode: "permanent", autoSortPackagesByProgress: true,
accountListShowDetailedDebridLinkKeys: false, autoSkipExtracted: false,
autoSortPackagesByProgress: true, hideExtractedItems: true,
autoSkipExtracted: false, confirmDeleteSelection: true,
hideExtractedItems: true, totalDownloadedAllTime: 0,
confirmDeleteSelection: true, totalCompletedFilesAllTime: 0,
backupIncludeDownloads: false, totalRuntimeAllTimeMs: 0,
totalDownloadedAllTime: 0, bandwidthSchedules: [],
totalCompletedFilesAllTime: 0, columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
totalRuntimeAllTimeMs: 0, extractCpuPriority: "high",
bandwidthSchedules: [], autoExtractWhenStopped: true,
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], disabledProviders: [],
extractCpuPriority: "high", hosterRouting: {},
autoExtractWhenStopped: true, providerDailyLimitBytes: {},
disabledProviders: [], providerDailyUsageBytes: {},
hosterRouting: {}, providerTotalUsageBytes: {},
providerDailyLimitBytes: {}, debridLinkApiKeyDailyLimitBytes: {},
providerDailyUsageBytes: {}, debridLinkApiKeyDailyUsageBytes: {},
providerTotalUsageBytes: {}, debridLinkApiKeyTotalUsageBytes: {},
debridLinkApiKeyDailyLimitBytes: {}, megaDebridDisabledAccountIds: [],
debridLinkApiKeyDailyUsageBytes: {}, megaDebridAccountDailyLimitBytes: {},
debridLinkApiKeyTotalUsageBytes: {}, megaDebridAccountDailyUsageBytes: {},
megaDebridDisabledAccountIds: [], megaDebridAccountTotalUsageBytes: {},
megaDebridAccountDailyLimitBytes: {}, providerDailyUsageDay: getProviderUsageDayKey(),
megaDebridAccountDailyUsageBytes: {}, scheduledStartEpochMs: 0
megaDebridAccountTotalUsageBytes: {}, };
debridAccountStatuses: {}, }
providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0
};
}

View File

@ -113,11 +113,13 @@ 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
} }
} }
@ -130,6 +132,7 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
links.push(url); links.push(url);
} }
} catch { } catch {
// skip broken entries
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,6 @@ 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";
@ -45,7 +44,6 @@ 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&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." }, { method: "GET", path: "/trace/config", queryExample: "enable=1&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
@ -118,6 +116,7 @@ function getPort(baseDir: string): number {
return n; return n;
} }
} catch { } catch {
// ignore
} }
return DEFAULT_PORT; return DEFAULT_PORT;
} }
@ -136,6 +135,7 @@ function getHost(baseDir: string): string {
return raw; return raw;
} }
} catch { } catch {
// ignore
} }
return DEFAULT_HOST; return DEFAULT_HOST;
} }
@ -530,18 +530,6 @@ 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") || "";

View File

@ -53,6 +53,7 @@ function readPort(baseDir: string): number {
return raw; return raw;
} }
} catch { } catch {
// ignore
} }
return DEFAULT_PORT; return DEFAULT_PORT;
} }
@ -70,6 +71,7 @@ function readHost(baseDir: string): string {
return raw; return raw;
} }
} catch { } catch {
// ignore
} }
return DEFAULT_HOST; return DEFAULT_HOST;
} }
@ -156,6 +158,7 @@ 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
} }
} }
} }

View File

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

View File

@ -127,14 +127,6 @@ 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

View File

@ -1,45 +0,0 @@
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();
}

View File

@ -1,3 +1,7 @@
// ════════════════════════════════════════════════════════════════════════════
// 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";
@ -43,6 +47,10 @@ 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;
@ -161,15 +169,20 @@ 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--) { for (let code = 90; code >= 71; code--) { // Z to G
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;
} }
@ -213,9 +226,14 @@ 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 {
@ -350,6 +368,7 @@ 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;
@ -387,6 +406,10 @@ 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, "\\$&");
} }
@ -415,6 +438,8 @@ 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) {
@ -479,10 +504,12 @@ 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]);
@ -545,6 +572,7 @@ export async function cleanupArchives(
index += 1; index += 1;
} }
} catch { } catch {
// ignore
} }
return false; return false;
}; };
@ -567,6 +595,7 @@ 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;
@ -655,11 +684,16 @@ 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) {
@ -681,6 +715,7 @@ 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;
@ -707,17 +742,6 @@ 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 [];
@ -767,6 +791,10 @@ 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) {
@ -907,6 +935,10 @@ 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)
@ -932,6 +964,9 @@ 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";
} }
@ -950,6 +985,10 @@ 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");
@ -1161,6 +1200,12 @@ 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));
@ -1227,15 +1272,21 @@ 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;
} }
@ -1250,12 +1301,14 @@ 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;
@ -1264,6 +1317,7 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
try { try {
child.kill(); child.kill();
} catch { } catch {
// ignore
} }
} }
@ -1420,6 +1474,10 @@ 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;
@ -1541,6 +1599,10 @@ 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;
@ -1554,8 +1616,8 @@ let daemonLayout: JvmExtractorLayout | null = null;
export function shutdownDaemon(): void { export function shutdownDaemon(): void {
if (daemonProcess) { if (daemonProcess) {
try { daemonProcess.stdin?.end(); } catch { } try { daemonProcess.stdin?.end(); } catch { /* ignore */ }
try { killProcessTree(daemonProcess); } catch { } try { killProcessTree(daemonProcess); } catch { /* ignore */ }
daemonProcess = null; daemonProcess = null;
} }
daemonReady = false; daemonReady = false;
@ -1731,6 +1793,7 @@ 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;
@ -1753,6 +1816,7 @@ 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();
@ -1865,12 +1929,14 @@ 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();
@ -1885,6 +1951,7 @@ 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);
@ -2053,6 +2120,10 @@ async function runJvmExtractCommand(
}); });
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 13 — Legacy Extraction (buildExternalExtractArgs, runExternalExtract*)
// ════════════════════════════════════════════════════════════════════════════
export function buildExternalExtractArgs( export function buildExternalExtractArgs(
command: string, command: string,
archivePath: string, archivePath: string,
@ -2079,6 +2150,7 @@ 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(
@ -2112,6 +2184,7 @@ 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)}`);
@ -2228,6 +2301,8 @@ 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
@ -2351,6 +2426,7 @@ 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) {
@ -2403,6 +2479,7 @@ 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(
@ -2528,6 +2605,10 @@ 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")
@ -2616,6 +2697,9 @@ 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") {
@ -2665,6 +2749,10 @@ 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) {
@ -2672,7 +2760,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 { } } catch { /* missing part, ignore */ }
} }
} }
return total; return total;
@ -2735,6 +2823,7 @@ 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) {
@ -2748,6 +2837,10 @@ 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`);
@ -2783,6 +2876,7 @@ 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) {
@ -2794,9 +2888,14 @@ 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",
@ -2822,6 +2921,10 @@ 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");
@ -2837,11 +2940,12 @@ 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 { } } catch { /* ignore */ }
await checkDiskSpaceForExtraction(options.targetDir, candidates); await checkDiskSpaceForExtraction(options.targetDir, candidates);
} }
@ -2963,6 +3067,9 @@ 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 });
@ -2995,6 +3102,8 @@ 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
@ -3012,6 +3121,7 @@ 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);
@ -3046,6 +3156,7 @@ 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") {
@ -3157,6 +3268,7 @@ 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) {
@ -3165,6 +3277,8 @@ 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)...`);
@ -3175,6 +3289,7 @@ 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) {
@ -3183,6 +3298,7 @@ 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;
@ -3197,20 +3313,24 @@ 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; break; // handled by noExtractorEncountered flag after the pool
} }
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");
@ -3242,6 +3362,11 @@ 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) {
@ -3250,12 +3375,14 @@ 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) {
@ -3274,6 +3401,7 @@ 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))
@ -3402,6 +3530,7 @@ 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
} }
} }

3564
src/main/extractor.ts.bak Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,56 +0,0 @@
// 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 ?? "");
}

View File

@ -1,5 +1,4 @@
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";
@ -94,6 +93,7 @@ function flushPending(): void {
try { try {
fs.appendFileSync(logPath, chunk, "utf8"); fs.appendFileSync(logPath, chunk, "utf8");
} catch { } catch {
// ignore write errors
} }
} }
} }
@ -123,9 +123,11 @@ 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
} }
} }
@ -164,7 +166,7 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
} }
if (!initializedThisProcess.has(normalizedItemId)) { if (!initializedThisProcess.has(normalizedItemId)) {
initializedThisProcess.add(normalizedItemId); initializedThisProcess.add(normalizedItemId);
const startedAt = logTimestamp(); const startedAt = new Date().toISOString();
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`,
@ -172,7 +174,7 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
); );
fs.appendFileSync( fs.appendFileSync(
logPath, logPath,
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({ `${new Date().toISOString()} [INFO] Item-Kontext initialisiert${formatFields({
packageId: meta.packageId, packageId: meta.packageId,
packageName: meta.packageName, packageName: meta.packageName,
fileName: meta.fileName, fileName: meta.fileName,
@ -197,7 +199,7 @@ export function logItemEvent(
if (!logPath) { if (!logPath) {
return; return;
} }
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`; const line = `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`;
appendLine(itemId, line); appendLine(itemId, line);
} }
@ -221,8 +223,9 @@ export function shutdownItemLogs(): void {
continue; continue;
} }
try { try {
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(logPath, `=== Item-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
} }
pendingLinesByItem.clear(); pendingLinesByItem.clear();

View File

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

View File

@ -1,24 +1,6 @@
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;
@ -87,6 +69,7 @@ function writeStderr(text: string): void {
try { try {
process.stderr.write(text); process.stderr.write(text);
} catch { } catch {
// ignore stderr failures
} }
} }
@ -152,9 +135,11 @@ 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
} }
} }
@ -174,6 +159,7 @@ 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
} }
} }
@ -221,21 +207,14 @@ function ensureExitHook(): void {
process.once("exit", flushSyncPending); process.once("exit", flushSyncPending);
} }
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void { function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
ensureExitHook(); ensureExitHook();
const ts = logTimestamp(); const line = `${new Date().toISOString()} [${level}] ${message}\n`;
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 { } try { listener(line); } catch { /* ignore */ }
} }
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) { while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
@ -254,9 +233,6 @@ function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): voi
} }
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)

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,437 +1,424 @@
import { UnrestrictedLink } from "./realdebrid"; import { UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, sleep } from "./utils"; import { compactErrorText, filenameFromUrl, sleep } from "./utils";
type MegaCredentials = { type MegaCredentials = {
login: string; login: string;
password: string; password: string;
}; };
type CodeEntry = { type CodeEntry = {
code: string; code: string;
linkHint: string; linkHint: string;
}; };
const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login"; const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login";
const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid"; 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 {
return link.trim().toLowerCase();
function normalizeLink(link: string): string { }
return link.trim().toLowerCase();
} function parseSetCookieFromHeaders(headers: Headers): string {
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
function parseSetCookieFromHeaders(headers: Headers): string { if (typeof getSetCookie === "function") {
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie; const values = getSetCookie.call(headers)
if (typeof getSetCookie === "function") { .map((entry) => entry.split(";")[0].trim())
const values = getSetCookie.call(headers) .filter(Boolean);
.map((entry) => entry.split(";")[0].trim()) if (values.length > 0) {
.filter(Boolean); return values.join("; ");
if (values.length > 0) { }
return values.join("; "); }
}
} const raw = headers.get("set-cookie") || "";
if (!raw) {
const raw = headers.get("set-cookie") || ""; return "";
if (!raw) { }
return ""; return raw
} .split(/,(?=[^;=]+?=)/g)
return raw .map((chunk) => chunk.split(";")[0].trim())
.split(/,(?=[^;=]+?=)/g) .filter(Boolean)
.map((chunk) => chunk.split(";")[0].trim()) .join("; ");
.filter(Boolean) }
.join("; ");
} const PERMANENT_HOSTER_ERRORS = [
"hosternotavailable",
const PERMANENT_HOSTER_ERRORS = [ "filenotfound",
"hosternotavailable", "file_unavailable",
"filenotfound", "file not found",
"file_unavailable", "link is dead",
"file not found", "file has been removed",
"link is dead", "file has been deleted",
"file has been removed", "file was deleted",
"file has been deleted", "file was removed",
"file was deleted", "not available",
"file was removed", "file is no longer available"
"not available", ];
"file is no longer available"
]; function parsePageErrors(html: string): string[] {
const errors: string[] = [];
function parsePageErrors(html: string): string[] { const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
const errors: string[] = []; let m: RegExpExecArray | null;
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi; while ((m = errorRegex.exec(html)) !== null) {
let m: RegExpExecArray | null; const text = m[1].replace(/^Fehler:\s*/i, "").trim();
while ((m = errorRegex.exec(html)) !== null) { if (text) {
const text = m[1].replace(/^Fehler:\s*/i, "").trim(); errors.push(text);
if (text) { }
errors.push(text); }
} return errors;
} }
return errors;
} function isPermanentHosterError(errors: string[]): string | null {
for (const err of errors) {
function isPermanentHosterError(errors: string[]): string | null { const lower = err.toLowerCase();
for (const err of errors) { for (const pattern of PERMANENT_HOSTER_ERRORS) {
const lower = err.toLowerCase(); if (lower.includes(pattern)) {
for (const pattern of PERMANENT_HOSTER_ERRORS) { return err;
if (lower.includes(pattern)) { }
return err; }
} }
} return null;
} }
return null;
} function parseCodes(html: string): CodeEntry[] {
const entries: CodeEntry[] = [];
function parseCodes(html: string): CodeEntry[] { const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
const entries: CodeEntry[] = []; let cardMatch: RegExpExecArray | null;
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi; while ((cardMatch = cardRegex.exec(html)) !== null) {
let cardMatch: RegExpExecArray | null; const block = cardMatch[0];
while ((cardMatch = cardRegex.exec(html)) !== null) { const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim();
const block = cardMatch[0]; const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim(); if (!code) {
const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || ""; continue;
if (!code) { }
continue; entries.push({ code, linkHint: normalizeLink(linkTitle) });
} }
entries.push({ code, linkHint: normalizeLink(linkTitle) });
} if (entries.length === 0) {
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi;
if (entries.length === 0) { let m: RegExpExecArray | null;
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi; while ((m = fallbackRegex.exec(html)) !== null) {
let m: RegExpExecArray | null; entries.push({ code: m[1], linkHint: "" });
while ((m = fallbackRegex.exec(html)) !== null) { }
entries.push({ code: m[1], linkHint: "" }); }
}
} return entries;
}
return entries;
} function pickCode(entries: CodeEntry[], link: string): string {
if (entries.length === 0) {
function pickCode(entries: CodeEntry[], link: string): string { return "";
if (entries.length === 0) { }
return ""; const target = normalizeLink(link);
} const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target));
const target = normalizeLink(link); return (match?.code || entries[0].code || "").trim();
const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target)); }
return (match?.code || entries[0].code || "").trim();
} function parseDebridJson(text: string): { link: string; text: string } | null {
try {
function parseDebridJson(text: string): { link: string; text: string } | null { const parsed = JSON.parse(text) as { link?: string; text?: string };
try { return {
const parsed = JSON.parse(text) as { link?: string; text?: string }; link: String(parsed.link || ""),
return { text: String(parsed.text || "")
link: String(parsed.link || ""), };
text: String(parsed.text || "") } catch {
}; return null;
} catch { }
return null; }
}
} function abortError(): Error {
return new Error("aborted:mega-web");
function abortError(): Error { }
return new Error("aborted:mega-web");
} function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { if (!signal) {
const timeoutSignal = AbortSignal.timeout(timeoutMs); return timeoutSignal;
if (!signal) { }
return timeoutSignal; return AbortSignal.any([signal, timeoutSignal]);
} }
return AbortSignal.any([signal, timeoutSignal]);
} function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
function throwIfAborted(signal?: AbortSignal): void { throw abortError();
if (signal?.aborted) { }
throw abortError(); }
}
} async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> { await sleep(ms);
if (!signal) { return;
await sleep(ms); }
return; if (signal.aborted) {
} throw abortError();
if (signal.aborted) { }
throw abortError();
} await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
await new Promise<void>((resolve, reject) => { timer = null;
let timer: NodeJS.Timeout | null = setTimeout(() => { signal.removeEventListener("abort", onAbort);
timer = null; resolve();
signal.removeEventListener("abort", onAbort); }, Math.max(0, ms));
resolve();
}, Math.max(0, ms)); const onAbort = (): void => {
if (timer) {
const onAbort = (): void => { clearTimeout(timer);
if (timer) { timer = null;
clearTimeout(timer); }
timer = null; signal.removeEventListener("abort", onAbort);
} reject(abortError());
signal.removeEventListener("abort", onAbort); };
reject(abortError());
}; signal.addEventListener("abort", onAbort, { once: true });
});
signal.addEventListener("abort", onAbort, { once: true }); }
});
} async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> { return promise;
if (!signal) { }
return promise; if (signal.aborted) {
} throw abortError();
if (signal.aborted) { }
throw abortError();
} return new Promise<T>((resolve, reject) => {
let settled = false;
return new Promise<T>((resolve, reject) => {
let settled = false; const onAbort = (): void => {
if (settled) {
const onAbort = (): void => { return;
if (settled) { }
return; settled = true;
} signal.removeEventListener("abort", onAbort);
settled = true; reject(abortError());
signal.removeEventListener("abort", onAbort); };
reject(abortError());
}; signal.addEventListener("abort", onAbort, { once: true });
signal.addEventListener("abort", onAbort, { once: true }); promise.then((value) => {
if (settled) {
promise.then((value) => { return;
if (settled) { }
return; settled = true;
} signal.removeEventListener("abort", onAbort);
settled = true; resolve(value);
signal.removeEventListener("abort", onAbort); }, (error) => {
resolve(value); if (settled) {
}, (error) => { return;
if (settled) { }
return; settled = true;
} signal.removeEventListener("abort", onAbort);
settled = true; reject(error);
signal.removeEventListener("abort", onAbort); });
reject(error); });
}); }
});
} export class MegaWebFallback {
private queue: Promise<unknown> = Promise.resolve();
export class MegaWebFallback {
private queue: Promise<unknown> = Promise.resolve(); 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( public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
link: string, const overallSignal = withTimeoutSignal(signal, 180000);
signal?: AbortSignal, return this.runExclusive(async () => {
account?: { login: string; password: string } throwIfAborted(overallSignal);
): Promise<UnrestrictedLink | null> { const creds = this.getCredentials();
const overallSignal = withTimeoutSignal(signal, 180000); if (!creds.login.trim() || !creds.password.trim()) {
return this.runExclusive(async () => { return null;
throwIfAborted(overallSignal); }
const creds = (account && account.login.trim() && account.password.trim())
? account if (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) {
: this.getCredentials(); await this.login(creds.login, creds.password, overallSignal);
if (!creds.login.trim() || !creds.password.trim()) { }
return null;
} const generated = await this.generate(link, overallSignal);
const key = creds.login.trim().toLowerCase(); if (!generated) {
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal); this.cookie = "";
await this.login(creds.login, creds.password, overallSignal);
let generated = await this.generate(link, cookie, overallSignal); const retry = await this.generate(link, overallSignal);
if (!generated) { if (!retry) {
this.sessions.delete(key); return null;
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal); }
generated = await this.generate(link, cookie, overallSignal); return {
if (!generated) { directUrl: retry.directUrl,
return null; fileName: retry.fileName || filenameFromUrl(link),
} fileSize: null,
} retriesUsed: 0
return { };
directUrl: generated.directUrl, }
fileName: generated.fileName || filenameFromUrl(link),
fileSize: null, return {
retriesUsed: 0 directUrl: generated.directUrl,
}; fileName: generated.fileName || filenameFromUrl(link),
}, overallSignal); fileSize: null,
} retriesUsed: 0
};
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> { }, overallSignal);
const existing = this.sessions.get(key); }
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
return existing.cookie; public invalidateSession(): void {
} this.cookie = "";
const cookie = await this.login(login, password, signal); this.cookieSetAt = 0;
this.sessions.set(key, { cookie, setAt: Date.now() }); }
return cookie;
} private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
public invalidateSession(): void { const QUEUE_WAIT_TIMEOUT_MS = 90000;
this.sessions.clear(); const guardedJob = async (): Promise<T> => {
} throwIfAborted(signal);
const waited = Date.now() - queuedAt;
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> { if (waited > QUEUE_WAIT_TIMEOUT_MS) {
const queuedAt = Date.now(); throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
const QUEUE_WAIT_TIMEOUT_MS = 90000; }
const guardedJob = async (): Promise<T> => { return job();
throwIfAborted(signal); };
const waited = Date.now() - queuedAt; const run = this.queue.then(guardedJob, guardedJob);
if (waited > QUEUE_WAIT_TIMEOUT_MS) { this.queue = run.then(() => undefined, () => undefined);
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); return raceWithAbort(run, signal);
} }
return job();
}; private async login(login: string, password: string, signal?: AbortSignal): Promise<void> {
const run = this.queue.then(guardedJob, guardedJob); throwIfAborted(signal);
this.queue = run.then(() => undefined, () => undefined); const response = await fetch(LOGIN_URL, {
return raceWithAbort(run, signal); method: "POST",
} headers: {
"Content-Type": "application/x-www-form-urlencoded",
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> { "User-Agent": "Mozilla/5.0"
throwIfAborted(signal); },
const response = await fetch(LOGIN_URL, { body: new URLSearchParams({
method: "POST", login,
headers: { password,
"Content-Type": "application/x-www-form-urlencoded", remember: "on"
"User-Agent": "Mozilla/5.0" }),
}, redirect: "manual",
body: new URLSearchParams({ signal: withTimeoutSignal(signal, 30000)
login, });
password,
remember: "on" const cookie = parseSetCookieFromHeaders(response.headers);
}), if (!cookie) {
redirect: "manual", throw new Error("Mega-Web Login liefert kein Session-Cookie");
signal: withTimeoutSignal(signal, 30000) }
});
const verify = await fetch(DEBRID_REFERER, {
const cookie = parseSetCookieFromHeaders(response.headers); method: "GET",
if (!cookie) { headers: {
throw new Error("Mega-Web Login liefert kein Session-Cookie"); "User-Agent": "Mozilla/5.0",
} Cookie: cookie,
Referer: DEBRID_REFERER
const verify = await fetch(DEBRID_REFERER, { },
method: "GET", signal: withTimeoutSignal(signal, 30000)
headers: { });
"User-Agent": "Mozilla/5.0", const verifyHtml = await verify.text();
Cookie: cookie, const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
Referer: DEBRID_REFERER if (!hasDebridForm) {
}, throw new Error("Mega-Web Login ungültig oder Session blockiert");
signal: withTimeoutSignal(signal, 30000) }
});
const verifyHtml = await verify.text(); this.cookie = cookie;
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml); this.cookieSetAt = Date.now();
if (!hasDebridForm) { }
throw new Error("Mega-Web Login ungültig oder Session blockiert");
} private async generate(link: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
throwIfAborted(signal);
return cookie; const page = await fetch(DEBRID_URL, {
} method: "POST",
headers: {
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> { "Content-Type": "application/x-www-form-urlencoded",
throwIfAborted(signal); "User-Agent": "Mozilla/5.0",
const page = await fetch(DEBRID_URL, { Cookie: this.cookie,
method: "POST", Referer: DEBRID_REFERER
headers: { },
"Content-Type": "application/x-www-form-urlencoded", body: new URLSearchParams({
"User-Agent": "Mozilla/5.0", links: link,
Cookie: cookie, password: "",
Referer: DEBRID_REFERER showLinks: "1"
}, }),
body: new URLSearchParams({ signal: withTimeoutSignal(signal, 30000)
links: link, });
password: "",
showLinks: "1" const html = await page.text();
}),
signal: withTimeoutSignal(signal, 30000) // Check for permanent hoster errors before looking for debrid codes
}); const pageErrors = parsePageErrors(html);
const permanentError = isPermanentHosterError(pageErrors);
const html = await page.text(); if (permanentError) {
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
const pageErrors = parsePageErrors(html); }
const permanentError = isPermanentHosterError(pageErrors);
if (permanentError) { const code = pickCode(parseCodes(html), link);
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`); if (!code) {
} return null;
}
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
if (noServerError) { for (let attempt = 1; attempt <= 60; attempt += 1) {
throw new Error(`Mega-Web: ${noServerError}`); throwIfAborted(signal);
} const res = await fetch(DEBRID_AJAX_URL, {
method: "POST",
const code = pickCode(parseCodes(html), link); headers: {
if (!code) { "Content-Type": "application/x-www-form-urlencoded",
return null; "User-Agent": "Mozilla/5.0",
} Cookie: this.cookie,
Referer: DEBRID_REFERER
for (let attempt = 1; attempt <= 60; attempt += 1) { },
throwIfAborted(signal); body: new URLSearchParams({
const res = await fetch(DEBRID_AJAX_URL, { code,
method: "POST", autodl: "0"
headers: { }),
"Content-Type": "application/x-www-form-urlencoded", signal: withTimeoutSignal(signal, 15000)
"User-Agent": "Mozilla/5.0", });
Cookie: cookie,
Referer: DEBRID_REFERER const text = (await res.text()).trim();
}, if (text === "reload") {
body: new URLSearchParams({ await sleepWithSignal(650, signal);
code, continue;
autodl: "0" }
}), if (text === "false") {
signal: withTimeoutSignal(signal, 15000) return null;
}); }
const text = (await res.text()).trim(); const parsed = parseDebridJson(text);
if (text === "reload") { if (!parsed) {
await sleepWithSignal(650, signal); return null;
continue; }
}
if (text === "false") { if (!parsed.link) {
return null; if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) {
} await sleepWithSignal(1200, signal);
continue;
const parsed = parseDebridJson(text); }
if (!parsed) { return null;
return null; }
}
const fromText = parsed.text
if (!parsed.link) { .replace(/<[^>]*>/g, " ")
if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) { .replace(/\s+/g, " ")
await sleepWithSignal(1200, signal); .trim();
continue;
} const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i);
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim();
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) { return {
throw new Error(`Mega-Web: ${serverMsg}`); directUrl: parsed.link,
} fileName
return null; };
} }
const fromText = parsed.text return null;
.replace(/<[^>]*>/g, " ") }
.replace(/\s+/g, " ")
.trim(); public dispose(): void {
this.cookie = "";
const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i); }
const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim(); }
return {
directUrl: parsed.link, export function compactMegaWebError(error: unknown): string {
fileName return compactErrorText(error);
}; }
}
return null;
}
public dispose(): void {
this.sessions.clear();
}
}
export function compactMegaWebError(error: unknown): string {
return compactErrorText(error);
}

View File

@ -1,5 +1,4 @@
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,6 +92,7 @@ function flushPending(): void {
try { try {
fs.appendFileSync(logPath, chunk, "utf8"); fs.appendFileSync(logPath, chunk, "utf8");
} catch { } catch {
// ignore write errors
} }
} }
} }
@ -122,9 +122,11 @@ 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
} }
} }
@ -163,7 +165,7 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null {
} }
if (!initializedThisProcess.has(normalizedPackageId)) { if (!initializedThisProcess.has(normalizedPackageId)) {
initializedThisProcess.add(normalizedPackageId); initializedThisProcess.add(normalizedPackageId);
const startedAt = logTimestamp(); const startedAt = new Date().toISOString();
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`,
@ -171,7 +173,7 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null {
); );
fs.appendFileSync( fs.appendFileSync(
logPath, logPath,
`${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({ `${new Date().toISOString()} [INFO] Paket-Kontext initialisiert${formatFields({
name: meta.name, name: meta.name,
outputDir: meta.outputDir, outputDir: meta.outputDir,
extractDir: meta.extractDir extractDir: meta.extractDir
@ -195,7 +197,7 @@ export function logPackageEvent(
if (!logPath) { if (!logPath) {
return; return;
} }
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`; const line = `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`;
appendLine(packageId, line); appendLine(packageId, line);
} }
@ -219,8 +221,9 @@ export function shutdownPackageLogs(): void {
continue; continue;
} }
try { try {
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(logPath, `=== Paket-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
} }
pendingLinesByPackage.clear(); pendingLinesByPackage.clear();

View File

@ -158,10 +158,12 @@ 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
} }
} }
} }
@ -318,6 +320,7 @@ 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;
@ -327,12 +330,14 @@ 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;
} }
@ -394,6 +399,7 @@ export class RealDebridWebFallback {
const text = await response.text(); const text = await response.text();
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
// Token expired or revoked — invalidate cache
this.cachedToken = ""; this.cachedToken = "";
this.cachedTokenAt = 0; this.cachedTokenAt = 0;
return { kind: "login_required" }; return { kind: "login_required" };

View File

@ -82,6 +82,8 @@ 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");
} }

View File

@ -1,5 +1,4 @@
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";
@ -46,9 +45,11 @@ 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
} }
} }
@ -61,6 +62,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} }
} catch { } catch {
// ignore
} }
} }
@ -76,7 +78,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: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
} catch { } catch {
renameLogPath = null; renameLogPath = null;
} }
@ -93,10 +95,11 @@ export function logRenameEvent(level: RenameLogLevel, message: string, fields?:
} }
fs.appendFileSync( fs.appendFileSync(
renameLogPath, renameLogPath,
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`,
"utf8" "utf8"
); );
} catch { } catch {
// ignore write errors
} }
} }
@ -112,8 +115,9 @@ export function shutdownRenameLog(): void {
return; return;
} }
try { try {
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
renameLogPath = null; renameLogPath = null;
} }

View File

@ -1,5 +1,4 @@
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";
@ -30,6 +29,7 @@ function flushPending(): void {
try { try {
fs.appendFileSync(sessionLogPath, chunk, "utf8"); fs.appendFileSync(sessionLogPath, chunk, "utf8");
} catch { } catch {
// ignore write errors
} }
} }
@ -66,9 +66,11 @@ 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
} }
} }
@ -84,7 +86,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 = logTimestamp(); const isoTimestamp = new Date().toISOString();
try { try {
fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8"); fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8");
} catch { } catch {
@ -106,16 +108,19 @@ export function shutdownSessionLog(): void {
return; return;
} }
// Flush any pending lines
if (flushTimer) { if (flushTimer) {
clearTimeout(flushTimer); clearTimeout(flushTimer);
flushTimer = null; flushTimer = null;
} }
flushPending(); flushPending();
const isoTimestamp = logTimestamp(); // Write closing line
const isoTimestamp = new Date().toISOString();
try { try {
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8"); fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
setLogListener(null); setLogListener(null);

View File

@ -5,6 +5,17 @@ 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 {
@ -21,8 +32,8 @@ export interface HealthCheckReport {
infoCount: number; infoCount: number;
} }
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; // 50 MB
function safeExists(p: string): boolean { function safeExists(p: string): boolean {
try { try {
@ -41,6 +52,9 @@ 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 {
@ -52,8 +66,12 @@ 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;
@ -105,9 +123,12 @@ 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({
@ -131,6 +152,7 @@ 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));
@ -143,6 +165,7 @@ 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({
@ -159,6 +182,7 @@ 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) {
@ -172,6 +196,7 @@ 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",

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,8 @@ 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";
@ -53,26 +51,6 @@ 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");
@ -170,8 +148,6 @@ 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");
@ -184,15 +160,13 @@ 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");
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); addDirectoryIfExists(zip, path.join(baseDir, "package-logs"), "logs/package-logs");
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); addDirectoryIfExists(zip, path.join(baseDir, "item-logs"), "logs/item-logs");
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`);

View File

@ -1,5 +1,4 @@
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";
@ -64,6 +63,7 @@ function flushPending(): void {
try { try {
fs.appendFileSync(traceLogPath, chunk, "utf8"); fs.appendFileSync(traceLogPath, chunk, "utf8");
} catch { } catch {
// ignore
} }
} }
@ -77,9 +77,11 @@ 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
} }
} }
@ -92,6 +94,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} }
} catch { } catch {
// ignore
} }
} }
@ -159,6 +162,7 @@ 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
} }
} }
@ -185,10 +189,10 @@ function disableTraceDueToExpiry(): void {
...traceConfig, ...traceConfig,
enabled: false, enabled: false,
autoDisableAt: null, autoDisableAt: null,
updatedAt: logTimestamp() updatedAt: new Date().toISOString()
}); });
persistTraceConfig(); persistTraceConfig();
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`); appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
} }
function scheduleAutoDisable(): void { function scheduleAutoDisable(): void {
@ -226,7 +230,7 @@ export function initTraceLog(baseDir: string): void {
} }
traceConfig = loadTraceConfig(); traceConfig = loadTraceConfig();
persistTraceConfig(); persistTraceConfig();
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
} catch { } catch {
traceLogPath = null; traceLogPath = null;
traceConfigPath = null; traceConfigPath = null;
@ -259,11 +263,11 @@ export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTr
traceConfig = normalizeTraceConfig({ traceConfig = normalizeTraceConfig({
...traceConfig, ...traceConfig,
...patch, ...patch,
updatedAt: logTimestamp() updatedAt: new Date().toISOString()
}); });
persistTraceConfig(); persistTraceConfig();
scheduleAutoDisable(); scheduleAutoDisable();
appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`); appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
return getTraceConfig(); return getTraceConfig();
} }
@ -272,7 +276,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(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`); appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
return next; return next;
} }
@ -288,7 +292,7 @@ export function logTraceEvent(
if (category === "audit" && !traceConfig.includeAudit) { if (category === "audit" && !traceConfig.includeAudit) {
return; return;
} }
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`); appendTraceLine(`${new Date().toISOString()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
} }
export function shutdownTraceLog(): void { export function shutdownTraceLog(): void {
@ -303,8 +307,9 @@ export function shutdownTraceLog(): void {
} }
flushPending(); flushPending();
try { try {
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
traceLogPath = null; traceLogPath = null;
traceConfigPath = null; traceConfigPath = null;

View File

@ -8,6 +8,8 @@ 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;
@ -16,6 +18,8 @@ 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;
@ -36,6 +40,8 @@ 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" },
@ -46,16 +52,23 @@ 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"));
@ -74,6 +87,8 @@ 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;
@ -112,12 +127,15 @@ 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);
@ -172,18 +190,25 @@ 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, "+") .replace(/-/g, "+") // URL-safe → standard
.replace(/_/g, "/") .replace(/_/g, "/") // URL-safe → standard
.replace(/=+$/g, ""); .replace(/=+$/g, ""); // strip padding for consistent comparison
} }
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" };
@ -194,6 +219,8 @@ 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" };
@ -204,6 +231,8 @@ 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" };
} }
@ -211,6 +240,8 @@ 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" };
@ -240,6 +271,8 @@ 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, "");
@ -251,8 +284,10 @@ 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 "";
@ -270,24 +305,28 @@ 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;
@ -306,6 +345,7 @@ 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;
@ -315,6 +355,8 @@ 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) {
@ -360,6 +402,8 @@ 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;
@ -431,6 +475,8 @@ 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[] = [];
@ -509,6 +555,8 @@ 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;
@ -537,6 +585,8 @@ 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");
@ -561,6 +611,8 @@ 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,
@ -571,6 +623,7 @@ 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 {
@ -587,9 +640,11 @@ 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;
@ -608,11 +663,13 @@ 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;
@ -634,6 +691,7 @@ async function downloadFile(
} }
}; };
// Stream body to disk
try { try {
resetIdle(); resetIdle();
for (;;) { for (;;) {
@ -663,21 +721,25 @@ 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)`);
@ -741,6 +803,8 @@ 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;
@ -763,6 +827,7 @@ async function resolveAssetFromApi(repo: string, tag: string): Promise<{
setupAssetDigest: setup.digest, setupAssetDigest: setup.digest,
}; };
} catch { } catch {
// try next endpoint
} }
} }
return null; return null;
@ -798,11 +863,14 @@ 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"];
} }
@ -835,6 +903,7 @@ 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,
@ -847,6 +916,7 @@ 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);
@ -869,6 +939,7 @@ 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 || ""),
@ -876,6 +947,7 @@ 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) {
@ -888,6 +960,7 @@ 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) {
@ -896,6 +969,7 @@ 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;
@ -917,6 +991,7 @@ 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;
@ -955,6 +1030,7 @@ 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;
@ -997,6 +1073,7 @@ 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",
@ -1022,6 +1099,7 @@ 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}` : "";

View File

@ -1,468 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
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;
}
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/dubbed token —
// the ".DL." marker alone (present on every processed file) is not enough.
export function looksLikeGermanRelease(fileName: string): boolean {
return /(^|[._\s-])(german|deutsch|dubbed)([._\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;
}
const title = (stream.title || "").toLowerCase();
return /\b(german|deutsch|ger|deu)\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;
}
}
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 ext = path.extname(filePath);
// Short, same-directory temp name (never longer than the original file name) so
// a long scene filename + temp suffix cannot push the temp path past Windows
// MAX_PATH and make ffmpeg fail (which would leave the file unprocessed).
const tempPath = path.join(path.dirname(filePath), `~rdtmp${ext}`);
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 };
}
try {
// libuv rename replaces an existing destination on Windows; fall back if not.
await fs.promises.rename(tempPath, filePath).catch(async () => {
await fs.promises.rm(filePath, { force: true });
await fs.promises.rename(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) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "error", reason: "Ersetzen der Datei fehlgeschlagen", error: String(error), totalAudioTracks: streams.length, audioLanguages };
}
return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length, audioLanguages };
}

View File

@ -319,6 +319,7 @@ export function hasRecentWindowsMinidumps(): boolean {
return true; return true;
} }
} catch { } catch {
// ignore
} }
} }
return false; return false;

View File

@ -1,29 +1,27 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import { 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, UiSnapshot,
UiSnapshot, UpdateCheckResult,
UpdateCheckResult, UpdateInstallProgress
UpdateInstallProgress } from "../shared/types";
} from "../shared/types"; import { IPC_CHANNELS } from "../shared/ipc";
import { IPC_CHANNELS } from "../shared/ipc"; import { ElectronApi } from "../shared/preload-api";
import { ElectronApi } from "../shared/preload-api";
const api: ElectronApi = {
const api: ElectronApi = { getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT), getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION), checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE), installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url), openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
@ -31,17 +29,17 @@ const api: ElectronApi = {
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> => addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths), ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> => resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy), ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL), clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START), start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds), startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP), stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE), togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId), cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName), renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds), reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId), removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
@ -57,9 +55,9 @@ const api: ElectronApi = {
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS), resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS), resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART), restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT), quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), importBackup: (): Promise<{ restored: 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),
@ -73,44 +71,41 @@ const api: ElectronApi = {
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes), setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN), rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), 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),
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority), setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
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); return () => {
return () => { ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener); };
}; },
}, onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
onClipboardDetected: (callback: (links: string[]) => void): (() => void) => { const listener = (_event: unknown, links: string[]): void => callback(links);
const listener = (_event: unknown, links: string[]): void => callback(links); ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener); return () => {
return () => { ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener); };
}; },
}, onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => {
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => { const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress);
const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress); ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener); return () => {
return () => { ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener); };
}; }
} };
};
contextBridge.exposeInMainWorld("rd", api);
contextBridge.exposeInMainWorld("rd", api);

View File

@ -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 { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts"; import { parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
import type { import type {
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
@ -32,7 +32,6 @@ 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";
@ -116,7 +115,6 @@ interface AccountDialogState {
megaAccounts: MegaDialogAccount[]; megaAccounts: MegaDialogAccount[];
megaNewLogin: string; megaNewLogin: string;
megaNewPassword: string; megaNewPassword: string;
megaDisabledIds: string[];
} }
interface DebridLinkAccountKeyEntry { interface DebridLinkAccountKeyEntry {
@ -466,12 +464,17 @@ 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);
} }
@ -581,7 +584,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" | "megaDisabledIds"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] }; const baseMega: Pick<AccountDialogState, "megaAccounts" | "megaNewLogin" | "megaNewPassword"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "" };
if (!kind) { if (!kind) {
return { return {
mode, mode,
@ -603,15 +606,14 @@ 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 }));
const loadedIds = new Set(parsed.map((a) => a.id)); return { mode, kind, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "" };
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 };
@ -676,18 +678,14 @@ 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 : "";
const validIds = new Set(megaParsed.map((a) => a.id)); return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridApiEnabled: true, megaDebridPreferApi: true, providerDailyLimitBytes: nextProviderDailyLimitBytes };
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 : "";
const validIds = new Set(megaParsed.map((a) => a.id)); return { ...settings, megaCredentials: megaSerialized, megaLogin: firstLogin, megaPassword: firstPassword, megaDebridWebEnabled: true, megaDebridPreferApi: false, providerDailyLimitBytes: nextProviderDailyLimitBytes };
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 };
@ -844,14 +842,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, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true, autoExtract: true, autoRename4sf4sj: false, 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, backupIncludeDownloads: false, theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true,
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"],
@ -868,7 +866,6 @@ const emptySnapshot = (): UiSnapshot => ({
megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {}, megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {}, megaDebridAccountTotalUsageBytes: {},
debridAccountStatuses: {},
providerDailyUsageDay: getProviderUsageDayKey(), providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0 scheduledStartEpochMs: 0
}, },
@ -1124,46 +1121,6 @@ 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
@ -1270,8 +1227,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 ? "#f2942d" : "#c2701a"; const accentColor = isDark ? "#38bdf8" : "#1168d9";
const fillColor = isDark ? "rgba(242, 148, 45, 0.15)" : "rgba(194, 112, 26, 0.15)"; const fillColor = isDark ? "rgba(56, 189, 248, 0.15)" : "rgba(17, 104, 217, 0.15)";
const history = speedHistoryRef.current; const history = speedHistoryRef.current;
const now = Date.now(); const now = Date.now();
@ -1285,6 +1242,7 @@ 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) {
@ -1367,7 +1325,12 @@ 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;
} }
@ -1378,6 +1341,7 @@ 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();
@ -1433,6 +1397,7 @@ 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) => {
@ -1567,6 +1532,10 @@ 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;
@ -1600,8 +1569,6 @@ 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);
@ -1675,6 +1642,7 @@ 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> => {
@ -1696,6 +1664,7 @@ 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]);
@ -1704,10 +1673,16 @@ 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]
@ -1717,6 +1692,7 @@ 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];
@ -1892,6 +1868,7 @@ 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) {
@ -1914,6 +1891,8 @@ 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) {
@ -2114,16 +2093,14 @@ 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;
@ -2161,6 +2138,7 @@ 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>();
@ -2177,6 +2155,7 @@ 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);
@ -2197,6 +2176,9 @@ 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]);
@ -2207,8 +2189,10 @@ 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;
@ -2613,42 +2597,6 @@ 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));
@ -3293,7 +3241,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; if (prev !== packageId) return prev; // already finished (e.g. blur after Enter key)
shouldRename = true; shouldRename = true;
return null; return null;
}); });
@ -3379,6 +3327,8 @@ 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] } };
@ -3387,6 +3337,7 @@ 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 } };
@ -3536,6 +3487,7 @@ 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) {
@ -3551,13 +3503,8 @@ 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; if (dragDidMoveRef.current) return; // drag handled it, skip click
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);
@ -3604,6 +3551,7 @@ 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; });
@ -3616,6 +3564,7 @@ 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) {
@ -3745,6 +3694,7 @@ 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);
@ -3815,6 +3765,7 @@ 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());
@ -3855,13 +3806,6 @@ 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);
} }
@ -3985,10 +3929,7 @@ export function App(): ReactElement {
if (inInput) return; if (inInput) return;
if (tabRef.current === "downloads") { if (tabRef.current === "downloads") {
e.preventDefault(); e.preventDefault();
// Select exactly the VISIBLE rows (packages + their items), honouring setSelectedIds(new Set(Object.keys(snapshotRef.current.session.packages)));
// 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)));
@ -4492,7 +4433,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];
@ -4908,7 +4849,6 @@ 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;
@ -4928,14 +4868,9 @@ 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 accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
<button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts"> Account hinzufügen
{accountCheckBusy ? "Prüfe Accounts…" : "Alle prüfen"} </button>
</button>
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
Account hinzufügen
</button>
</div>
</div> </div>
<div className="account-board-summary"> <div className="account-board-summary">
@ -5100,31 +5035,6 @@ 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">
@ -5376,11 +5286,6 @@ 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>
@ -5676,21 +5581,13 @@ 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={() => { onClick={() => setAccountDialog((prev) => {
const login = accountDialog.megaNewLogin.trim(); if (!prev || !prev.megaNewLogin.trim() || !prev.megaNewPassword.trim()) return prev;
const password = accountDialog.megaNewPassword.trim(); const exists = prev.megaAccounts.some((a) => a.login.trim().toLowerCase() === prev.megaNewLogin.trim().toLowerCase());
if (!login || !password) return; if (exists) return prev;
const exists = accountDialog.megaAccounts.some((a) => a.login.trim().toLowerCase() === login.toLowerCase()); const nextAccounts = [...prev.megaAccounts, { login: prev.megaNewLogin.trim(), password: prev.megaNewPassword.trim() }];
setAccountDialog((prev) => { return { ...prev, megaAccounts: nextAccounts, megaNewLogin: "", megaNewPassword: "", token: serializeMegaDebridAccounts(nextAccounts) };
if (!prev || !prev.megaNewLogin.trim() || !prev.megaNewPassword.trim()) return prev; })}
if (prev.megaAccounts.some((a) => a.login.trim().toLowerCase() === login.toLowerCase())) return prev;
const nextAccounts = [...prev.megaAccounts, { login, password }];
return { ...prev, megaAccounts: nextAccounts, megaNewLogin: "", megaNewPassword: "", token: serializeMegaDebridAccounts(nextAccounts) };
});
if (!exists) {
void runMegaAccountCheck(login, password);
}
}}
> >
Hinzufügen Hinzufügen
</button> </button>
@ -5698,56 +5595,24 @@ 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) => (
const accId = getMegaDebridAccountId(account.login); <div key={index} className="account-dl-key-limit-row">
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 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
className="btn danger"
onClick={() => setAccountDialog((prev) => {
if (!prev) return prev;
const nextAccounts = prev.megaAccounts.filter((_, i) => i !== index);
return { ...prev, megaAccounts: nextAccounts, megaDisabledIds: (prev.megaDisabledIds || []).filter((id) => id !== accId), token: serializeMegaDebridAccounts(nextAccounts) };
})}
>
Entfernen
</button>
</div> </div>
<button
className="btn danger"
onClick={() => setAccountDialog((prev) => {
if (!prev) return prev;
const nextAccounts = prev.megaAccounts.filter((_, i) => i !== index);
return { ...prev, megaAccounts: nextAccounts, token: serializeMegaDebridAccounts(nextAccounts) };
})}
>
Entfernen
</button>
</div> </div>
); ))}
})}
</div> </div>
</div> </div>
)} )}
@ -5807,14 +5672,6 @@ 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"
@ -6111,6 +5968,7 @@ 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;
@ -6309,6 +6167,8 @@ 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 "";
@ -6334,6 +6194,9 @@ 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();
@ -6351,7 +6214,10 @@ 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) : "";
@ -6368,10 +6234,7 @@ 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}>
<span {item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />}
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>
); );
@ -6419,6 +6282,7 @@ 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;
@ -6486,6 +6350,8 @@ 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;
@ -6651,6 +6517,10 @@ 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)) {

View File

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

View File

@ -1,39 +1,8 @@
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");
@ -41,8 +10,6 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<ErrorBoundary> <App />
<App />
</ErrorBoundary>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -36,26 +36,38 @@ export function sortPackagesForDisplay(
return packages; return packages;
} }
const active: PackageEntry[] = []; const active: Array<{ pkg: PackageEntry; index: number; completedRatio: number; downloadedBytes: number }> = [];
const rest: PackageEntry[] = []; const rest: PackageEntry[] = [];
// Float packages that have an active item to the top, but keep BOTH groups in packages.forEach((pkg, index) => {
// their original (queue) order. Earlier this sorted the active group by live const items = pkg.itemIds
// completedRatio/downloadedBytes — which change on every progress tick (every .map((id) => itemsById[id])
// 150-700ms), so active packages visibly reshuffled the whole time. A package .filter((item): item is DownloadItem => Boolean(item));
// entering/leaving the active bucket is a real, discrete event (start/finish); const hasActive = items.some((item) => ACTIVE_PACKAGE_STATUSES.has(item.status));
// ranking *within* the bucket by live bytes was pure jitter nobody needs. if (!hasActive) {
for (const pkg of packages) { rest.push(pkg);
const hasActive = pkg.itemIds.some((id) => { return;
const item = itemsById[id]; }
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status); const completedRatio = items.length > 0
}); ? items.filter((item) => item.status === "completed").length / items.length
(hasActive ? active : rest).push(pkg); : 0;
} const downloadedBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
active.push({ pkg, index, completedRatio, downloadedBytes });
});
if (active.length === 0 || active.length === packages.length) { if (active.length === 0 || active.length === packages.length) {
return packages; return packages;
} }
return [...active, ...rest]; active.sort((a, b) => {
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];
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,42 +1,42 @@
export const IPC_CHANNELS = { export const IPC_CHANNELS = {
GET_SNAPSHOT: "app:get-snapshot", GET_SNAPSHOT: "app:get-snapshot",
GET_VERSION: "app:get-version", GET_VERSION: "app:get-version",
CHECK_UPDATES: "app:check-updates", CHECK_UPDATES: "app:check-updates",
INSTALL_UPDATE: "app:install-update", INSTALL_UPDATE: "app:install-update",
UPDATE_INSTALL_PROGRESS: "app:update-install-progress", UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
OPEN_EXTERNAL: "app:open-external", OPEN_EXTERNAL: "app:open-external",
UPDATE_SETTINGS: "app:update-settings", UPDATE_SETTINGS: "app:update-settings",
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage", RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage", RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
ADD_LINKS: "queue:add-links", ADD_LINKS: "queue:add-links",
ADD_CONTAINERS: "queue:add-containers", ADD_CONTAINERS: "queue:add-containers",
GET_START_CONFLICTS: "queue:get-start-conflicts", GET_START_CONFLICTS: "queue:get-start-conflicts",
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict", RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
CLEAR_ALL: "queue:clear-all", CLEAR_ALL: "queue:clear-all",
START: "queue:start", START: "queue:start",
START_PACKAGES: "queue:start-packages", START_PACKAGES: "queue:start-packages",
STOP: "queue:stop", STOP: "queue:stop",
TOGGLE_PAUSE: "queue:toggle-pause", TOGGLE_PAUSE: "queue:toggle-pause",
CANCEL_PACKAGE: "queue:cancel-package", CANCEL_PACKAGE: "queue:cancel-package",
RENAME_PACKAGE: "queue:rename-package", RENAME_PACKAGE: "queue:rename-package",
REORDER_PACKAGES: "queue:reorder-packages", REORDER_PACKAGES: "queue:reorder-packages",
REMOVE_ITEM: "queue:remove-item", REMOVE_ITEM: "queue:remove-item",
TOGGLE_PACKAGE: "queue:toggle-package", TOGGLE_PACKAGE: "queue:toggle-package",
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection", EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
EXPORT_ITEM_SELECTION: "queue:export-item-selection", EXPORT_ITEM_SELECTION: "queue:export-item-selection",
EXPORT_QUEUE: "queue:export", EXPORT_QUEUE: "queue:export",
IMPORT_QUEUE: "queue:import", IMPORT_QUEUE: "queue:import",
PICK_FOLDER: "dialog:pick-folder", PICK_FOLDER: "dialog:pick-folder",
PICK_CONTAINERS: "dialog:pick-containers", PICK_CONTAINERS: "dialog:pick-containers",
STATE_UPDATE: "state:update", STATE_UPDATE: "state:update",
CLIPBOARD_DETECTED: "clipboard:detected", CLIPBOARD_DETECTED: "clipboard:detected",
TOGGLE_CLIPBOARD: "clipboard:toggle", TOGGLE_CLIPBOARD: "clipboard:toggle",
GET_SESSION_STATS: "stats:get-session-stats", GET_SESSION_STATS: "stats:get-session-stats",
RESET_SESSION_STATS: "stats:reset-session", RESET_SESSION_STATS: "stats:reset-session",
RESET_DOWNLOAD_STATS: "stats:reset-download", RESET_DOWNLOAD_STATS: "stats:reset-download",
RESTART: "app:restart", RESTART: "app:restart",
QUIT: "app:quit", QUIT: "app:quit",
EXPORT_BACKUP: "app:export-backup", EXPORT_BACKUP: "app:export-backup",
IMPORT_BACKUP: "app:import-backup", IMPORT_BACKUP: "app:import-backup",
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle", EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
OPEN_LOG: "app:open-log", OPEN_LOG: "app:open-log",
@ -51,21 +51,18 @@ export const IPC_CHANNELS = {
SET_TRACE_ENABLED: "app:set-trace-enabled", SET_TRACE_ENABLED: "app:set-trace-enabled",
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token", ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login", OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info", GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits", 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",
GET_HISTORY: "history:get", GET_HISTORY: "history:get",
CLEAR_HISTORY: "history:clear", CLEAR_HISTORY: "history:clear",
REMOVE_HISTORY_ENTRY: "history:remove-entry", REMOVE_HISTORY_ENTRY: "history:remove-entry",
SET_PACKAGE_PRIORITY: "queue:set-package-priority", SET_PACKAGE_PRIORITY: "queue:set-package-priority",
SKIP_ITEMS: "queue:skip-items", SKIP_ITEMS: "queue:skip-items",
RESET_ITEMS: "queue:reset-items", RESET_ITEMS: "queue:reset-items",
START_ITEMS: "queue:start-items", START_ITEMS: "queue:start-items"
LOG_RENDERER_ERROR: "log:renderer-error" } as const;
} as const;

View File

@ -39,6 +39,11 @@ 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 || "")
@ -55,6 +60,7 @@ 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;
} }

View File

@ -1,15 +1,13 @@
import type { 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,
@ -17,30 +15,30 @@ import type {
UiSnapshot, UiSnapshot,
UpdateCheckResult, UpdateCheckResult,
UpdateInstallProgress, UpdateInstallProgress,
UpdateInstallResult UpdateInstallResult
} from "./types"; } from "./types";
export interface ElectronApi { export interface ElectronApi {
getSnapshot: () => Promise<UiSnapshot>; getSnapshot: () => Promise<UiSnapshot>;
getVersion: () => Promise<string>; getVersion: () => Promise<string>;
checkUpdates: () => Promise<UpdateCheckResult>; checkUpdates: () => Promise<UpdateCheckResult>;
installUpdate: () => Promise<UpdateInstallResult>; installUpdate: () => Promise<UpdateInstallResult>;
openExternal: (url: string) => Promise<boolean>; openExternal: (url: string) => Promise<boolean>;
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>; updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>; resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>; resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
getStartConflicts: () => Promise<StartConflictEntry[]>; getStartConflicts: () => Promise<StartConflictEntry[]>;
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>; resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
clearAll: () => Promise<void>; clearAll: () => Promise<void>;
start: () => Promise<void>; start: () => Promise<void>;
startPackages: (packageIds: string[]) => Promise<void>; startPackages: (packageIds: string[]) => Promise<void>;
stop: () => Promise<void>; stop: () => Promise<void>;
togglePause: () => Promise<boolean>; togglePause: () => Promise<boolean>;
cancelPackage: (packageId: string) => Promise<void>; cancelPackage: (packageId: string) => Promise<void>;
renamePackage: (packageId: string, newName: string) => Promise<void>; renamePackage: (packageId: string, newName: string) => Promise<void>;
reorderPackages: (packageIds: string[]) => Promise<void>; reorderPackages: (packageIds: string[]) => Promise<void>;
removeItem: (itemId: string) => Promise<void>; removeItem: (itemId: string) => Promise<void>;
togglePackage: (packageId: string) => Promise<void>; togglePackage: (packageId: string) => Promise<void>;
exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>; exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
@ -54,9 +52,9 @@ export interface ElectronApi {
resetSessionStats: () => Promise<void>; resetSessionStats: () => Promise<void>;
resetDownloadStats: () => Promise<void>; resetDownloadStats: () => Promise<void>;
restart: () => Promise<void>; restart: () => Promise<void>;
quit: () => Promise<void>; quit: () => Promise<void>;
exportBackup: () => Promise<{ saved: boolean }>; exportBackup: () => Promise<{ saved: boolean }>;
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>; importBackup: () => Promise<{ restored: 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>;
@ -70,24 +68,21 @@ export interface ElectronApi {
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>; setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
rotateDebugToken: () => Promise<{ path: string }>; rotateDebugToken: () => Promise<{ path: string }>;
openRealDebridLogin: () => Promise<void>; openRealDebridLogin: () => Promise<void>;
openAllDebridLogin: () => Promise<void>; openAllDebridLogin: () => Promise<void>;
importBestDebridCookies: () => Promise<number>; importBestDebridCookies: () => Promise<number>;
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>; getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>; 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>;
getHistory: () => Promise<HistoryEntry[]>; getHistory: () => Promise<HistoryEntry[]>;
clearHistory: () => Promise<void>; clearHistory: () => Promise<void>;
removeHistoryEntry: (entryId: string) => Promise<void>; removeHistoryEntry: (entryId: string) => Promise<void>;
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>; setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
skipItems: (itemIds: string[]) => Promise<void>; skipItems: (itemIds: string[]) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>; resetItems: (itemIds: string[]) => Promise<void>;
startItems: (itemIds: string[]) => Promise<void>; startItems: (itemIds: string[]) => Promise<void>;
reportRendererError: (report: RendererErrorReport) => void; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; }
}

View File

@ -250,6 +250,8 @@ 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);
} }

View File

@ -1,513 +1,478 @@
export type DownloadStatus = export type DownloadStatus =
| "queued" | "queued"
| "validating" | "validating"
| "downloading" | "downloading"
| "paused" | "paused"
| "reconnect_wait" | "reconnect_wait"
| "extracting" | "extracting"
| "integrity_check" | "integrity_check"
| "completed" | "completed"
| "failed" | "failed"
| "cancelled"; | "cancelled";
export type CleanupMode = "none" | "trash" | "delete"; export type CleanupMode = "none" | "trash" | "delete";
export type ConflictMode = "overwrite" | "skip" | "rename" | "ask"; export type ConflictMode = "overwrite" | "skip" | "rename" | "ask";
export type SpeedMode = "global" | "per_download"; export type SpeedMode = "global" | "per_download";
export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done"; export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done";
export type DebridProvider = export type DebridProvider =
| "realdebrid" | "realdebrid"
| "megadebrid" | "megadebrid"
| "megadebrid-api" | "megadebrid-api"
| "megadebrid-web" | "megadebrid-web"
| "bestdebrid" | "bestdebrid"
| "alldebrid" | "alldebrid"
| "ddownload" | "ddownload"
| "onefichier" | "onefichier"
| "debridlink" | "debridlink"
| "linksnappy"; | "linksnappy";
export type DebridFallbackProvider = DebridProvider | "none"; export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light"; export type AppTheme = "dark" | "light";
export type PackagePriority = "high" | "normal" | "low"; export type PackagePriority = "high" | "normal" | "low";
export type ExtractCpuPriority = "high" | "middle" | "low"; export type ExtractCpuPriority = "high" | "middle" | "low";
export type HistoryRetentionMode = "never" | "session" | "permanent"; export type HistoryRetentionMode = "never" | "session" | "permanent";
export interface BandwidthScheduleEntry { export interface BandwidthScheduleEntry {
id: string; id: string;
startHour: number; startHour: number;
endHour: number; endHour: number;
speedLimitKbps: number; speedLimitKbps: number;
enabled: boolean; enabled: boolean;
} }
export interface DownloadStats { export interface DownloadStats {
totalDownloaded: number; totalDownloaded: number;
totalDownloadedAllTime: number; totalDownloadedAllTime: number;
totalFiles?: number; totalFiles?: number;
totalFilesSession: number; totalFilesSession: number;
totalFilesAllTime: number; totalFilesAllTime: number;
totalPackages: number; totalPackages: number;
sessionStartedAt: number; sessionStartedAt: number;
appSessionStartedAt: number; appSessionStartedAt: number;
sessionRuntimeMs: number; sessionRuntimeMs: number;
totalRuntimeMs: number; totalRuntimeMs: number;
runtimeMeasuredAt: number; runtimeMeasuredAt: number;
} }
export interface DebridAccountStatus { export interface AppSettings {
accountId: string; token: string;
provider: "megadebrid" | "debridlink"; realDebridUseWebLogin: boolean;
label: string; megaLogin: string;
maskedLogin: string; megaPassword: string;
valid: boolean; megaCredentials: string;
isPremium: boolean; megaDebridApiEnabled: boolean;
premiumUntilMs: number | null; megaDebridWebEnabled: boolean;
email?: string; megaDebridPreferApi: boolean;
message: string; bestToken: string;
checkedAt: number; bestDebridUseWebLogin: boolean;
} allDebridToken: string;
allDebridUseWebLogin: boolean;
export interface AppSettings { ddownloadLogin: string;
token: string; ddownloadPassword: string;
realDebridUseWebLogin: boolean; oneFichierApiKey: string;
megaLogin: string; debridLinkApiKeys: string;
megaPassword: string; debridLinkDisabledKeyIds: string[];
megaCredentials: string; linkSnappyLogin: string;
megaDebridApiEnabled: boolean; linkSnappyPassword: string;
megaDebridWebEnabled: boolean; archivePasswordList: string;
megaDebridPreferApi: boolean; rememberToken: boolean;
bestToken: string; providerOrder: readonly DebridProvider[];
bestDebridUseWebLogin: boolean; providerPrimary: DebridProvider;
allDebridToken: string; providerSecondary: DebridFallbackProvider;
allDebridUseWebLogin: boolean; providerTertiary: DebridFallbackProvider;
ddownloadLogin: string; autoProviderFallback: boolean;
ddownloadPassword: string; outputDir: string;
oneFichierApiKey: string; packageName: string;
debridLinkApiKeys: string; autoExtract: boolean;
debridLinkDisabledKeyIds: string[]; autoRename4sf4sj: boolean;
linkSnappyLogin: string; extractDir: string;
linkSnappyPassword: string; collectMkvToLibrary: boolean;
archivePasswordList: string; mkvLibraryDir: string;
rememberToken: boolean; createExtractSubfolder: boolean;
providerOrder: readonly DebridProvider[]; hybridExtract: boolean;
providerPrimary: DebridProvider; cleanupMode: CleanupMode;
providerSecondary: DebridFallbackProvider; extractConflictMode: ConflictMode;
providerTertiary: DebridFallbackProvider; removeLinkFilesAfterExtract: boolean;
autoProviderFallback: boolean; removeSamplesAfterExtract: boolean;
outputDir: string; enableIntegrityCheck: boolean;
packageName: string; autoResumeOnStart: boolean;
autoExtract: boolean; autoReconnect: boolean;
autoRename4sf4sj: boolean; reconnectWaitSeconds: number;
keepGermanAudioOnly: boolean; completedCleanupPolicy: FinishedCleanupPolicy;
germanAudioMode: "tag" | "first"; maxParallel: number;
extractDir: string; maxParallelExtract: number;
collectMkvToLibrary: boolean; retryLimit: number;
mkvLibraryDir: string; speedLimitEnabled: boolean;
createExtractSubfolder: boolean; speedLimitKbps: number;
hybridExtract: boolean; speedLimitMode: SpeedMode;
cleanupMode: CleanupMode; updateRepo: string;
extractConflictMode: ConflictMode; autoUpdateCheck: boolean;
removeLinkFilesAfterExtract: boolean; clipboardWatch: boolean;
removeSamplesAfterExtract: boolean; minimizeToTray: boolean;
enableIntegrityCheck: boolean; theme: AppTheme;
autoResumeOnStart: boolean; collapseNewPackages: boolean;
autoReconnect: boolean; historyRetentionMode: HistoryRetentionMode;
reconnectWaitSeconds: number; accountListShowDetailedDebridLinkKeys: boolean;
completedCleanupPolicy: FinishedCleanupPolicy; autoSortPackagesByProgress: boolean;
maxParallel: number; autoSkipExtracted: boolean;
maxParallelExtract: number; hideExtractedItems: boolean;
retryLimit: number; confirmDeleteSelection: boolean;
speedLimitEnabled: boolean; totalDownloadedAllTime: number;
speedLimitKbps: number; totalCompletedFilesAllTime: number;
speedLimitMode: SpeedMode; totalRuntimeAllTimeMs: number;
updateRepo: string; bandwidthSchedules: BandwidthScheduleEntry[];
autoUpdateCheck: boolean; columnOrder: string[];
clipboardWatch: boolean; extractCpuPriority: ExtractCpuPriority;
minimizeToTray: boolean; autoExtractWhenStopped: boolean;
theme: AppTheme; disabledProviders: DebridProvider[];
collapseNewPackages: boolean; hosterRouting: Record<string, DebridProvider>;
historyRetentionMode: HistoryRetentionMode; providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
accountListShowDetailedDebridLinkKeys: boolean; providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
autoSortPackagesByProgress: boolean; providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
autoSkipExtracted: boolean; debridLinkApiKeyDailyLimitBytes: Record<string, number>;
hideExtractedItems: boolean; debridLinkApiKeyDailyUsageBytes: Record<string, number>;
confirmDeleteSelection: boolean; debridLinkApiKeyTotalUsageBytes: Record<string, number>;
backupIncludeDownloads: boolean; megaDebridDisabledAccountIds: string[];
totalDownloadedAllTime: number; megaDebridAccountDailyLimitBytes: Record<string, number>;
totalCompletedFilesAllTime: number; megaDebridAccountDailyUsageBytes: Record<string, number>;
totalRuntimeAllTimeMs: number; megaDebridAccountTotalUsageBytes: Record<string, number>;
bandwidthSchedules: BandwidthScheduleEntry[]; providerDailyUsageDay: string;
columnOrder: string[]; scheduledStartEpochMs: number;
extractCpuPriority: ExtractCpuPriority; }
autoExtractWhenStopped: boolean;
disabledProviders: DebridProvider[]; export interface DownloadItem {
hosterRouting: Record<string, DebridProvider>; id: string;
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>; packageId: string;
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>; url: string;
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>; provider: DebridProvider | null;
debridLinkApiKeyDailyLimitBytes: Record<string, number>; providerLabel?: string;
debridLinkApiKeyDailyUsageBytes: Record<string, number>; providerAccountId?: string;
debridLinkApiKeyTotalUsageBytes: Record<string, number>; providerAccountLabel?: string;
megaDebridDisabledAccountIds: string[]; status: DownloadStatus;
megaDebridAccountDailyLimitBytes: Record<string, number>; retries: number;
megaDebridAccountDailyUsageBytes: Record<string, number>; speedBps: number;
megaDebridAccountTotalUsageBytes: Record<string, number>; downloadedBytes: number;
debridAccountStatuses: Record<string, DebridAccountStatus>; totalBytes: number | null;
providerDailyUsageDay: string; progressPercent: number;
scheduledStartEpochMs: number; fileName: string;
} targetPath: string;
resumable: boolean;
export interface DownloadItem { attempts: number;
id: string; lastError: string;
packageId: string; fullStatus: string;
url: string; createdAt: number;
provider: DebridProvider | null; updatedAt: number;
providerLabel?: string; onlineStatus?: "online" | "offline" | "checking";
providerAccountId?: string; }
providerAccountLabel?: string;
status: DownloadStatus; export interface PackageEntry {
retries: number; id: string;
speedBps: number; name: string;
downloadedBytes: number; outputDir: string;
totalBytes: number | null; extractDir: string;
progressPercent: number; status: DownloadStatus;
fileName: string; itemIds: string[];
targetPath: string; cancelled: boolean;
resumable: boolean; enabled: boolean;
attempts: number; priority?: PackagePriority;
lastError: string; postProcessLabel?: string;
fullStatus: string; downloadStartedAt?: number;
createdAt: number; downloadCompletedAt?: number;
updatedAt: number; createdAt: number;
onlineStatus?: "online" | "offline" | "checking"; updatedAt: number;
} }
export interface PackageEntry { export interface SessionState {
id: string; version: number;
name: string; packageOrder: string[];
outputDir: string; packages: Record<string, PackageEntry>;
extractDir: string; items: Record<string, DownloadItem>;
status: DownloadStatus; runStartedAt: number;
itemIds: string[]; totalDownloadedBytes: number;
cancelled: boolean; summaryText: string;
enabled: boolean; reconnectUntil: number;
priority?: PackagePriority; reconnectReason: string;
postProcessLabel?: string; paused: boolean;
downloadStartedAt?: number; running: boolean;
downloadCompletedAt?: number; updatedAt: number;
createdAt: number; }
updatedAt: number;
} export interface DownloadSummary {
total: number;
export interface SessionState { success: number;
version: number; failed: number;
packageOrder: string[]; cancelled: number;
packages: Record<string, PackageEntry>; extracted: number;
items: Record<string, DownloadItem>; durationSeconds: number;
runStartedAt: number; averageSpeedBps: number;
totalDownloadedBytes: number; }
summaryText: string;
reconnectUntil: number; export interface ParsedPackageInput {
reconnectReason: string; name: string;
paused: boolean; links: string[];
running: boolean; fileNames?: string[];
updatedAt: number; }
}
export interface ContainerImportResult {
export interface DownloadSummary { packages: ParsedPackageInput[];
total: number; source: "dlc";
success: number; }
failed: number;
cancelled: number; export interface UiSnapshot {
extracted: number; settings: AppSettings;
durationSeconds: number; session: SessionState;
averageSpeedBps: number; summary: DownloadSummary | null;
} stats: DownloadStats;
speedText: string;
export interface ParsedPackageInput { etaText: string;
name: string; canStart: boolean;
links: string[]; canStop: boolean;
fileNames?: string[]; canPause: boolean;
} clipboardActive: boolean;
reconnectSeconds: number;
export interface ContainerImportResult { packageSpeedBps: Record<string, number>;
packages: ParsedPackageInput[]; /** When set to "delta", session.items contains ONLY items that changed since
source: "dlc"; * 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). */
export interface RotationEvent { payloadKind?: "full" | "delta";
id: string; /** Item IDs to remove from the renderer's master state when payloadKind="delta". */
at: number; removedItemIds?: string[];
level: "INFO" | "WARN" | "ERROR"; /** Package IDs to remove from the renderer's master state when payloadKind="delta". */
provider: string; removedPackageIds?: string[];
accountLabel: string; }
event: string;
reason?: string; export interface AddLinksPayload {
category?: string; rawText: string;
cooldownSec?: number; packageName?: string;
next?: string; duplicatePolicy?: DuplicatePolicy;
} }
export interface UiSnapshot { export interface AddContainerPayload {
settings: AppSettings; filePaths: string[];
session: SessionState; }
summary: DownloadSummary | null;
stats: DownloadStats; export type DuplicatePolicy = "keep" | "skip" | "overwrite";
speedText: string;
etaText: string; export interface QueueAddResult {
canStart: boolean; addedPackages: number;
canStop: boolean; addedLinks: number;
canPause: boolean; skippedExistingPackages: string[];
clipboardActive: boolean; overwrittenPackages: string[];
reconnectSeconds: number; }
packageSpeedBps: Record<string, number>;
payloadKind?: "full" | "delta"; export interface ContainerConflictResult {
removedItemIds?: string[]; conflicts: string[];
removedPackageIds?: string[]; packageCount: number;
rotationEvents?: RotationEvent[]; linkCount: number;
} }
export interface AddLinksPayload { export interface StartConflictEntry {
rawText: string; packageId: string;
packageName?: string; packageName: string;
duplicatePolicy?: DuplicatePolicy; extractDir: string;
} }
export interface AddContainerPayload { export interface StartConflictResolutionResult {
filePaths: string[]; skipped: boolean;
} overwritten: boolean;
}
export type DuplicatePolicy = "keep" | "skip" | "overwrite";
export interface UpdateCheckResult {
export interface QueueAddResult { updateAvailable: boolean;
addedPackages: number; currentVersion: string;
addedLinks: number; latestVersion: string;
skippedExistingPackages: string[]; latestTag: string;
overwrittenPackages: string[]; releaseUrl: string;
} setupAssetUrl?: string;
setupAssetName?: string;
export interface ContainerConflictResult { setupAssetDigest?: string;
conflicts: string[]; releaseNotes?: string;
packageCount: number; error?: string;
linkCount: number; }
}
export interface UpdateInstallResult {
export interface StartConflictEntry { started: boolean;
packageId: string; message: string;
packageName: string; }
extractDir: string;
} export interface UpdateInstallProgress {
stage: "starting" | "downloading" | "verifying" | "launching" | "done" | "error";
export interface StartConflictResolutionResult { percent: number | null;
skipped: boolean; downloadedBytes: number;
overwritten: boolean; totalBytes: number | null;
} message: string;
}
export interface UpdateCheckResult {
updateAvailable: boolean; export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown";
currentVersion: string; export type AllDebridHostInfoSource = "api" | "web";
latestVersion: string; export type DebridLinkHostState = "up" | "down" | "unknown";
latestTag: string; export type DebridLinkKeyState = "ready" | "cooldown" | "invalid" | "quota" | "rate_limit" | "error" | "unknown";
releaseUrl: string;
setupAssetUrl?: string; export interface AllDebridHostInfo {
setupAssetName?: string; host: string;
setupAssetDigest?: string; source: AllDebridHostInfoSource;
releaseNotes?: string; state: AllDebridHostState;
error?: string; statusLabel: string;
} fetchedAt: number;
lastCheckedAt: number | null;
export interface UpdateInstallResult { quota: number | null;
started: boolean; quotaMax: number | null;
message: string; quotaType: string;
} limitSimuDl: number | null;
note: string;
export interface UpdateInstallProgress { }
stage: "starting" | "downloading" | "verifying" | "launching" | "done" | "error";
percent: number | null; export interface DebridLinkHostLimitInfo {
downloadedBytes: number; keyId: string;
totalBytes: number | null; keyLabel: string;
message: string; host: string;
} fetchedAt: number;
trafficCurrentBytes: number | null;
export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown"; trafficMaxBytes: number | null;
export type AllDebridHostInfoSource = "api" | "web"; linksCurrent: number | null;
export type DebridLinkHostState = "up" | "down" | "unknown"; linksMax: number | null;
export type DebridLinkKeyState = "ready" | "cooldown" | "invalid" | "quota" | "rate_limit" | "error" | "unknown"; note: string;
state: DebridLinkKeyState;
export interface AllDebridHostInfo { stateLabel: string;
host: string; stateDetail: string;
source: AllDebridHostInfoSource; cooldownUntil: number | null;
state: AllDebridHostState; cooldownRemainingMs: number;
statusLabel: string; lastCheckedAt: number | null;
fetchedAt: number; hostState: DebridLinkHostState;
lastCheckedAt: number | null; hostStateLabel: string;
quota: number | null; hostNote: string;
quotaMax: number | null; }
quotaType: string;
limitSimuDl: number | null; export interface ParsedHashEntry {
note: string; fileName: string;
} algorithm: "crc32" | "md5" | "sha1";
digest: string;
export interface DebridLinkHostLimitInfo { }
keyId: string;
keyLabel: string; export interface BandwidthSample {
host: string; timestamp: number;
fetchedAt: number; speedBps: number;
trafficCurrentBytes: number | null; }
trafficMaxBytes: number | null;
linksCurrent: number | null; export interface BandwidthStats {
linksMax: number | null; samples: BandwidthSample[];
note: string; currentSpeedBps: number;
state: DebridLinkKeyState; averageSpeedBps: number;
stateLabel: string; maxSpeedBps: number;
stateDetail: string; totalBytesSession: number;
cooldownUntil: number | null; sessionDurationSeconds: number;
cooldownRemainingMs: number; }
lastCheckedAt: number | null;
hostState: DebridLinkHostState; export interface SessionStats {
hostStateLabel: string; bandwidth: BandwidthStats;
hostNote: string; totalDownloads: number;
} completedDownloads: number;
failedDownloads: number;
export interface ParsedHashEntry { activeDownloads: number;
fileName: string; queuedDownloads: number;
algorithm: "crc32" | "md5" | "sha1"; }
digest: string;
} export interface SupportTraceConfig {
enabled: boolean;
export interface BandwidthSample { includeMainLog: boolean;
timestamp: number; includeAudit: boolean;
speedBps: number; logDebugRequests: boolean;
} autoDisableAt: string | null;
updatedAt: string;
export interface BandwidthStats { }
samples: BandwidthSample[];
currentSpeedBps: number; export interface SupportFileSizeInfo {
averageSpeedBps: number; path: string | null;
maxSpeedBps: number; exists: boolean;
totalBytesSession: number; bytes: number;
sessionDurationSeconds: number; }
}
export interface SupportDirectorySizeInfo {
export interface SessionStats { path: string;
bandwidth: BandwidthStats; exists: boolean;
totalDownloads: number; fileCount: number;
completedDownloads: number; bytes: number;
failedDownloads: number; }
activeDownloads: number;
queuedDownloads: number; export interface SupportDiskSpaceInfo {
} path: string;
totalBytes: number | null;
export interface SupportTraceConfig { freeBytes: number | null;
enabled: boolean; freePercent: number | null;
includeMainLog: boolean; }
includeAudit: boolean;
logDebugRequests: boolean; export interface SupportBundleEstimate {
autoDisableAt: string | null; estimatedBytes: number;
updatedAt: string; estimatedEntries: number;
} duplicatedLiveLogBytes: number;
note: string;
export interface SupportFileSizeInfo { }
path: string | null;
exists: boolean; export interface DebugSetupCheckResult {
bytes: number; status: "ok" | "warn";
} enabled: boolean;
runtimeBaseDir: string;
export interface SupportDirectorySizeInfo { host: string;
path: string; port: number;
exists: boolean; localOnly: boolean;
fileCount: number; tokenConfigured: boolean;
bytes: number; tokenPath: string;
} aiManifestPath: string;
aiManifestPresent: boolean;
export interface SupportDiskSpaceInfo { traceConfigPath: string | null;
path: string; traceLogPath: string | null;
totalBytes: number | null; traceEnabled: boolean;
freeBytes: number | null; traceAutoDisableAt: string | null;
freePercent: number | null; diskSpace: {
} runtime: SupportDiskSpaceInfo;
output: SupportDiskSpaceInfo;
export interface SupportBundleEstimate { extract: SupportDiskSpaceInfo;
estimatedBytes: number; };
estimatedEntries: number; logSummary: {
duplicatedLiveLogBytes: number; totalBytes: number;
note: string; main: SupportFileSizeInfo;
} mainBackup: SupportFileSizeInfo;
audit: SupportFileSizeInfo;
export interface DebugSetupCheckResult { auditBackup: SupportFileSizeInfo;
status: "ok" | "warn"; rename: SupportFileSizeInfo;
enabled: boolean; renameBackup: SupportFileSizeInfo;
runtimeBaseDir: string; session: SupportFileSizeInfo;
host: string; trace: SupportFileSizeInfo;
port: number; traceBackup: SupportFileSizeInfo;
localOnly: boolean; sessionLogs: SupportDirectorySizeInfo;
tokenConfigured: boolean; packageLogs: SupportDirectorySizeInfo;
tokenPath: string; itemLogs: SupportDirectorySizeInfo;
aiManifestPath: string; };
aiManifestPresent: boolean; supportBundle: SupportBundleEstimate;
traceConfigPath: string | null; warnings: string[];
traceLogPath: string | null; notes: string[];
traceEnabled: boolean; localUrls: {
traceAutoDisableAt: string | null; health: string;
diskSpace: { meta: string;
runtime: SupportDiskSpaceInfo; diagnostics: string;
output: SupportDiskSpaceInfo; };
extract: SupportDiskSpaceInfo; remoteUrlTemplates: {
}; health: string;
logSummary: { meta: string;
totalBytes: number; diagnostics: string;
main: SupportFileSizeInfo; };
mainBackup: SupportFileSizeInfo; }
audit: SupportFileSizeInfo;
auditBackup: SupportFileSizeInfo; export interface HistoryEntry {
rename: SupportFileSizeInfo; id: string;
renameBackup: SupportFileSizeInfo; name: string;
session: SupportFileSizeInfo; totalBytes: number;
trace: SupportFileSizeInfo; downloadedBytes: number;
traceBackup: SupportFileSizeInfo; fileCount: number;
sessionLogs: SupportDirectorySizeInfo; provider: DebridProvider | null;
packageLogs: SupportDirectorySizeInfo; completedAt: number;
itemLogs: SupportDirectorySizeInfo; durationSeconds: number;
}; status: "completed" | "deleted";
supportBundle: SupportBundleEstimate; outputDir: string;
warnings: string[]; urls?: string[];
notes: string[]; }
localUrls: {
health: string; export interface HistoryState {
meta: string; entries: HistoryEntry[];
diagnostics: string; maxEntries: number;
}; }
remoteUrlTemplates: {
health: string;
meta: string;
diagnostics: string;
};
}
export interface HistoryEntry {
id: string;
name: string;
totalBytes: number;
downloadedBytes: number;
fileCount: number;
provider: DebridProvider | null;
completedAt: number;
durationSeconds: number;
status: "completed" | "deleted";
outputDir: string;
urls?: string[];
}
export interface HistoryState {
entries: HistoryEntry[];
maxEntries: number;
}
export interface RendererErrorReport {
kind: "error" | "unhandledrejection" | "react";
message: string;
stack?: string;
source?: string;
line?: number;
column?: number;
componentStack?: string;
}

View File

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

View File

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

View File

@ -1,100 +0,0 @@
# Real-Debrid-Downloader — Tasks (Stand 2026-06-07)
**Status:** Alle zugesagten Features erledigt+released (Archiv unten). EIN Bug analysiert
+ geparkt (Mega-Web Account-3-Rotation, siehe direkt unten — wartet auf 1 Log-Zahl vom User).
Rest ist freiwilliger Backlog.
---
## 🟢 OFFEN — Backlog (optional, nie begonnen)
### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187)
**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief)
setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel,
RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account
und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure).
Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt
rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2
rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1
brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte
per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft.
**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)**
**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen
Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert.
**Verifizierter Mechanismus (Code):**
- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s →
weiter zu Account 2. Account 2 → `aborted:debrid`.
- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein →
`throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.**
- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim
download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein
Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2.
- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort,
timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis
180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt
das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance.
**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich"
(11:51:4511:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 —
**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der
wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal →
Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig
bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt
nur sporadisch (no-server-Poll) bis ins 60s-Timeout.
**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap.
debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann
beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink`
({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider
unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal,
AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop;
eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3).
**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST).
**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download).
Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation
nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten.
### Features / UX (nach ROI)
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.
1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — SM. Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
2. [ ] **Fernsteuerung über Debug-Server** (POST-Endpunkte) — SM. Server hat HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop`.
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird nie geprüft → versehentliche Re-Downloads. Warnen: "3 Links bereits geladen".
4. [ ] **Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen".
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung für Downloads → Abbruch mitten drin bei voller Platte.
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen". (Daten dafür liegen jetzt teils in der Error-Ring aus v1.7.185.)
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nicht dargestellt. Welches Abo lohnt sich?
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — SM. Quota/Cooldown-Fails am nächsten Tag automatisch neu.
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1.
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M.
### Design-Richtung (Entscheidung steht aus)
4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon).
→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich).
### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren)
- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host
- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär
- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas
- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate())
- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`)
- [ ] Hybrid race: 1 Datei/Staffel evtl. beim MKV-Move nicht umbenannt (NUR per-package fixen — Post-MKV-Move-Scan ist tabu, v1.7.107 revertiert)
---
## ✅ ERLEDIGT — Archiv (Details in git-History + Memory)
- **Erweitertes Logging** → released **v1.7.185** (Crash-Handler, Renderer-Fehler-IPC, RD_DEBUG-Level, Error-Ring + `/errors`, ENOSPC-Klassifizierung, Memory-Heartbeat). → Memory: extended-logging
- **Link-Prefetch** → untersucht (6-Agent) + **bewusst verworfen** (marginal bei maxParallel 8, Mega-Web single-flight). → Memory: link-prefetch-declined
- **Backup nur Settings** → v1.7.184 (`backupIncludeDownloads`-Toggle + 4 Selektions/Flicker-Fixes). → Memory: backup-settings-only
- **Account-Rotation-Overhaul** → v1.7.164168 (Validity/Premium-Badges, Live-Panel, "Alle prüfen"). → Memory: account-rotation
- **Mega-Debrid-Account deaktivieren (UI)** → erledigt (Toggle im Edit-Dialog, im Code verifiziert 2026-06-07)
- **Bugs/Robustheit (Deferred-Pipeline H1/H2/H3/M1/M2/N1)** → v1.7.158/159; M3 bewusst übersprungen (Generation-Guard schützt Integrität bereits)
- **Deferred-Pfad Rename-Gap** → gefixt v1.7.162+ (finaler Deferred-Pass benennt frische Dateien vor Collect um; Repro-Test grün)
- **Repo-Privacy-Audit** → GitHub gelöscht+neu (saubere History), Gitea unberührt. → Memory: repo-privacy-audit
### Bewusst NICHT angefasst (Crash-Debris / alte Experimente)
- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar
- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai)

View File

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

View File

@ -1,57 +0,0 @@
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);
});
});

View File

@ -6,223 +6,9 @@ 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");
@ -467,6 +253,7 @@ 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");
@ -484,10 +271,12 @@ 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",
@ -552,13 +341,18 @@ 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");
}); });
@ -576,19 +370,23 @@ 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"); expect(result!).not.toContain(".S01E01.S01E01"); // no duplication
}); });
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(":");
}); });
@ -852,6 +650,7 @@ 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"],
@ -879,6 +678,7 @@ 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");
}); });
@ -964,6 +764,11 @@ 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",
@ -972,118 +777,10 @@ 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");
});
});

View File

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

View File

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

View File

@ -73,6 +73,7 @@ describe("bestdebrid-web", () => {
try { try {
fs.rmSync(filePath, { force: true }); fs.rmSync(filePath, { force: true });
} catch { } catch {
// ignore temp cleanup failures
} }
} }
}); });

View File

@ -1,100 +1,109 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup"; import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
const tempDirs: string[] = []; const tempDirs: string[] = [];
afterEach(() => { afterEach(() => {
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 });
} }
}); });
describe("cleanup", () => { describe("cleanup", () => {
it("removes archive artifacts but keeps media", () => { it("removes archive artifacts but keeps media", () => {
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);
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x"); fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
fs.writeFileSync(path.join(dir, "movie.mkv"), "x"); fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
const removed = cleanupCancelledPackageArtifacts(dir); const removed = cleanupCancelledPackageArtifacts(dir);
expect(removed).toBeGreaterThan(0); expect(removed).toBeGreaterThan(0);
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false); expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true); expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
}); });
it("removes sample artifacts and link files", async () => { it("removes sample artifacts and link files", async () => {
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);
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true }); fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x"); fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n"); fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
const links = await removeDownloadLinkArtifacts(dir); const links = await removeDownloadLinkArtifacts(dir);
const samples = await removeSampleArtifacts(dir); const samples = await removeSampleArtifacts(dir);
expect(links).toBeGreaterThan(0); expect(links).toBeGreaterThan(0);
expect(samples.files + samples.dirs).toBeGreaterThan(0); expect(samples.files + samples.dirs).toBeGreaterThan(0);
}); });
it("cleans up archive files in nested directories", () => { it("cleans up archive files in nested directories", () => {
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);
const sub1 = path.join(dir, "season1"); // Create nested directory structure with archive files
const sub2 = path.join(dir, "season1", "extras"); const sub1 = path.join(dir, "season1");
fs.mkdirSync(sub2, { recursive: true }); const sub2 = path.join(dir, "season1", "extras");
fs.mkdirSync(sub2, { recursive: true });
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x"); fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x"); fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x"); fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content"); fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content"); // Non-archive files should be kept
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
const removed = cleanupCancelledPackageArtifacts(dir); fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
expect(removed).toBe(4);
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false); const removed = cleanupCancelledPackageArtifacts(dir);
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false); expect(removed).toBe(4); // 2 rar parts + zip + 7z
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false); expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false); expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true); expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true); expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
}); // Non-archives kept
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
it("detects link artifacts by URL content in text files", async () => { expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); });
tempDirs.push(dir);
it("detects link artifacts by URL content in text files", async () => {
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n"); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs"); tempDirs.push(dir);
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com"); // File with link-like name containing URLs should be removed
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data"); 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
const removed = await removeDownloadLinkArtifacts(dir); fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
expect(removed).toBeGreaterThanOrEqual(3); // Regular text file that doesn't match the link pattern should be kept
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false); fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false); // .url files should always be removed
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false); fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true); // .dlc files should always be removed
}); fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
it("does not recurse into sample symlink or junction targets", async () => { const removed = await removeDownloadLinkArtifacts(dir);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); expect(removed).toBeGreaterThanOrEqual(3); // download_links.txt + bookmark.url + container.dlc
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-")); expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
tempDirs.push(dir, external); expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
const outsideFile = path.join(external, "outside-sample.mkv"); // Non-matching files should be kept
fs.writeFileSync(outsideFile, "keep", "utf8"); expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
});
const linkedSampleDir = path.join(dir, "sample");
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir"; it("does not recurse into sample symlink or junction targets", async () => {
fs.symlinkSync(external, linkedSampleDir, linkType); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
const result = await removeSampleArtifacts(dir); tempDirs.push(dir, external);
expect(result.files).toBe(0);
expect(fs.existsSync(outsideFile)).toBe(true); const outsideFile = path.join(external, "outside-sample.mkv");
}); fs.writeFileSync(outsideFile, "keep", "utf8");
});
const linkedSampleDir = path.join(dir, "sample");
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
fs.symlinkSync(external, linkedSampleDir, linkType);
const result = await removeSampleArtifacts(dir);
expect(result.files).toBe(0);
expect(fs.existsSync(outsideFile)).toBe(true);
});
});

View File

@ -22,7 +22,9 @@ 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) => {
@ -36,6 +38,7 @@ 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"]);
@ -57,14 +60,17 @@ 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 });
@ -75,6 +81,7 @@ 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);
}); });
@ -128,6 +135,7 @@ describe("container", () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].name).toBe("big-dlc"); expect(result[0].name).toBe("big-dlc");
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]); expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
// local RC + upload + paste = 3 calls
expect(fetchSpy).toHaveBeenCalledTimes(3); expect(fetchSpy).toHaveBeenCalledTimes(3);
}); });

File diff suppressed because it is too large Load Diff

View File

@ -78,6 +78,7 @@ 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));
} }
@ -313,6 +314,7 @@ afterEach(() => {
try { try {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
} catch { } catch {
// ignore cleanup failures
} }
} }
}); });

View File

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

View File

@ -1,61 +0,0 @@
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

View File

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

View File

@ -74,6 +74,7 @@ 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)));
@ -107,16 +108,20 @@ 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);
}); });
@ -130,6 +135,7 @@ 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"));
@ -156,8 +162,10 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(result.extracted).toBe(2); expect(result.extracted).toBe(2);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
// Both archive names should have appeared in progress
expect(archiveNames.has("archive1.zip")).toBe(true); expect(archiveNames.has("archive1.zip")).toBe(true);
expect(archiveNames.has("archive2.zip")).toBe(true); expect(archiveNames.has("archive2.zip")).toBe(true);
// Both files extracted
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
}); });

View File

@ -865,6 +865,7 @@ 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");
@ -941,6 +942,7 @@ 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");
}); });
@ -955,6 +957,7 @@ 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);
}); });
@ -1097,6 +1100,7 @@ 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));
@ -1123,6 +1127,7 @@ 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");
}); });
@ -1139,6 +1144,7 @@ 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,

View File

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

View File

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

View File

@ -34,20 +34,25 @@ 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();
}); });

View File

@ -8,16 +8,16 @@ 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"] } { name: "", links: ["http://link5"] } // empty name will be inferred
]; ];
const result = mergePackageInputs(input); const result = mergePackageInputs(input);
expect(result).toHaveLength(3); expect(result).toHaveLength(3); // Package A, Package B, and inferred 'Paket'
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"]); expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]); // link1 deduplicated
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"]);
}); });
@ -29,7 +29,8 @@ 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"]);
}); });
@ -58,23 +59,24 @@ describe("link-parser", () => {
Here are some links: Here are some links:
http://example.com/part1.rar http://example.com/part1.rar
http://example.com/part2.rar http://example.com/part2.rar
# package: Custom_Name # package: Custom_Name
http://other.com/file1 http://other.com/file1
http://other.com/file2 http://other.com/file2
`; `;
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");
expect(defaultPkg?.links).toEqual([ expect(defaultPkg?.links).toEqual([
"http://example.com/part1.rar", "http://example.com/part1.rar",
"http://example.com/part2.rar" "http://example.com/part2.rar"
]); ]);
const customPkg = result.find(p => p.name === "Custom_Name"); const customPkg = result.find(p => p.name === "Custom_Name"); // sanitized!
expect(customPkg?.links).toEqual([ expect(customPkg?.links).toEqual([
"http://other.com/file1", "http://other.com/file1",
"http://other.com/file2" "http://other.com/file2"

View File

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

View File

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

View File

@ -21,18 +21,19 @@ describe("mega-web-fallback", () => {
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);
fetchCallCount += 1; fetchCallCount += 1;
if (urlStr.includes("form=login")) { if (urlStr.includes("form=login")) {
const headers = new Headers(); const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/"); headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 }); return new Response("", { headers, status: 200 });
} }
if (urlStr.includes("page=debrideur")) { if (urlStr.includes("page=debrideur")) {
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 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>
@ -40,119 +41,37 @@ describe("mega-web-fallback", () => {
</div> </div>
`, { status: 200 }); `, { status: 200 });
} }
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 });
} }
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
const result = await fallback.unrestrict("https://mega.debrid/link1"); const result = await fallback.unrestrict("https://mega.debrid/link1");
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(); const headers = new Headers(); // No cookie
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 });
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" })); const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" }));
await expect(fallback.unrestrict("http://mega.debrid/file")) await expect(fallback.unrestrict("http://mega.debrid/file"))
.rejects.toThrow("Mega-Web Login liefert kein Session-Cookie"); .rejects.toThrow("Mega-Web Login liefert kein Session-Cookie");
}); });
@ -166,17 +85,18 @@ 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 });
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" })); const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
await expect(fallback.unrestrict("http://mega.debrid/file")) await expect(fallback.unrestrict("http://mega.debrid/file"))
.rejects.toThrow("Mega-Web Login ungültig oder Session blockiert"); .rejects.toThrow("Mega-Web Login ungültig oder Session blockiert");
}); });
it("returns null if generation fails to find a code", async () => { it("returns null if generation fails to find a code", async () => {
let callCount = 0; let callCount = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => { globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
@ -191,6 +111,7 @@ 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 });
@ -198,7 +119,8 @@ 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();
}); });

View File

@ -44,50 +44,23 @@ function createItem(id: string, packageId: string, status: DownloadItem["status"
} }
describe("sortPackagesForDisplay", () => { describe("sortPackagesForDisplay", () => {
it("floats active packages to the top, keeping queue order within each group", () => { it("moves active packages with more progress to the top when auto sort is enabled", () => {
// 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-c", ["c1"]), createPackage("pkg-b", ["b1", "b2"]),
createPackage("pkg-b", ["b1", "b2"]) createPackage("pkg-c", ["c1"])
]; ];
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);
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c] expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-a", "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", () => {

View File

@ -17,6 +17,7 @@ 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([
@ -45,6 +46,8 @@ 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",
@ -57,6 +60,8 @@ 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);
@ -64,6 +69,8 @@ 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",
@ -75,6 +82,8 @@ 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",
@ -84,6 +93,8 @@ 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",
@ -94,6 +105,8 @@ 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);
@ -101,6 +114,8 @@ 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",
@ -110,26 +125,40 @@ 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" },
@ -139,6 +168,8 @@ 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",

View File

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

View File

@ -8,7 +8,9 @@ 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 });
@ -40,9 +42,11 @@ 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");
@ -73,6 +77,7 @@ 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");
@ -89,16 +94,21 @@ 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);
@ -114,6 +124,7 @@ 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);
@ -136,6 +147,7 @@ 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);

View File

@ -1,132 +0,0 @@
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"]);
});
});

View File

@ -13,6 +13,7 @@ afterEach(() => {
try { try {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
} catch { } catch {
// ignore cleanup errors
} }
} }
}); });
@ -87,6 +88,7 @@ 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 = {
@ -101,6 +103,7 @@ 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(),

File diff suppressed because it is too large Load Diff

View File

@ -1,172 +0,0 @@
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();
}
});
});

File diff suppressed because it is too large Load Diff

View File

@ -92,6 +92,7 @@ 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);
}); });
@ -99,6 +100,7 @@ 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");
}); });
@ -111,22 +113,28 @@ 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);
}); });
}); });

View File

@ -1,283 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
stripDualLangMarker,
hasDualLangMarker,
isRemuxableVideoFile,
looksLikeGermanRelease,
pickAudioTrack,
parseFfprobeAudioStreams,
buildFfprobeArgs,
buildFfmpegRemuxArgs,
computeRemuxTimeoutMs,
processVideoFile,
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 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);
});
});
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" };
};
}
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(fs.existsSync(`${file}.gertmp.mkv`)).toBe(false); // 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(fs.existsSync(`${file}.gertmp.mkv`)).toBe(false);
});
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");
});
});