Migrate app to Node Electron with modern React UI
This commit is contained in:
parent
56e4355d6b
commit
df953517d8
43
.github/workflows/release.yml
vendored
43
.github/workflows/release.yml
vendored
@ -16,50 +16,37 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Node
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: npm ci
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install pyinstaller pillow
|
|
||||||
|
|
||||||
- name: Prepare release metadata
|
- name: Apply tag version
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$version = "${{ github.ref_name }}".TrimStart('v')
|
$version = "${{ github.ref_name }}".TrimStart('v')
|
||||||
python scripts/set_version.py $version
|
node scripts/set_version_node.mjs $version
|
||||||
python scripts/prepare_icon.py
|
|
||||||
|
|
||||||
- name: Build exe
|
- name: Build app
|
||||||
run: |
|
run: npm run build
|
||||||
pyinstaller --noconfirm --windowed --onedir --name "Real-Debrid-Downloader" --icon "assets/app_icon.ico" real_debrid_downloader_gui.py
|
|
||||||
|
|
||||||
- name: Pack release zip
|
- name: Build Windows artifacts
|
||||||
|
run: npm run release:win
|
||||||
|
|
||||||
|
- name: Pack portable zip
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
New-Item -ItemType Directory -Path release -Force | Out-Null
|
Compress-Archive -Path "release\win-unpacked\*" -DestinationPath "Real-Debrid-Downloader-win64.zip" -Force
|
||||||
Compress-Archive -Path "dist/Real-Debrid-Downloader/*" -DestinationPath "Real-Debrid-Downloader-win64.zip" -Force
|
|
||||||
|
|
||||||
- name: Install Inno Setup
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
choco install innosetup --no-progress -y
|
|
||||||
|
|
||||||
- name: Build installer
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$version = "${{ github.ref_name }}".TrimStart('v')
|
|
||||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "/DMyAppVersion=$version" "/DMySourceDir=..\\dist\\Real-Debrid-Downloader" "/DMyOutputDir=..\\release" "/DMyIconFile=..\\assets\\app_icon.ico" "installer\\RealDebridDownloader.iss"
|
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
Real-Debrid-Downloader-win64.zip
|
Real-Debrid-Downloader-win64.zip
|
||||||
release/Real-Debrid-Downloader-Setup-*.exe
|
release/*.exe
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -16,3 +16,10 @@ rd_downloader.log
|
|||||||
rd_download_manifest.json
|
rd_download_manifest.json
|
||||||
_update_staging/
|
_update_staging/
|
||||||
apply_update.cmd
|
apply_update.cmd
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|||||||
149
README.md
149
README.md
@ -1,116 +1,83 @@
|
|||||||
# Real-Debrid Downloader GUI
|
# Real-Debrid Download Manager (Node/Electron)
|
||||||
|
|
||||||
Kleine Desktop-App mit GUI (Tkinter), um mehrere Links (z. B. 20+) einzufuegen,
|
Desktop-App auf **Node.js + Electron + React + TypeScript** mit JDownloader-Style Workflow, optimiert fuer Real-Debrid.
|
||||||
ueber Real-Debrid zu unrestricten und direkt auf deinen PC zu laden.
|
|
||||||
|
|
||||||
## Features
|
## Highlights
|
||||||
|
|
||||||
- Mehrere Links auf einmal (ein Link pro Zeile)
|
- Modernes, dunkles UI mit Header-Steuerung (Start, Pause, Stop, Speed, ETA)
|
||||||
- DLC Import (`.dlc`) ueber dcrypt.it inklusive Paket-Gruppierung
|
- Tabs: **Linksammler**, **Downloads**, **Settings**
|
||||||
- DLC Drag-and-Drop: `.dlc` direkt in den Links-Bereich ziehen
|
- Paketbasierte Queue mit Datei-Status, Progress, Speed, Retries
|
||||||
- Nutzt die Real-Debrid API (`/unrestrict/link`)
|
- Paket-Abbruch waehrend laufender Downloads inklusive sicherem Archiv-Cleanup
|
||||||
- Download-Status pro Link
|
- `.dlc` Import (Dateidialog und Drag-and-Drop)
|
||||||
- Paket-Ansicht: Paket ist aufklappbar, darunter alle Einzel-Links
|
- Session-Persistenz (robustes JSON-State-Management)
|
||||||
- Laufende Pakete koennen per Rechtsklick direkt abgebrochen/entfernt werden
|
- Auto-Resume beim Start (optional)
|
||||||
- Download-Speed pro Link und gesamt
|
- Reconnect-Basislogik (429/503, Wartefenster, resumable priorisiert)
|
||||||
- Gesamt-Fortschritt
|
- Integritaetscheck (SFV/CRC32/MD5/SHA1) nach Download
|
||||||
- Download-Ordner und Paketname waehlbar
|
- Auto-Retry bei Integritaetsfehlern
|
||||||
- Einstellbare Parallel-Downloads (z. B. 20 gleichzeitig)
|
- Cleanup-Trigger fuer fertige Tasks:
|
||||||
- Parallel-Wert kann waehrend laufender Downloads live angepasst werden
|
- Nie
|
||||||
- Retry-Counter pro Link in der Tabelle
|
- Sofort
|
||||||
- Automatisches Entpacken nach dem Download
|
- Beim App-Start
|
||||||
- Hybrid-Entpacken: entpackt sofort, sobald ein Archivsatz komplett ist
|
- Sobald Paket fertig ist
|
||||||
- Optionales Auto-Cleanup: Archivteile nach erfolgreichem Entpacken loeschen
|
|
||||||
- Speed-Limit (global oder pro Download), live aenderbar
|
|
||||||
- Linklisten als `.txt` speichern/laden
|
|
||||||
- DLC-Dateien als Paketliste importieren (`DLC import`)
|
|
||||||
- `Entpacken nach` + optional `Unterordner erstellen (Paketname)` wie bei JDownloader
|
|
||||||
- `Settings` (JDownloader-Style):
|
|
||||||
- Nach erfolgreichem Entpacken: keine / Papierkorb / unwiderruflich loeschen
|
|
||||||
- Bei Konflikten: ueberschreiben / ueberspringen / umbenennen
|
|
||||||
- ZIP-Passwort-Check mit `serienfans.org` und `serienjunkies.net`
|
|
||||||
- Multi-Part-RAR wird ueber `part1` entpackt (nur wenn alle Parts vorhanden sind)
|
|
||||||
- Auto-Update Check ueber GitHub Releases (fuer .exe)
|
|
||||||
- Optionales lokales Speichern vom API Token
|
|
||||||
|
|
||||||
## Voraussetzung
|
## Voraussetzungen
|
||||||
|
|
||||||
- Python 3.10+
|
- Node.js 20+ (empfohlen 22+)
|
||||||
- Optional, aber empfohlen: 7-Zip im PATH fuer RAR/7Z-Entpackung
|
- Windows 10/11 (fuer Release-Build)
|
||||||
- Alternative fuer RAR: WinRAR `UnRAR.exe` (wird automatisch erkannt)
|
- Optional: 7-Zip/UnRAR fuer RAR/7Z Entpacken
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m venv .venv
|
npm install
|
||||||
.venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Start
|
## Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python real_debrid_downloader_gui.py
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Nutzung
|
## Build
|
||||||
|
|
||||||
1. API Token von Real-Debrid eintragen (`https://real-debrid.com/apitoken`)
|
|
||||||
2. Download-Ordner waehlen
|
|
||||||
3. Optional Paketname setzen (sonst wird automatisch einer erzeugt)
|
|
||||||
4. Optional Entpack-Ordner waehlen (`Entpacken nach`)
|
|
||||||
5. Optional `Unterordner erstellen (Paketname)` aktiv lassen
|
|
||||||
6. Optional `Hybrid-Entpacken` und `Cleanup` setzen
|
|
||||||
7. Parallel-Wert setzen (z. B. 20)
|
|
||||||
8. Optional Speed-Limit setzen (KB/s, Modus `global` oder `per_download`)
|
|
||||||
9. Links einfuegen oder per `Links laden` / `DLC import` importieren
|
|
||||||
10. `Download starten` klicken
|
|
||||||
|
|
||||||
Wenn du 20 Links einfuegst, werden sie als ein Paket behandelt. Downloads landen in einem Paketordner. Beim Entpacken kann derselbe Paketname automatisch als Unterordner genutzt werden.
|
|
||||||
|
|
||||||
Bei DLC-Import mit vielen Paketen setzt die App automatisch Paketmarker (`# package: ...`) und verarbeitet die Pakete in einer Queue.
|
|
||||||
|
|
||||||
## Auto-Update (GitHub)
|
|
||||||
|
|
||||||
1. Standard-Repo ist bereits gesetzt: `Sucukdeluxe/real-debrid-downloader`
|
|
||||||
2. Optional kannst du es in der App mit `GitHub Repo (owner/name)` ueberschreiben
|
|
||||||
3. Klicke `Update suchen` oder aktiviere `Beim Start auf Updates pruefen`
|
|
||||||
4. In der .exe wird ein neues Release heruntergeladen und beim Neustart installiert
|
|
||||||
|
|
||||||
Hinweis: Beim Python-Skript gibt es nur einen Release-Hinweis, kein Self-Replace.
|
|
||||||
|
|
||||||
## Release Build (.exe)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./build_exe.ps1 -Version 1.1.0
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach liegt die App unter `dist/Real-Debrid-Downloader/`.
|
Danach liegen die Artefakte in:
|
||||||
|
|
||||||
## GitHub Release Workflow
|
- `build/main`
|
||||||
|
- `build/renderer`
|
||||||
|
|
||||||
- Workflow-Datei: `.github/workflows/release.yml`
|
## Start (Production lokal)
|
||||||
- Bei Tag-Push wie `v1.0.1` wird automatisch eine Windows-EXE gebaut
|
|
||||||
- Release-Asset fuer Auto-Update: `Real-Debrid-Downloader-win64.zip`
|
|
||||||
- Zusaetzlich wird ein Installer gebaut: `Real-Debrid-Downloader-Setup-<version>.exe`
|
|
||||||
- Installer legt automatisch eine Desktop-Verknuepfung an
|
|
||||||
|
|
||||||
## Auto-Installer
|
|
||||||
|
|
||||||
- Im GitHub Release findest du direkt die Setup-Datei (`...Setup-<version>.exe`)
|
|
||||||
- Setup installiert die App unter `Programme/Real-Debrid Downloader`
|
|
||||||
- Setup erstellt automatisch eine Desktop-Verknuepfung mit App-Icon
|
|
||||||
|
|
||||||
## App-Icon
|
|
||||||
|
|
||||||
- Das Projekt nutzt `assets/app_icon.png` (aus deinem aktuellen Downloads-Icon)
|
|
||||||
- Beim Build wird automatisch `assets/app_icon.ico` erzeugt
|
|
||||||
|
|
||||||
Beispiel:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag v1.0.1
|
npm run start
|
||||||
git push origin v1.0.1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Hinweis: Die App kann nur Links laden, die von Real-Debrid unterstuetzt werden.
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run self-check
|
||||||
|
```
|
||||||
|
|
||||||
|
- `npm test`: Unit-Tests fuer Parser/Cleanup/Integrity
|
||||||
|
- `npm run self-check`: End-to-End-Checks mit lokalem Mock-Server (Queue, Pause/Resume, Reconnect, Paket-Cancel)
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
- `src/main`: Electron Main Process + Download/Queue Logik
|
||||||
|
- `src/preload`: sichere IPC Bridge
|
||||||
|
- `src/renderer`: React UI
|
||||||
|
- `src/shared`: gemeinsame Typen und IPC-Channel
|
||||||
|
- `tests`: Unit- und Self-Check Tests
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Runtime-Dateien liegen im Electron `userData` Verzeichnis:
|
||||||
|
- `rd_downloader_config.json`
|
||||||
|
- `rd_session_state.json`
|
||||||
|
- `rd_downloader.log`
|
||||||
|
|
||||||
|
- Die bisherige Python-Datei bleibt vorerst als Legacy-Referenz im Repo, die aktive App ist jetzt Node/Electron.
|
||||||
|
|||||||
9654
package-lock.json
generated
Normal file
9654
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
package.json
Normal file
73
package.json
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"name": "real-debrid-downloader",
|
||||||
|
"version": "1.1.9",
|
||||||
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
|
"main": "build/main/main/main.js",
|
||||||
|
"author": "Sucukdeluxe",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -k \"npm:dev:main:watch\" \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||||
|
"dev:renderer": "vite",
|
||||||
|
"dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch",
|
||||||
|
"dev:electron": "wait-on tcp:5173 file:build/main/main/main.js && cross-env NODE_ENV=development electron .",
|
||||||
|
"build": "npm run build:main && npm run build:renderer",
|
||||||
|
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
||||||
|
"build:renderer": "vite build",
|
||||||
|
"start": "cross-env NODE_ENV=production electron .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"self-check": "tsx tests/self-check.ts",
|
||||||
|
"release:win": "npm run build && electron-builder --win nsis portable"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.13",
|
||||||
|
"@types/adm-zip": "^0.5.7",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^31.7.7",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"tsup": "^8.3.6",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vitest": "^2.1.8",
|
||||||
|
"wait-on": "^8.0.1"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.sucukdeluxe.realdebrid",
|
||||||
|
"productName": "Real-Debrid-Downloader",
|
||||||
|
"artifactName": "${productName}-${version}-${arch}-${target}.${ext}",
|
||||||
|
"directories": {
|
||||||
|
"buildResources": "assets",
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"build/main/**/*",
|
||||||
|
"build/renderer/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis",
|
||||||
|
"portable"
|
||||||
|
],
|
||||||
|
"icon": "assets/app_icon.ico",
|
||||||
|
"signAndEditExecutable": false
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"perMachine": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
scripts/set_version_node.mjs
Normal file
24
scripts/set_version_node.mjs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const version = process.argv[2];
|
||||||
|
if (!version) {
|
||||||
|
console.error("Usage: node scripts/set_version_node.mjs <version>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
|
||||||
|
const packageJsonPath = path.join(root, "package.json");
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||||
|
packageJson.version = version;
|
||||||
|
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
|
||||||
|
|
||||||
|
const constantsPath = path.join(root, "src", "main", "constants.ts");
|
||||||
|
const constants = fs.readFileSync(constantsPath, "utf8").replace(
|
||||||
|
/APP_VERSION = "[^"]+"/,
|
||||||
|
`APP_VERSION = "${version}"`
|
||||||
|
);
|
||||||
|
fs.writeFileSync(constantsPath, constants, "utf8");
|
||||||
|
|
||||||
|
console.log(`Set version to ${version}`);
|
||||||
106
src/main/app-controller.ts
Normal file
106
src/main/app-controller.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { app } from "electron";
|
||||||
|
import { AddLinksPayload, AppSettings, ParsedPackageInput, UiSnapshot } from "../shared/types";
|
||||||
|
import { importDlcContainers } from "./container";
|
||||||
|
import { APP_VERSION, defaultSettings } from "./constants";
|
||||||
|
import { DownloadManager } from "./download-manager";
|
||||||
|
import { parseCollectorInput } from "./link-parser";
|
||||||
|
import { configureLogger, logger } from "./logger";
|
||||||
|
import { createStoragePaths, emptySession, loadSession, loadSettings, saveSettings } from "./storage";
|
||||||
|
|
||||||
|
export class AppController {
|
||||||
|
private settings: AppSettings;
|
||||||
|
|
||||||
|
private manager: DownloadManager;
|
||||||
|
|
||||||
|
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
configureLogger(this.storagePaths.baseDir);
|
||||||
|
this.settings = loadSettings(this.storagePaths);
|
||||||
|
const session = loadSession(this.storagePaths);
|
||||||
|
this.manager = new DownloadManager(this.settings, session, this.storagePaths);
|
||||||
|
this.manager.on("state", (snapshot: UiSnapshot) => {
|
||||||
|
this.onState?.(snapshot);
|
||||||
|
});
|
||||||
|
logger.info(`App gestartet v${APP_VERSION}`);
|
||||||
|
|
||||||
|
if (this.settings.autoResumeOnStart) {
|
||||||
|
const snapshot = this.manager.getSnapshot();
|
||||||
|
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||||
|
if (hasPending && this.settings.token.trim()) {
|
||||||
|
this.manager.start();
|
||||||
|
logger.info("Auto-Resume beim Start aktiviert");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onState: ((snapshot: UiSnapshot) => void) | null = null;
|
||||||
|
|
||||||
|
public getSnapshot(): UiSnapshot {
|
||||||
|
return this.manager.getSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getVersion(): string {
|
||||||
|
return APP_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSettings(): AppSettings {
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||||
|
this.settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
...this.settings,
|
||||||
|
...partial
|
||||||
|
};
|
||||||
|
saveSettings(this.storagePaths, this.settings);
|
||||||
|
this.manager.setSettings(this.settings);
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } {
|
||||||
|
const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName);
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return { addedPackages: 0, addedLinks: 0, invalidCount: 1 };
|
||||||
|
}
|
||||||
|
const result = this.manager.addPackages(parsed);
|
||||||
|
return { ...result, invalidCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> {
|
||||||
|
const packages = await importDlcContainers(filePaths);
|
||||||
|
const merged: ParsedPackageInput[] = packages.map((pkg) => ({
|
||||||
|
name: pkg.name,
|
||||||
|
links: pkg.links
|
||||||
|
}));
|
||||||
|
const result = this.manager.addPackages(merged);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearAll(): void {
|
||||||
|
this.manager.clearAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public start(): void {
|
||||||
|
this.manager.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
this.manager.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public togglePause(): boolean {
|
||||||
|
return this.manager.togglePause();
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancelPackage(packageId: string): void {
|
||||||
|
this.manager.cancelPackage(packageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public shutdown(): void {
|
||||||
|
this.manager.stop();
|
||||||
|
logger.info("App beendet");
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/main/cleanup.ts
Normal file
145
src/main/cleanup.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { ARCHIVE_TEMP_EXTENSIONS, LINK_ARTIFACT_EXTENSIONS, RAR_SPLIT_RE, SAMPLE_DIR_NAMES, SAMPLE_TOKEN_RE, SAMPLE_VIDEO_EXTENSIONS } from "./constants";
|
||||||
|
|
||||||
|
export function isArchiveOrTempFile(filePath: string): boolean {
|
||||||
|
const lower = filePath.toLowerCase();
|
||||||
|
const ext = path.extname(lower);
|
||||||
|
if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (lower.includes(".part") && lower.endsWith(".rar")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return RAR_SPLIT_RE.test(lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
||||||
|
if (!fs.existsSync(packageDir)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let removed = 0;
|
||||||
|
const stack = [packageDir];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop() as string;
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const full = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full);
|
||||||
|
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(full, { force: true });
|
||||||
|
removed += 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeDownloadLinkArtifacts(extractDir: string): number {
|
||||||
|
if (!fs.existsSync(extractDir)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let removed = 0;
|
||||||
|
const stack = [extractDir];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop() as string;
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const full = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
|
const name = entry.name.toLowerCase();
|
||||||
|
let shouldDelete = LINK_ARTIFACT_EXTENSIONS.has(ext);
|
||||||
|
if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) {
|
||||||
|
if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(full, "utf8");
|
||||||
|
shouldDelete = /https?:\/\//i.test(text);
|
||||||
|
} catch {
|
||||||
|
shouldDelete = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDelete) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(full, { force: true });
|
||||||
|
removed += 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSampleArtifacts(extractDir: string): { files: number; dirs: number } {
|
||||||
|
if (!fs.existsSync(extractDir)) {
|
||||||
|
return { files: 0, dirs: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let removedFiles = 0;
|
||||||
|
let removedDirs = 0;
|
||||||
|
const allDirs: string[] = [];
|
||||||
|
const stack = [extractDir];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop() as string;
|
||||||
|
allDirs.push(current);
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const full = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = path.basename(path.dirname(full)).toLowerCase();
|
||||||
|
const stem = path.parse(entry.name).name.toLowerCase();
|
||||||
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
|
const inSampleDir = SAMPLE_DIR_NAMES.has(parent);
|
||||||
|
const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem);
|
||||||
|
|
||||||
|
if (inSampleDir || isSampleVideo) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(full, { force: true });
|
||||||
|
removedFiles += 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allDirs.sort((a, b) => b.length - a.length);
|
||||||
|
for (const dir of allDirs) {
|
||||||
|
if (dir === extractDir) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const base = path.basename(dir).toLowerCase();
|
||||||
|
if (!SAMPLE_DIR_NAMES.has(base)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
removedDirs += 1;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { files: removedFiles, dirs: removedDirs };
|
||||||
|
}
|
||||||
54
src/main/constants.ts
Normal file
54
src/main/constants.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { AppSettings } from "../shared/types";
|
||||||
|
|
||||||
|
export const APP_NAME = "Real-Debrid Download Manager";
|
||||||
|
export const APP_VERSION = "1.1.9";
|
||||||
|
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 DLC_SERVICE_URL = "http://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_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
|
||||||
|
|
||||||
|
export const REQUEST_RETRIES = 3;
|
||||||
|
export const CHUNK_SIZE = 512 * 1024;
|
||||||
|
|
||||||
|
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 LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
|
||||||
|
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
||||||
|
|
||||||
|
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part"]);
|
||||||
|
export const RAR_SPLIT_RE = /\.r\d{2}$/i;
|
||||||
|
|
||||||
|
export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/real-debrid-downloader";
|
||||||
|
|
||||||
|
export function defaultSettings(): AppSettings {
|
||||||
|
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
||||||
|
return {
|
||||||
|
token: "",
|
||||||
|
rememberToken: true,
|
||||||
|
outputDir: baseDir,
|
||||||
|
packageName: "",
|
||||||
|
autoExtract: true,
|
||||||
|
extractDir: path.join(baseDir, "_entpackt"),
|
||||||
|
createExtractSubfolder: true,
|
||||||
|
hybridExtract: true,
|
||||||
|
cleanupMode: "none",
|
||||||
|
extractConflictMode: "overwrite",
|
||||||
|
removeLinkFilesAfterExtract: false,
|
||||||
|
removeSamplesAfterExtract: false,
|
||||||
|
enableIntegrityCheck: true,
|
||||||
|
autoResumeOnStart: true,
|
||||||
|
autoReconnect: false,
|
||||||
|
reconnectWaitSeconds: 45,
|
||||||
|
completedCleanupPolicy: "never",
|
||||||
|
maxParallel: 4,
|
||||||
|
speedLimitEnabled: false,
|
||||||
|
speedLimitKbps: 0,
|
||||||
|
speedLimitMode: "global",
|
||||||
|
updateRepo: DEFAULT_UPDATE_REPO,
|
||||||
|
autoUpdateCheck: true
|
||||||
|
};
|
||||||
|
}
|
||||||
187
src/main/container.ts
Normal file
187
src/main/container.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./constants";
|
||||||
|
import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||||
|
import { ParsedPackageInput } from "../shared/types";
|
||||||
|
|
||||||
|
function decodeDcryptPayload(responseText: string): unknown {
|
||||||
|
let text = String(responseText || "").trim();
|
||||||
|
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
|
||||||
|
if (m) {
|
||||||
|
text = m[1].replace(/"/g, '"').replace(/&/g, "&").trim();
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUrlsRecursive(data: unknown): string[] {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
const found = data.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
|
||||||
|
return uniquePreserveOrder(found.filter((url) => isHttpLink(url)));
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return uniquePreserveOrder(data.flatMap((item) => extractUrlsRecursive(item)));
|
||||||
|
}
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
return uniquePreserveOrder(Object.values(data as Record<string, unknown>).flatMap((value) => extractUrlsRecursive(value)));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLinksByName(links: string[]): ParsedPackageInput[] {
|
||||||
|
const unique = uniquePreserveOrder(links.filter((link) => isHttpLink(link)));
|
||||||
|
const grouped = new Map<string, string[]>();
|
||||||
|
for (const link of unique) {
|
||||||
|
const name = sanitizeFilename(inferPackageNameFromLinks([link]) || "Paket");
|
||||||
|
const current = grouped.get(name) ?? [];
|
||||||
|
current.push(link);
|
||||||
|
grouped.set(name, current);
|
||||||
|
}
|
||||||
|
return Array.from(grouped.entries()).map(([name, packageLinks]) => ({ name, links: packageLinks }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPackagesFromPayload(payload: unknown): ParsedPackageInput[] {
|
||||||
|
const urls = extractUrlsRecursive(payload);
|
||||||
|
if (urls.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return groupLinksByName(urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptRcPayload(base64Rc: string): Buffer {
|
||||||
|
const rcBytes = Buffer.from(base64Rc, "base64");
|
||||||
|
const decipher = crypto.createDecipheriv("aes-128-cbc", DLC_AES_KEY, DLC_AES_IV);
|
||||||
|
decipher.setAutoPadding(false);
|
||||||
|
return Buffer.concat([decipher.update(rcBytes), decipher.final()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
||||||
|
const packages: ParsedPackageInput[] = [];
|
||||||
|
const packageRegex = /<package\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi;
|
||||||
|
|
||||||
|
for (let m = packageRegex.exec(xml); m; m = packageRegex.exec(xml)) {
|
||||||
|
const encodedName = m[1] || "";
|
||||||
|
const packageBody = m[2] || "";
|
||||||
|
let packageName = "";
|
||||||
|
if (encodedName) {
|
||||||
|
try {
|
||||||
|
packageName = Buffer.from(encodedName, "base64").toString("utf8");
|
||||||
|
} catch {
|
||||||
|
packageName = encodedName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const links: string[] = [];
|
||||||
|
const urlRegex = /<url>(.*?)<\/url>/gi;
|
||||||
|
for (let um = urlRegex.exec(packageBody); um; um = urlRegex.exec(packageBody)) {
|
||||||
|
try {
|
||||||
|
const url = Buffer.from((um[1] || "").trim(), "base64").toString("utf8").trim();
|
||||||
|
if (isHttpLink(url)) {
|
||||||
|
links.push(url);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip broken entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueLinks = uniquePreserveOrder(links);
|
||||||
|
if (uniqueLinks.length > 0) {
|
||||||
|
packages.push({
|
||||||
|
name: sanitizeFilename(packageName || inferPackageNameFromLinks(uniqueLinks) || `Paket-${packages.length + 1}`),
|
||||||
|
links: uniqueLinks
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]> {
|
||||||
|
const content = fs.readFileSync(filePath, "ascii").trim();
|
||||||
|
if (content.length < 89) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dlcKey = content.slice(-88);
|
||||||
|
const dlcData = content.slice(0, -88);
|
||||||
|
|
||||||
|
const rcUrl = DLC_SERVICE_URL.replace("{KEY}", encodeURIComponent(dlcKey));
|
||||||
|
const rcResponse = await fetch(rcUrl, { method: "GET" });
|
||||||
|
if (!rcResponse.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rcText = await rcResponse.text();
|
||||||
|
const rcMatch = rcText.match(/<rc>(.*?)<\/rc>/i);
|
||||||
|
if (!rcMatch) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const realKey = decryptRcPayload(rcMatch[1]).subarray(0, 16);
|
||||||
|
const encrypted = Buffer.from(dlcData, "base64");
|
||||||
|
const decipher = crypto.createDecipheriv("aes-128-cbc", realKey, realKey);
|
||||||
|
decipher.setAutoPadding(false);
|
||||||
|
let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||||
|
|
||||||
|
const pad = decrypted[decrypted.length - 1];
|
||||||
|
if (pad > 0 && pad <= 16) {
|
||||||
|
decrypted = decrypted.subarray(0, decrypted.length - pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8");
|
||||||
|
return parsePackagesFromDlcXml(xmlData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
const blob = new Blob([fs.readFileSync(filePath)]);
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("dlcfile", blob, fileName);
|
||||||
|
|
||||||
|
const response = await fetch(DCRYPT_UPLOAD_URL, {
|
||||||
|
method: "POST",
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(compactErrorText(text));
|
||||||
|
}
|
||||||
|
const payload = decodeDcryptPayload(text);
|
||||||
|
let packages = extractPackagesFromPayload(payload);
|
||||||
|
if (packages.length === 1) {
|
||||||
|
const regrouped = groupLinksByName(packages[0].links);
|
||||||
|
if (regrouped.length > 1) {
|
||||||
|
packages = regrouped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (packages.length === 0) {
|
||||||
|
packages = groupLinksByName(extractUrlsRecursive(text));
|
||||||
|
}
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> {
|
||||||
|
const out: ParsedPackageInput[] = [];
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
if (path.extname(filePath).toLowerCase() !== ".dlc") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let packages: ParsedPackageInput[] = [];
|
||||||
|
try {
|
||||||
|
packages = await decryptDlcLocal(filePath);
|
||||||
|
} catch {
|
||||||
|
packages = [];
|
||||||
|
}
|
||||||
|
if (packages.length === 0) {
|
||||||
|
packages = await decryptDlcViaDcrypt(filePath);
|
||||||
|
}
|
||||||
|
out.push(...packages);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
846
src/main/download-manager.ts
Normal file
846
src/main/download-manager.ts
Normal file
@ -0,0 +1,846 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { AppSettings, DownloadItem, DownloadSummary, DownloadStatus, PackageEntry, ParsedPackageInput, SessionState, UiSnapshot } from "../shared/types";
|
||||||
|
import { CHUNK_SIZE, REQUEST_RETRIES } from "./constants";
|
||||||
|
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
|
import { extractPackageArchives } from "./extractor";
|
||||||
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
import { RealDebridClient } from "./realdebrid";
|
||||||
|
import { StoragePaths, saveSession } from "./storage";
|
||||||
|
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, nowMs, sanitizeFilename, sleep } from "./utils";
|
||||||
|
|
||||||
|
type ActiveTask = {
|
||||||
|
itemId: string;
|
||||||
|
packageId: string;
|
||||||
|
abortController: AbortController;
|
||||||
|
abortReason: "stop" | "cancel" | "reconnect" | "none";
|
||||||
|
resumable: boolean;
|
||||||
|
speedEvents: Array<{ at: number; bytes: number }>;
|
||||||
|
nonResumableCounted: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function cloneSession(session: SessionState): SessionState {
|
||||||
|
return JSON.parse(JSON.stringify(session)) as SessionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContentRangeTotal(contentRange: string | null): number | null {
|
||||||
|
if (!contentRange) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = contentRange.match(/\/(\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = Number(match[1]);
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRetryStatus(status: number): boolean {
|
||||||
|
return status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFinishedStatus(status: DownloadStatus): boolean {
|
||||||
|
return status === "completed" || status === "failed" || status === "cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAvailablePath(targetPath: string): string {
|
||||||
|
if (!fs.existsSync(targetPath)) {
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
|
const parsed = path.parse(targetPath);
|
||||||
|
let i = 1;
|
||||||
|
while (true) {
|
||||||
|
const candidate = path.join(parsed.dir, `${parsed.name} (${i})${parsed.ext}`);
|
||||||
|
if (!fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DownloadManager extends EventEmitter {
|
||||||
|
private settings: AppSettings;
|
||||||
|
|
||||||
|
private session: SessionState;
|
||||||
|
|
||||||
|
private storagePaths: StoragePaths;
|
||||||
|
|
||||||
|
private rdClient: RealDebridClient;
|
||||||
|
|
||||||
|
private activeTasks = new Map<string, ActiveTask>();
|
||||||
|
|
||||||
|
private scheduleRunning = false;
|
||||||
|
|
||||||
|
private persistTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
private speedEvents: Array<{ at: number; bytes: number }> = [];
|
||||||
|
|
||||||
|
private summary: DownloadSummary | null = null;
|
||||||
|
|
||||||
|
private nonResumableActive = 0;
|
||||||
|
|
||||||
|
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths) {
|
||||||
|
super();
|
||||||
|
this.settings = settings;
|
||||||
|
this.session = cloneSession(session);
|
||||||
|
this.storagePaths = storagePaths;
|
||||||
|
this.rdClient = new RealDebridClient(settings.token);
|
||||||
|
this.applyOnStartCleanupPolicy();
|
||||||
|
this.normalizeSessionStatuses();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setSettings(next: AppSettings): void {
|
||||||
|
const tokenChanged = next.token !== this.settings.token;
|
||||||
|
this.settings = next;
|
||||||
|
if (tokenChanged) {
|
||||||
|
this.rdClient = new RealDebridClient(next.token);
|
||||||
|
}
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSettings(): AppSettings {
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSession(): SessionState {
|
||||||
|
return cloneSession(this.session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSummary(): DownloadSummary | null {
|
||||||
|
return this.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSnapshot(): UiSnapshot {
|
||||||
|
const now = nowMs();
|
||||||
|
this.speedEvents = this.speedEvents.filter((event) => event.at >= now - 3000);
|
||||||
|
const speedBps = this.speedEvents.reduce((acc, event) => acc + event.bytes, 0) / 3;
|
||||||
|
|
||||||
|
const totalItems = Object.keys(this.session.items).length;
|
||||||
|
const doneItems = Object.values(this.session.items).filter((item) => isFinishedStatus(item.status)).length;
|
||||||
|
const elapsed = this.session.runStartedAt > 0 ? (now - this.session.runStartedAt) / 1000 : 0;
|
||||||
|
const rate = doneItems > 0 && elapsed > 0 ? doneItems / elapsed : 0;
|
||||||
|
const remaining = totalItems - doneItems;
|
||||||
|
const eta = remaining > 0 && rate > 0 ? remaining / rate : -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: this.settings,
|
||||||
|
session: this.getSession(),
|
||||||
|
summary: this.summary,
|
||||||
|
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
||||||
|
etaText: `ETA: ${formatEta(eta)}`,
|
||||||
|
canStart: !this.session.running,
|
||||||
|
canStop: this.session.running,
|
||||||
|
canPause: this.session.running
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearAll(): void {
|
||||||
|
this.stop();
|
||||||
|
this.session.packageOrder = [];
|
||||||
|
this.session.packages = {};
|
||||||
|
this.session.items = {};
|
||||||
|
this.session.summaryText = "";
|
||||||
|
this.summary = null;
|
||||||
|
this.persistNow();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public addPackages(packages: ParsedPackageInput[]): { addedPackages: number; addedLinks: number } {
|
||||||
|
let addedPackages = 0;
|
||||||
|
let addedLinks = 0;
|
||||||
|
for (const pkg of packages) {
|
||||||
|
const links = pkg.links.filter((link) => !!link.trim());
|
||||||
|
if (links.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const packageId = uuidv4();
|
||||||
|
const outputDir = ensureDirPath(this.settings.outputDir, pkg.name);
|
||||||
|
const extractBase = this.settings.extractDir || path.join(this.settings.outputDir, "_entpackt");
|
||||||
|
const extractDir = this.settings.createExtractSubfolder ? ensureDirPath(extractBase, pkg.name) : extractBase;
|
||||||
|
const packageEntry: PackageEntry = {
|
||||||
|
id: packageId,
|
||||||
|
name: sanitizeFilename(pkg.name),
|
||||||
|
outputDir,
|
||||||
|
extractDir,
|
||||||
|
status: "queued",
|
||||||
|
itemIds: [],
|
||||||
|
cancelled: false,
|
||||||
|
createdAt: nowMs(),
|
||||||
|
updatedAt: nowMs()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
const itemId = uuidv4();
|
||||||
|
const fileName = filenameFromUrl(link);
|
||||||
|
const item: DownloadItem = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: link,
|
||||||
|
status: "queued",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progressPercent: 0,
|
||||||
|
fileName,
|
||||||
|
targetPath: path.join(outputDir, fileName),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 0,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Wartet",
|
||||||
|
createdAt: nowMs(),
|
||||||
|
updatedAt: nowMs()
|
||||||
|
};
|
||||||
|
packageEntry.itemIds.push(itemId);
|
||||||
|
this.session.items[itemId] = item;
|
||||||
|
addedLinks += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session.packages[packageId] = packageEntry;
|
||||||
|
this.session.packageOrder.push(packageId);
|
||||||
|
addedPackages += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
return { addedPackages, addedLinks };
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancelPackage(packageId: string): void {
|
||||||
|
const pkg = this.session.packages[packageId];
|
||||||
|
if (!pkg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pkg.cancelled = true;
|
||||||
|
pkg.status = "cancelled";
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
|
||||||
|
for (const itemId of pkg.itemIds) {
|
||||||
|
const item = this.session.items[itemId];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.status === "queued" || item.status === "validating" || item.status === "reconnect_wait") {
|
||||||
|
item.status = "cancelled";
|
||||||
|
item.fullStatus = "Entfernt";
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
const active = this.activeTasks.get(itemId);
|
||||||
|
if (active) {
|
||||||
|
active.abortReason = "cancel";
|
||||||
|
active.abortController.abort("cancel");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = cleanupCancelledPackageArtifacts(pkg.outputDir);
|
||||||
|
logger.info(`Paket ${pkg.name} abgebrochen, ${removed} Artefakte gelöscht`);
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public start(): void {
|
||||||
|
if (this.session.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.session.running = true;
|
||||||
|
this.session.paused = false;
|
||||||
|
this.session.runStartedAt = this.session.runStartedAt || nowMs();
|
||||||
|
this.summary = null;
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
this.ensureScheduler();
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
this.session.running = false;
|
||||||
|
this.session.paused = false;
|
||||||
|
for (const active of this.activeTasks.values()) {
|
||||||
|
active.abortReason = "stop";
|
||||||
|
active.abortController.abort("stop");
|
||||||
|
}
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public togglePause(): boolean {
|
||||||
|
if (!this.session.running) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.session.paused = !this.session.paused;
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
return this.session.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSessionStatuses(): void {
|
||||||
|
for (const item of Object.values(this.session.items)) {
|
||||||
|
if (item.status === "downloading" || item.status === "validating" || item.status === "extracting" || item.status === "integrity_check") {
|
||||||
|
item.status = "queued";
|
||||||
|
item.speedBps = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pkg of Object.values(this.session.packages)) {
|
||||||
|
if (pkg.status === "downloading" || pkg.status === "validating" || pkg.status === "extracting" || pkg.status === "integrity_check") {
|
||||||
|
pkg.status = "queued";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.persistSoon();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyOnStartCleanupPolicy(): void {
|
||||||
|
if (this.settings.completedCleanupPolicy !== "on_start") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const pkgId of [...this.session.packageOrder]) {
|
||||||
|
const pkg = this.session.packages[pkgId];
|
||||||
|
if (!pkg) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pkg.itemIds = pkg.itemIds.filter((itemId) => {
|
||||||
|
const item = this.session.items[itemId];
|
||||||
|
if (!item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (item.status === "completed") {
|
||||||
|
delete this.session.items[itemId];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (pkg.itemIds.length === 0) {
|
||||||
|
delete this.session.packages[pkgId];
|
||||||
|
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== pkgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistSoon(): void {
|
||||||
|
if (this.persistTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.persistTimer = setTimeout(() => {
|
||||||
|
this.persistTimer = null;
|
||||||
|
this.persistNow();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistNow(): void {
|
||||||
|
saveSession(this.storagePaths, this.session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitState(): void {
|
||||||
|
this.emit("state", this.getSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureScheduler(): Promise<void> {
|
||||||
|
if (this.scheduleRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scheduleRunning = true;
|
||||||
|
try {
|
||||||
|
while (this.session.running) {
|
||||||
|
if (this.session.paused) {
|
||||||
|
await sleep(120);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.reconnectActive() && (this.nonResumableActive > 0 || this.activeTasks.size === 0)) {
|
||||||
|
this.markQueuedAsReconnectWait();
|
||||||
|
await sleep(200);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (this.activeTasks.size < Math.max(1, this.settings.maxParallel)) {
|
||||||
|
const next = this.findNextQueuedItem();
|
||||||
|
if (!next) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.startItem(next.packageId, next.itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeTasks.size === 0 && !this.hasQueuedItems()) {
|
||||||
|
this.finishRun();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(120);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.scheduleRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reconnectActive(): boolean {
|
||||||
|
return this.session.reconnectUntil > nowMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestReconnect(reason: string): void {
|
||||||
|
if (!this.settings.autoReconnect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const until = nowMs() + this.settings.reconnectWaitSeconds * 1000;
|
||||||
|
this.session.reconnectUntil = Math.max(this.session.reconnectUntil, until);
|
||||||
|
this.session.reconnectReason = reason;
|
||||||
|
|
||||||
|
for (const active of this.activeTasks.values()) {
|
||||||
|
if (active.resumable) {
|
||||||
|
active.abortReason = "reconnect";
|
||||||
|
active.abortController.abort("reconnect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`Reconnect angefordert: ${reason}`);
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private markQueuedAsReconnectWait(): void {
|
||||||
|
for (const item of Object.values(this.session.items)) {
|
||||||
|
if (item.status === "queued") {
|
||||||
|
item.status = "reconnect_wait";
|
||||||
|
item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
|
||||||
|
for (const packageId of this.session.packageOrder) {
|
||||||
|
const pkg = this.session.packages[packageId];
|
||||||
|
if (!pkg || pkg.cancelled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const itemId of pkg.itemIds) {
|
||||||
|
const item = this.session.items[itemId];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.status === "queued" || item.status === "reconnect_wait") {
|
||||||
|
return { packageId, itemId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasQueuedItems(): boolean {
|
||||||
|
return Object.values(this.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||||
|
}
|
||||||
|
|
||||||
|
private startItem(packageId: string, itemId: string): void {
|
||||||
|
const item = this.session.items[itemId];
|
||||||
|
const pkg = this.session.packages[packageId];
|
||||||
|
if (!item || !pkg || pkg.cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.status = "validating";
|
||||||
|
item.fullStatus = "Link wird via Real-Debrid umgewandelt";
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
pkg.status = "downloading";
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
|
||||||
|
const active: ActiveTask = {
|
||||||
|
itemId,
|
||||||
|
packageId,
|
||||||
|
abortController: new AbortController(),
|
||||||
|
abortReason: "none",
|
||||||
|
resumable: true,
|
||||||
|
speedEvents: [],
|
||||||
|
nonResumableCounted: false
|
||||||
|
};
|
||||||
|
this.activeTasks.set(itemId, active);
|
||||||
|
this.emitState();
|
||||||
|
|
||||||
|
void this.processItem(active).finally(() => {
|
||||||
|
if (active.nonResumableCounted) {
|
||||||
|
this.nonResumableActive = Math.max(0, this.nonResumableActive - 1);
|
||||||
|
}
|
||||||
|
this.activeTasks.delete(itemId);
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processItem(active: ActiveTask): Promise<void> {
|
||||||
|
const item = this.session.items[active.itemId];
|
||||||
|
const pkg = this.session.packages[active.packageId];
|
||||||
|
if (!item || !pkg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unrestricted = await this.rdClient.unrestrictLink(item.url);
|
||||||
|
item.retries = unrestricted.retriesUsed;
|
||||||
|
item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
|
||||||
|
fs.mkdirSync(pkg.outputDir, { recursive: true });
|
||||||
|
item.targetPath = nextAvailablePath(path.join(pkg.outputDir, item.fileName));
|
||||||
|
item.totalBytes = unrestricted.fileSize;
|
||||||
|
item.status = "downloading";
|
||||||
|
item.fullStatus = "Download läuft";
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
this.emitState();
|
||||||
|
|
||||||
|
const maxAttempts = REQUEST_RETRIES;
|
||||||
|
let done = false;
|
||||||
|
let downloadRetries = 0;
|
||||||
|
while (!done && item.attempts < maxAttempts) {
|
||||||
|
item.attempts += 1;
|
||||||
|
const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes);
|
||||||
|
downloadRetries += result.retriesUsed;
|
||||||
|
active.resumable = result.resumable;
|
||||||
|
if (!active.resumable && !active.nonResumableCounted) {
|
||||||
|
active.nonResumableCounted = true;
|
||||||
|
this.nonResumableActive += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.settings.enableIntegrityCheck) {
|
||||||
|
item.status = "integrity_check";
|
||||||
|
item.fullStatus = "CRC-Check läuft";
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
this.emitState();
|
||||||
|
|
||||||
|
const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir);
|
||||||
|
if (!validation.ok) {
|
||||||
|
item.lastError = validation.message;
|
||||||
|
item.fullStatus = `${validation.message}, Neuversuch`;
|
||||||
|
try {
|
||||||
|
fs.rmSync(item.targetPath, { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (item.attempts < maxAttempts) {
|
||||||
|
item.status = "queued";
|
||||||
|
item.progressPercent = 0;
|
||||||
|
item.downloadedBytes = 0;
|
||||||
|
item.totalBytes = unrestricted.fileSize;
|
||||||
|
this.emitState();
|
||||||
|
await sleep(300);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.retries += downloadRetries;
|
||||||
|
item.status = "completed";
|
||||||
|
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
|
||||||
|
item.progressPercent = 100;
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
|
||||||
|
await this.handlePackagePostProcessing(pkg.id);
|
||||||
|
this.applyCompletedCleanupPolicy(pkg.id, item.id);
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
} catch (error) {
|
||||||
|
const reason = active.abortReason;
|
||||||
|
if (reason === "cancel") {
|
||||||
|
item.status = "cancelled";
|
||||||
|
item.fullStatus = "Entfernt";
|
||||||
|
} else if (reason === "stop") {
|
||||||
|
item.status = "cancelled";
|
||||||
|
item.fullStatus = "Gestoppt";
|
||||||
|
} else if (reason === "reconnect") {
|
||||||
|
item.status = "queued";
|
||||||
|
item.fullStatus = "Wartet auf Reconnect";
|
||||||
|
} else {
|
||||||
|
item.status = "failed";
|
||||||
|
item.lastError = compactErrorText(error);
|
||||||
|
item.fullStatus = `Fehler: ${item.lastError}`;
|
||||||
|
}
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadToFile(
|
||||||
|
active: ActiveTask,
|
||||||
|
directUrl: string,
|
||||||
|
targetPath: string,
|
||||||
|
knownTotal: number | null
|
||||||
|
): Promise<{ retriesUsed: number; resumable: boolean }> {
|
||||||
|
const item = this.session.items[active.itemId];
|
||||||
|
if (!item) {
|
||||||
|
throw new Error("Download-Item fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError = "";
|
||||||
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
|
const existingBytes = fs.existsSync(targetPath) ? fs.statSync(targetPath).size : 0;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (existingBytes > 0) {
|
||||||
|
headers.Range = `bytes=${existingBytes}-`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.reconnectActive()) {
|
||||||
|
await sleep(250);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(directUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: active.abortController.signal
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
lastError = compactErrorText(error);
|
||||||
|
if (attempt < REQUEST_RETRIES) {
|
||||||
|
item.fullStatus = `Verbindungsfehler, retry ${attempt + 1}/${REQUEST_RETRIES}`;
|
||||||
|
this.emitState();
|
||||||
|
await sleep(300 * attempt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
lastError = compactErrorText(text || `HTTP ${response.status}`);
|
||||||
|
if (this.settings.autoReconnect && [429, 503].includes(response.status)) {
|
||||||
|
this.requestReconnect(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
if (canRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||||
|
item.fullStatus = `Serverfehler ${response.status}, retry ${attempt + 1}/${REQUEST_RETRIES}`;
|
||||||
|
this.emitState();
|
||||||
|
await sleep(350 * attempt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptRanges = (response.headers.get("accept-ranges") || "").toLowerCase().includes("bytes");
|
||||||
|
const resumable = response.status === 206 || acceptRanges;
|
||||||
|
active.resumable = resumable;
|
||||||
|
|
||||||
|
const contentLength = Number(response.headers.get("content-length") || 0);
|
||||||
|
const totalFromRange = parseContentRangeTotal(response.headers.get("content-range"));
|
||||||
|
if (knownTotal && knownTotal > 0) {
|
||||||
|
item.totalBytes = knownTotal;
|
||||||
|
} else if (totalFromRange) {
|
||||||
|
item.totalBytes = totalFromRange;
|
||||||
|
} else if (contentLength > 0) {
|
||||||
|
item.totalBytes = existingBytes + contentLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
|
||||||
|
if (writeMode === "w" && existingBytes > 0) {
|
||||||
|
fs.rmSync(targetPath, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = fs.createWriteStream(targetPath, { flags: writeMode });
|
||||||
|
let written = writeMode === "a" ? existingBytes : 0;
|
||||||
|
let windowBytes = 0;
|
||||||
|
let windowStarted = nowMs();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = response.body;
|
||||||
|
if (!body) {
|
||||||
|
throw new Error("Leerer Response-Body");
|
||||||
|
}
|
||||||
|
const reader = body.getReader();
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const chunk = value;
|
||||||
|
if (active.abortController.signal.aborted) {
|
||||||
|
throw new Error(`aborted:${active.abortReason}`);
|
||||||
|
}
|
||||||
|
while (this.session.paused && this.session.running && !active.abortController.signal.aborted) {
|
||||||
|
item.status = "paused";
|
||||||
|
item.fullStatus = "Pausiert";
|
||||||
|
this.emitState();
|
||||||
|
await sleep(120);
|
||||||
|
}
|
||||||
|
if (this.reconnectActive() && active.resumable) {
|
||||||
|
active.abortReason = "reconnect";
|
||||||
|
active.abortController.abort("reconnect");
|
||||||
|
throw new Error("aborted:reconnect");
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(chunk);
|
||||||
|
await this.applySpeedLimit(buffer.length, windowBytes, windowStarted);
|
||||||
|
stream.write(buffer);
|
||||||
|
written += buffer.length;
|
||||||
|
windowBytes += buffer.length;
|
||||||
|
this.session.totalDownloadedBytes += buffer.length;
|
||||||
|
this.speedEvents.push({ at: nowMs(), bytes: buffer.length });
|
||||||
|
this.speedEvents = this.speedEvents.filter((event) => event.at >= nowMs() - 3000);
|
||||||
|
|
||||||
|
const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1);
|
||||||
|
const speed = windowBytes / elapsed;
|
||||||
|
if (elapsed >= 1.2) {
|
||||||
|
windowStarted = nowMs();
|
||||||
|
windowBytes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.status = "downloading";
|
||||||
|
item.speedBps = Math.max(0, Math.floor(speed));
|
||||||
|
item.downloadedBytes = written;
|
||||||
|
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0;
|
||||||
|
item.fullStatus = "Download läuft";
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
stream.end(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
item.downloadedBytes = written;
|
||||||
|
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100;
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
return { retriesUsed: attempt - 1, resumable };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(lastError || "Download fehlgeschlagen");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> {
|
||||||
|
if (!this.settings.speedLimitEnabled || this.settings.speedLimitKbps <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bytesPerSecond = this.settings.speedLimitKbps * 1024;
|
||||||
|
const now = nowMs();
|
||||||
|
const elapsed = Math.max((now - localWindowStarted) / 1000, 0.1);
|
||||||
|
if (this.settings.speedLimitMode === "per_download") {
|
||||||
|
const projected = localWindowBytes + chunkBytes;
|
||||||
|
const allowed = bytesPerSecond * elapsed;
|
||||||
|
if (projected > allowed) {
|
||||||
|
const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000);
|
||||||
|
if (sleepMs > 0) {
|
||||||
|
await sleep(Math.min(300, sleepMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalBytes = this.speedEvents.reduce((acc, event) => acc + event.bytes, 0) + chunkBytes;
|
||||||
|
const globalAllowed = bytesPerSecond * 3;
|
||||||
|
if (globalBytes > globalAllowed) {
|
||||||
|
await sleep(Math.min(250, Math.ceil(((globalBytes - globalAllowed) / bytesPerSecond) * 1000)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePackagePostProcessing(packageId: string): Promise<void> {
|
||||||
|
const pkg = this.session.packages[packageId];
|
||||||
|
if (!pkg || pkg.cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
||||||
|
const success = items.filter((item) => item.status === "completed").length;
|
||||||
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||||
|
|
||||||
|
if (success + failed + cancelled < items.length) {
|
||||||
|
pkg.status = "downloading";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.settings.autoExtract && failed === 0 && success > 0) {
|
||||||
|
pkg.status = "extracting";
|
||||||
|
this.emitState();
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
packageDir: pkg.outputDir,
|
||||||
|
targetDir: pkg.extractDir,
|
||||||
|
cleanupMode: this.settings.cleanupMode,
|
||||||
|
conflictMode: this.settings.extractConflictMode,
|
||||||
|
removeLinks: this.settings.removeLinkFilesAfterExtract,
|
||||||
|
removeSamples: this.settings.removeSamplesAfterExtract
|
||||||
|
});
|
||||||
|
if (result.failed > 0) {
|
||||||
|
pkg.status = "failed";
|
||||||
|
} else {
|
||||||
|
pkg.status = "completed";
|
||||||
|
}
|
||||||
|
} else if (failed > 0) {
|
||||||
|
pkg.status = "failed";
|
||||||
|
} else if (cancelled > 0 && success === 0) {
|
||||||
|
pkg.status = "cancelled";
|
||||||
|
} else {
|
||||||
|
pkg.status = "completed";
|
||||||
|
}
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCompletedCleanupPolicy(packageId: string, itemId: string): void {
|
||||||
|
const policy = this.settings.completedCleanupPolicy;
|
||||||
|
if (policy === "never" || policy === "on_start") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = this.session.packages[packageId];
|
||||||
|
if (!pkg) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy === "immediate") {
|
||||||
|
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
||||||
|
delete this.session.items[itemId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy === "package_done") {
|
||||||
|
const hasOpen = pkg.itemIds.some((id) => {
|
||||||
|
const item = this.session.items[id];
|
||||||
|
if (!item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return item.status !== "completed";
|
||||||
|
});
|
||||||
|
if (!hasOpen) {
|
||||||
|
for (const id of pkg.itemIds) {
|
||||||
|
delete this.session.items[id];
|
||||||
|
}
|
||||||
|
delete this.session.packages[packageId];
|
||||||
|
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pkg.itemIds.length === 0) {
|
||||||
|
delete this.session.packages[packageId];
|
||||||
|
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishRun(): void {
|
||||||
|
this.session.running = false;
|
||||||
|
this.session.paused = false;
|
||||||
|
const items = Object.values(this.session.items);
|
||||||
|
const total = items.length;
|
||||||
|
const success = items.filter((item) => item.status === "completed").length;
|
||||||
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||||
|
const extracted = Object.values(this.session.packages).filter((pkg) => pkg.status === "completed").length;
|
||||||
|
const duration = this.session.runStartedAt > 0 ? Math.max(1, Math.floor((nowMs() - this.session.runStartedAt) / 1000)) : 1;
|
||||||
|
const avgSpeed = Math.floor(this.session.totalDownloadedBytes / duration);
|
||||||
|
this.summary = {
|
||||||
|
total,
|
||||||
|
success,
|
||||||
|
failed,
|
||||||
|
cancelled,
|
||||||
|
extracted,
|
||||||
|
durationSeconds: duration,
|
||||||
|
averageSpeedBps: avgSpeed
|
||||||
|
};
|
||||||
|
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${Math.max(total, 1)}`;
|
||||||
|
this.persistNow();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/main/extractor.ts
Normal file
137
src/main/extractor.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import AdmZip from "adm-zip";
|
||||||
|
import { CleanupMode, ConflictMode } from "../shared/types";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
|
|
||||||
|
export interface ExtractOptions {
|
||||||
|
packageDir: string;
|
||||||
|
targetDir: string;
|
||||||
|
cleanupMode: CleanupMode;
|
||||||
|
conflictMode: ConflictMode;
|
||||||
|
removeLinks: boolean;
|
||||||
|
removeSamples: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findArchiveCandidates(packageDir: string): string[] {
|
||||||
|
const files = fs.readdirSync(packageDir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile())
|
||||||
|
.map((entry) => path.join(packageDir, entry.name));
|
||||||
|
|
||||||
|
const preferred = files.filter((file) => /\.part0*1\.rar$/i.test(file));
|
||||||
|
const zip = files.filter((file) => /\.zip$/i.test(file));
|
||||||
|
const singleRar = files.filter((file) => /\.rar$/i.test(file) && !/\.part\d+\.rar$/i.test(file));
|
||||||
|
const seven = files.filter((file) => /\.7z$/i.test(file));
|
||||||
|
|
||||||
|
const ordered = [...preferred, ...zip, ...singleRar, ...seven];
|
||||||
|
return Array.from(new Set(ordered));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runExternalExtract(archivePath: string, targetDir: string): Promise<void> {
|
||||||
|
const candidates = ["7z", "C:\\Program Files\\7-Zip\\7z.exe", "C:\\Program Files (x86)\\7-Zip\\7z.exe", "unrar"];
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tryExec = (idx: number): void => {
|
||||||
|
if (idx >= candidates.length) {
|
||||||
|
reject(new Error("Kein 7z/unrar gefunden"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cmd = candidates[idx];
|
||||||
|
const args = cmd.toLowerCase().includes("unrar")
|
||||||
|
? ["x", "-o+", archivePath, `${targetDir}${path.sep}`]
|
||||||
|
: ["x", "-y", archivePath, `-o${targetDir}`];
|
||||||
|
const child = spawn(cmd, args, { windowsHide: true });
|
||||||
|
child.on("error", () => tryExec(idx + 1));
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code === 0 || code === 1) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
tryExec(idx + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
tryExec(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode): void {
|
||||||
|
const zip = new AdmZip(archivePath);
|
||||||
|
const entries = zip.getEntries();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const outputPath = path.join(targetDir, entry.entryName);
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
fs.mkdirSync(outputPath, { recursive: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
|
if (fs.existsSync(outputPath)) {
|
||||||
|
if (conflictMode === "skip") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (conflictMode === "rename") {
|
||||||
|
const parsed = path.parse(outputPath);
|
||||||
|
let n = 1;
|
||||||
|
let candidate = outputPath;
|
||||||
|
while (fs.existsSync(candidate)) {
|
||||||
|
candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(candidate, entry.getData());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.writeFileSync(outputPath, entry.getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): void {
|
||||||
|
if (cleanupMode === "none") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const filePath of sourceFiles) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(filePath, { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number }> {
|
||||||
|
const candidates = findArchiveCandidates(options.packageDir);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return { extracted: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(options.targetDir, { recursive: true });
|
||||||
|
|
||||||
|
let extracted = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const archivePath of candidates) {
|
||||||
|
try {
|
||||||
|
const ext = path.extname(archivePath).toLowerCase();
|
||||||
|
if (ext === ".zip") {
|
||||||
|
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
|
||||||
|
} else {
|
||||||
|
await runExternalExtract(archivePath, options.targetDir);
|
||||||
|
}
|
||||||
|
extracted += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
logger.error(`Entpack-Fehler ${path.basename(archivePath)}: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extracted > 0) {
|
||||||
|
cleanupArchives(candidates, options.cleanupMode);
|
||||||
|
if (options.removeLinks) {
|
||||||
|
removeDownloadLinkArtifacts(options.targetDir);
|
||||||
|
}
|
||||||
|
if (options.removeSamples) {
|
||||||
|
removeSampleArtifacts(options.targetDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { extracted, failed };
|
||||||
|
}
|
||||||
116
src/main/integrity.ts
Normal file
116
src/main/integrity.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { ParsedHashEntry } from "../shared/types";
|
||||||
|
|
||||||
|
export function parseHashLine(line: string): ParsedHashEntry | null {
|
||||||
|
const text = String(line || "").trim();
|
||||||
|
if (!text || text.startsWith(";")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const md = text.match(/^([0-9a-fA-F]{32}|[0-9a-fA-F]{40})\s+\*?(.+)$/);
|
||||||
|
if (md) {
|
||||||
|
const digest = md[1].toLowerCase();
|
||||||
|
return {
|
||||||
|
fileName: md[2].trim(),
|
||||||
|
algorithm: digest.length === 32 ? "md5" : "sha1",
|
||||||
|
digest
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const sfv = text.match(/^(.+?)\s+([0-9A-Fa-f]{8})$/);
|
||||||
|
if (sfv) {
|
||||||
|
return {
|
||||||
|
fileName: sfv[1].trim(),
|
||||||
|
algorithm: "crc32",
|
||||||
|
digest: sfv[2].toLowerCase()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readHashManifest(packageDir: string): Map<string, ParsedHashEntry> {
|
||||||
|
const map = new Map<string, ParsedHashEntry>();
|
||||||
|
const patterns: Array<[string, "crc32" | "md5" | "sha1"]> = [
|
||||||
|
[".sfv", "crc32"],
|
||||||
|
[".md5", "md5"],
|
||||||
|
[".sha1", "sha1"]
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!fs.existsSync(packageDir)) {
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
|
const hit = patterns.find(([pattern]) => pattern === ext);
|
||||||
|
if (!hit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const filePath = path.join(packageDir, entry.name);
|
||||||
|
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
const parsed = parseHashLine(line);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized: ParsedHashEntry = {
|
||||||
|
...parsed,
|
||||||
|
algorithm: hit[1]
|
||||||
|
};
|
||||||
|
map.set(parsed.fileName.toLowerCase(), normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32Buffer(data: Buffer, seed = 0): number {
|
||||||
|
let crc = seed ^ -1;
|
||||||
|
for (let i = 0; i < data.length; i += 1) {
|
||||||
|
let c = (crc ^ data[i]) & 0xff;
|
||||||
|
for (let j = 0; j < 8; j += 1) {
|
||||||
|
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
}
|
||||||
|
crc = (crc >>> 8) ^ c;
|
||||||
|
}
|
||||||
|
return crc ^ -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashFile(filePath: string, algorithm: "crc32" | "md5" | "sha1"): Promise<string> {
|
||||||
|
if (algorithm === "crc32") {
|
||||||
|
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||||
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
let crc = 0;
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
crc = crc32Buffer(chunk, crc);
|
||||||
|
});
|
||||||
|
stream.on("error", reject);
|
||||||
|
stream.on("end", () => resolve(((crc >>> 0).toString(16)).padStart(8, "0").toLowerCase()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = crypto.createHash(algorithm);
|
||||||
|
const data = fs.readFileSync(filePath);
|
||||||
|
hash.update(data);
|
||||||
|
return hash.digest("hex").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateFileAgainstManifest(filePath: string, packageDir: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const manifest = readHashManifest(packageDir);
|
||||||
|
if (manifest.size === 0) {
|
||||||
|
return { ok: true, message: "Kein Hash verfügbar" };
|
||||||
|
}
|
||||||
|
const key = path.basename(filePath).toLowerCase();
|
||||||
|
const entry = manifest.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return { ok: true, message: "Kein Hash für Datei" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const actual = await hashFile(filePath, entry.algorithm);
|
||||||
|
if (actual === entry.digest.toLowerCase()) {
|
||||||
|
return { ok: true, message: `${entry.algorithm.toUpperCase()} ok` };
|
||||||
|
}
|
||||||
|
return { ok: false, message: `${entry.algorithm.toUpperCase()} mismatch` };
|
||||||
|
}
|
||||||
24
src/main/link-parser.ts
Normal file
24
src/main/link-parser.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ParsedPackageInput } from "../shared/types";
|
||||||
|
import { inferPackageNameFromLinks, parsePackagesFromLinksText, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||||
|
|
||||||
|
export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackageInput[] {
|
||||||
|
const grouped = new Map<string, string[]>();
|
||||||
|
for (const pkg of packages) {
|
||||||
|
const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links));
|
||||||
|
const list = grouped.get(name) ?? [];
|
||||||
|
list.push(...pkg.links);
|
||||||
|
grouped.set(name, list);
|
||||||
|
}
|
||||||
|
return Array.from(grouped.entries()).map(([name, links]) => ({
|
||||||
|
name,
|
||||||
|
links: uniquePreserveOrder(links)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {
|
||||||
|
const parsed = parsePackagesFromLinksText(rawText, packageName);
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return mergePackageInputs(parsed);
|
||||||
|
}
|
||||||
28
src/main/logger.ts
Normal file
28
src/main/logger.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||||
|
|
||||||
|
export function configureLogger(baseDir: string): void {
|
||||||
|
logFilePath = path.join(baseDir, "rd_downloader.log");
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
|
||||||
|
const line = `${new Date().toISOString()} [${level}] ${message}\n`;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(logFilePath), { recursive: true });
|
||||||
|
fs.appendFileSync(logFilePath, line, "utf8");
|
||||||
|
} catch {
|
||||||
|
// ignore logging failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
info: (msg: string): void => write("INFO", msg),
|
||||||
|
warn: (msg: string): void => write("WARN", msg),
|
||||||
|
error: (msg: string): void => write("ERROR", msg)
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLogFilePath(): string {
|
||||||
|
return logFilePath;
|
||||||
|
}
|
||||||
100
src/main/main.ts
Normal file
100
src/main/main.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { app, BrowserWindow, dialog, ipcMain, IpcMainInvokeEvent } from "electron";
|
||||||
|
import { AddLinksPayload, AppSettings } from "../shared/types";
|
||||||
|
import { AppController } from "./app-controller";
|
||||||
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
const controller = new AppController();
|
||||||
|
|
||||||
|
function isDevMode(): boolean {
|
||||||
|
return process.env.NODE_ENV === "development";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow(): BrowserWindow {
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
width: 1440,
|
||||||
|
height: 940,
|
||||||
|
minWidth: 1120,
|
||||||
|
minHeight: 760,
|
||||||
|
backgroundColor: "#070b14",
|
||||||
|
title: `Real-Debrid Download Manager v${controller.getVersion()}`,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
preload: path.join(__dirname, "../preload/preload.js")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDevMode()) {
|
||||||
|
void window.loadURL("http://localhost:5173");
|
||||||
|
} else {
|
||||||
|
void window.loadFile(path.join(__dirname, "../renderer/index.html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerIpcHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot());
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
|
||||||
|
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => controller.updateSettings(partial ?? {}));
|
||||||
|
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => controller.addLinks(payload));
|
||||||
|
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? []));
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
||||||
|
ipcMain.handle(IPC_CHANNELS.START, () => controller.start());
|
||||||
|
ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop());
|
||||||
|
ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause());
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => controller.cancelPackage(packageId));
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PICK_FOLDER, async () => {
|
||||||
|
const options = {
|
||||||
|
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
||||||
|
};
|
||||||
|
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||||
|
return result.canceled ? null : result.filePaths[0] || null;
|
||||||
|
});
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PICK_CONTAINERS, async () => {
|
||||||
|
const options = {
|
||||||
|
properties: ["openFile", "multiSelections"] as Array<"openFile" | "multiSelections">,
|
||||||
|
filters: [
|
||||||
|
{ name: "Container", extensions: ["dlc"] },
|
||||||
|
{ name: "Alle Dateien", extensions: ["*"] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||||
|
return result.canceled ? [] : result.filePaths;
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onState = (snapshot) => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainWindow.webContents.send(IPC_CHANNELS.STATE_UPDATE, snapshot);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
registerIpcHandlers();
|
||||||
|
mainWindow = createWindow();
|
||||||
|
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
mainWindow = createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("before-quit", () => {
|
||||||
|
try {
|
||||||
|
controller.shutdown();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Fehler beim Shutdown: ${String(error)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
81
src/main/realdebrid.ts
Normal file
81
src/main/realdebrid.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
||||||
|
import { compactErrorText, sleep } from "./utils";
|
||||||
|
|
||||||
|
export interface UnrestrictedLink {
|
||||||
|
fileName: string;
|
||||||
|
directUrl: string;
|
||||||
|
fileSize: number | null;
|
||||||
|
retriesUsed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryStatus(status: number): boolean {
|
||||||
|
return status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryDelay(attempt: number): number {
|
||||||
|
return Math.min(5000, 400 * 2 ** attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrorBody(status: number, body: string): string {
|
||||||
|
const clean = compactErrorText(body);
|
||||||
|
return clean || `HTTP ${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RealDebridClient {
|
||||||
|
private token: string;
|
||||||
|
|
||||||
|
public constructor(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async unrestrictLink(link: string): Promise<UnrestrictedLink> {
|
||||||
|
let lastError = "";
|
||||||
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const body = new URLSearchParams({ link });
|
||||||
|
const response = await fetch(`${API_BASE_URL}/unrestrict/link`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"User-Agent": "RD-Node-Downloader/1.1.9"
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
const parsed = parseErrorBody(response.status, text);
|
||||||
|
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||||
|
await sleep(retryDelay(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
const directUrl = String(payload.download || payload.link || "").trim();
|
||||||
|
if (!directUrl) {
|
||||||
|
throw new Error("Unrestrict ohne Download-URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = String(payload.filename || "download.bin").trim() || "download.bin";
|
||||||
|
const fileSizeRaw = Number(payload.filesize ?? NaN);
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
directUrl,
|
||||||
|
fileSize: Number.isFinite(fileSizeRaw) && fileSizeRaw > 0 ? Math.floor(fileSizeRaw) : null,
|
||||||
|
retriesUsed: attempt - 1
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = compactErrorText(error);
|
||||||
|
if (attempt >= REQUEST_RETRIES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(retryDelay(attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(lastError || "Unrestrict fehlgeschlagen");
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/main/storage.ts
Normal file
97
src/main/storage.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { AppSettings, SessionState } from "../shared/types";
|
||||||
|
import { defaultSettings } from "./constants";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
export interface StoragePaths {
|
||||||
|
baseDir: string;
|
||||||
|
configFile: string;
|
||||||
|
sessionFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStoragePaths(baseDir: string): StoragePaths {
|
||||||
|
return {
|
||||||
|
baseDir,
|
||||||
|
configFile: path.join(baseDir, "rd_downloader_config.json"),
|
||||||
|
sessionFile: path.join(baseDir, "rd_session_state.json")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBaseDir(baseDir: string): void {
|
||||||
|
fs.mkdirSync(baseDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSettings(paths: StoragePaths): AppSettings {
|
||||||
|
ensureBaseDir(paths.baseDir);
|
||||||
|
if (!fs.existsSync(paths.configFile)) {
|
||||||
|
return defaultSettings();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as Partial<AppSettings>;
|
||||||
|
const merged: AppSettings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
...parsed
|
||||||
|
};
|
||||||
|
merged.maxParallel = Math.max(1, Math.min(50, Number(merged.maxParallel) || 4));
|
||||||
|
merged.speedLimitKbps = Math.max(0, Math.min(500000, Number(merged.speedLimitKbps) || 0));
|
||||||
|
merged.reconnectWaitSeconds = Math.max(10, Math.min(600, Number(merged.reconnectWaitSeconds) || 45));
|
||||||
|
return merged;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Konfiguration konnte nicht geladen werden: ${String(error)}`);
|
||||||
|
return defaultSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
||||||
|
ensureBaseDir(paths.baseDir);
|
||||||
|
const payload = JSON.stringify(settings, null, 2);
|
||||||
|
const tempPath = `${paths.configFile}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
|
fs.renameSync(tempPath, paths.configFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptySession(): SessionState {
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
packageOrder: [],
|
||||||
|
packages: {},
|
||||||
|
items: {},
|
||||||
|
runStartedAt: 0,
|
||||||
|
totalDownloadedBytes: 0,
|
||||||
|
summaryText: "",
|
||||||
|
reconnectUntil: 0,
|
||||||
|
reconnectReason: "",
|
||||||
|
paused: false,
|
||||||
|
running: false,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSession(paths: StoragePaths): SessionState {
|
||||||
|
ensureBaseDir(paths.baseDir);
|
||||||
|
if (!fs.existsSync(paths.sessionFile)) {
|
||||||
|
return emptySession();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as Partial<SessionState>;
|
||||||
|
return {
|
||||||
|
...emptySession(),
|
||||||
|
...parsed,
|
||||||
|
packages: parsed.packages ?? {},
|
||||||
|
items: parsed.items ?? {},
|
||||||
|
packageOrder: parsed.packageOrder ?? []
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Session konnte nicht geladen werden: ${String(error)}`);
|
||||||
|
return emptySession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(paths: StoragePaths, session: SessionState): void {
|
||||||
|
ensureBaseDir(paths.baseDir);
|
||||||
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, null, 2);
|
||||||
|
const tempPath = `${paths.sessionFile}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
|
fs.renameSync(tempPath, paths.sessionFile);
|
||||||
|
}
|
||||||
151
src/main/utils.ts
Normal file
151
src/main/utils.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { ParsedPackageInput } from "../shared/types";
|
||||||
|
|
||||||
|
export function compactErrorText(message: unknown, maxLen = 220): string {
|
||||||
|
const raw = String(message ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
if (!raw) {
|
||||||
|
return "Unbekannter Fehler";
|
||||||
|
}
|
||||||
|
if (raw.length <= maxLen) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return `${raw.slice(0, maxLen - 3)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFilename(name: string): string {
|
||||||
|
const cleaned = String(name || "").trim().replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
return cleaned || "Paket";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHttpLink(value: string): boolean {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(text);
|
||||||
|
return (url.protocol === "http:" || url.protocol === "https:") && !!url.hostname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function humanSize(bytes: number): string {
|
||||||
|
const value = Number(bytes);
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
return "0 B";
|
||||||
|
}
|
||||||
|
if (value < 1024) {
|
||||||
|
return `${Math.round(value)} B`;
|
||||||
|
}
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let size = value / 1024;
|
||||||
|
let unit = 0;
|
||||||
|
while (size >= 1024 && unit < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filenameFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const name = path.basename(parsed.pathname || "");
|
||||||
|
return sanitizeFilename(name || "download.bin");
|
||||||
|
} catch {
|
||||||
|
return "download.bin";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferPackageNameFromLinks(links: string[]): string {
|
||||||
|
if (links.length === 0) {
|
||||||
|
return "Paket";
|
||||||
|
}
|
||||||
|
const names = links.map((link) => filenameFromUrl(link).toLowerCase());
|
||||||
|
const first = names[0];
|
||||||
|
const match = first.match(/^([a-z0-9._\- ]{3,80}?)(?:\.|-|_)(?:part\d+|r\d{2}|s\d{2}e\d{2})/i);
|
||||||
|
if (match) {
|
||||||
|
return sanitizeFilename(match[1]);
|
||||||
|
}
|
||||||
|
return sanitizeFilename(path.parse(first).name || "Paket");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniquePreserveOrder(items: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
if (!trimmed || seen.has(trimmed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(trimmed);
|
||||||
|
out.push(trimmed);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePackagesFromLinksText(rawText: string, defaultPackageName: string): ParsedPackageInput[] {
|
||||||
|
const lines = String(rawText || "").split(/\r?\n/);
|
||||||
|
const packages: ParsedPackageInput[] = [];
|
||||||
|
let currentName = sanitizeFilename(defaultPackageName || "Paket");
|
||||||
|
let currentLinks: string[] = [];
|
||||||
|
|
||||||
|
const flush = (): void => {
|
||||||
|
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
|
||||||
|
if (links.length > 0) {
|
||||||
|
packages.push({
|
||||||
|
name: sanitizeFilename(currentName || inferPackageNameFromLinks(links)),
|
||||||
|
links
|
||||||
|
});
|
||||||
|
}
|
||||||
|
currentLinks = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const text = line.trim();
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const marker = text.match(/^#\s*package\s*:\s*(.+)$/i);
|
||||||
|
if (marker) {
|
||||||
|
flush();
|
||||||
|
currentName = sanitizeFilename(marker[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
currentLinks.push(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
if (packages.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureDirPath(baseDir: string, packageName: string): string {
|
||||||
|
return path.join(baseDir, sanitizeFilename(packageName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowMs(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEta(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
const s = Math.floor(seconds);
|
||||||
|
const sec = s % 60;
|
||||||
|
const minTotal = Math.floor(s / 60);
|
||||||
|
const min = minTotal % 60;
|
||||||
|
const hr = Math.floor(minTotal / 60);
|
||||||
|
if (hr > 0) {
|
||||||
|
return `${String(hr).padStart(2, "0")}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
30
src/preload/preload.ts
Normal file
30
src/preload/preload.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
import { AddLinksPayload, AppSettings, UiSnapshot } from "../shared/types";
|
||||||
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
|
import { ElectronApi } from "../shared/preload-api";
|
||||||
|
|
||||||
|
const api: ElectronApi = {
|
||||||
|
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
||||||
|
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
||||||
|
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
||||||
|
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
||||||
|
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
|
||||||
|
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
|
||||||
|
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
|
||||||
|
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
||||||
|
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
||||||
|
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
|
||||||
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||||
|
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||||
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||||
|
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("rd", api);
|
||||||
386
src/renderer/App.tsx
Normal file
386
src/renderer/App.tsx
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
import { DragEvent, ReactElement, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { AppSettings, DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
|
||||||
|
|
||||||
|
type Tab = "collector" | "downloads" | "settings";
|
||||||
|
|
||||||
|
const emptySnapshot = (): UiSnapshot => ({
|
||||||
|
settings: {
|
||||||
|
token: "",
|
||||||
|
rememberToken: true,
|
||||||
|
outputDir: "",
|
||||||
|
packageName: "",
|
||||||
|
autoExtract: true,
|
||||||
|
extractDir: "",
|
||||||
|
createExtractSubfolder: true,
|
||||||
|
hybridExtract: true,
|
||||||
|
cleanupMode: "none",
|
||||||
|
extractConflictMode: "overwrite",
|
||||||
|
removeLinkFilesAfterExtract: false,
|
||||||
|
removeSamplesAfterExtract: false,
|
||||||
|
enableIntegrityCheck: true,
|
||||||
|
autoResumeOnStart: true,
|
||||||
|
autoReconnect: false,
|
||||||
|
reconnectWaitSeconds: 45,
|
||||||
|
completedCleanupPolicy: "never",
|
||||||
|
maxParallel: 4,
|
||||||
|
speedLimitEnabled: false,
|
||||||
|
speedLimitKbps: 0,
|
||||||
|
speedLimitMode: "global",
|
||||||
|
updateRepo: "",
|
||||||
|
autoUpdateCheck: true
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
version: 2,
|
||||||
|
packageOrder: [],
|
||||||
|
packages: {},
|
||||||
|
items: {},
|
||||||
|
runStartedAt: 0,
|
||||||
|
totalDownloadedBytes: 0,
|
||||||
|
summaryText: "",
|
||||||
|
reconnectUntil: 0,
|
||||||
|
reconnectReason: "",
|
||||||
|
paused: false,
|
||||||
|
running: false,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
},
|
||||||
|
summary: null,
|
||||||
|
speedText: "Geschwindigkeit: 0 B/s",
|
||||||
|
etaText: "ETA: --",
|
||||||
|
canStart: true,
|
||||||
|
canStop: false,
|
||||||
|
canPause: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanupLabels: Record<string, string> = {
|
||||||
|
never: "Nie",
|
||||||
|
immediate: "Sofort",
|
||||||
|
on_start: "Beim App-Start",
|
||||||
|
package_done: "Sobald Paket fertig ist"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function App(): ReactElement {
|
||||||
|
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
||||||
|
const [tab, setTab] = useState<Tab>("collector");
|
||||||
|
const [linksRaw, setLinksRaw] = useState("");
|
||||||
|
const [statusToast, setStatusToast] = useState("");
|
||||||
|
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
void window.rd.getSnapshot().then((state) => {
|
||||||
|
setSnapshot(state);
|
||||||
|
setSettingsDraft(state.settings);
|
||||||
|
});
|
||||||
|
unsubscribe = window.rd.onStateUpdate((state) => {
|
||||||
|
setSnapshot(state);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const packages = useMemo(() => snapshot.session.packageOrder
|
||||||
|
.map((id: string) => snapshot.session.packages[id])
|
||||||
|
.filter(Boolean), [snapshot]);
|
||||||
|
|
||||||
|
const onSaveSettings = async (): Promise<void> => {
|
||||||
|
await window.rd.updateSettings(settingsDraft);
|
||||||
|
setStatusToast("Settings gespeichert");
|
||||||
|
setTimeout(() => setStatusToast(""), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddLinks = async (): Promise<void> => {
|
||||||
|
await window.rd.updateSettings(settingsDraft);
|
||||||
|
const result = await window.rd.addLinks({ rawText: linksRaw, packageName: settingsDraft.packageName });
|
||||||
|
if (result.addedLinks > 0) {
|
||||||
|
setStatusToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
|
||||||
|
setLinksRaw("");
|
||||||
|
} else {
|
||||||
|
setStatusToast("Keine gültigen Links gefunden");
|
||||||
|
}
|
||||||
|
setTimeout(() => setStatusToast(""), 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImportDlc = async (): Promise<void> => {
|
||||||
|
const files = await window.rd.pickContainers();
|
||||||
|
if (files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await window.rd.addContainers(files);
|
||||||
|
setStatusToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
||||||
|
setTimeout(() => setStatusToast(""), 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = async (event: DragEvent<HTMLTextAreaElement>): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
const files = Array.from(event.dataTransfer.files ?? []) as File[];
|
||||||
|
const dlc = files
|
||||||
|
.filter((file) => file.name.toLowerCase().endsWith(".dlc"))
|
||||||
|
.map((file) => (file as unknown as { path?: string }).path)
|
||||||
|
.filter((value): value is string => !!value);
|
||||||
|
if (dlc.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await window.rd.addContainers(dlc);
|
||||||
|
setStatusToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
|
||||||
|
setTimeout(() => setStatusToast(""), 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBool = (key: keyof AppSettings, value: boolean): void => {
|
||||||
|
setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setText = (key: keyof AppSettings, value: string): void => {
|
||||||
|
setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNum = (key: keyof AppSettings, value: number): void => {
|
||||||
|
setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="top-header">
|
||||||
|
<div className="title-block">
|
||||||
|
<h1>Real-Debrid Download Manager</h1>
|
||||||
|
<span>JDownloader-Style Workflow</span>
|
||||||
|
</div>
|
||||||
|
<div className="metrics">
|
||||||
|
<div>{snapshot.speedText}</div>
|
||||||
|
<div>{snapshot.etaText}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="control-strip">
|
||||||
|
<div className="buttons">
|
||||||
|
<button className="btn accent" disabled={!snapshot.canStart} onClick={() => window.rd.start()}>Start</button>
|
||||||
|
<button className="btn" disabled={!snapshot.canPause} onClick={() => window.rd.togglePause()}>
|
||||||
|
{snapshot.session.paused ? "Resume" : "Pause"}
|
||||||
|
</button>
|
||||||
|
<button className="btn" disabled={!snapshot.canStop} onClick={() => window.rd.stop()}>Stop</button>
|
||||||
|
<button className="btn" onClick={() => window.rd.clearAll()}>Alles leeren</button>
|
||||||
|
</div>
|
||||||
|
<div className="speed-config">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingsDraft.speedLimitEnabled}
|
||||||
|
onChange={(event) => setBool("speedLimitEnabled", event.target.checked)}
|
||||||
|
/>
|
||||||
|
Speed-Limit
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={500000}
|
||||||
|
value={settingsDraft.speedLimitKbps}
|
||||||
|
onChange={(event) => setNum("speedLimitKbps", Number(event.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
<span>KB/s</span>
|
||||||
|
<select value={settingsDraft.speedLimitMode} onChange={(event) => setText("speedLimitMode", event.target.value)}>
|
||||||
|
<option value="global">global</option>
|
||||||
|
<option value="per_download">per_download</option>
|
||||||
|
</select>
|
||||||
|
<button className="btn" onClick={onSaveSettings}>Live speichern</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav className="tabs">
|
||||||
|
<button className={tab === "collector" ? "tab active" : "tab"} onClick={() => setTab("collector")}>Linksammler</button>
|
||||||
|
<button className={tab === "downloads" ? "tab active" : "tab"} onClick={() => setTab("downloads")}>Downloads</button>
|
||||||
|
<button className={tab === "settings" ? "tab active" : "tab"} onClick={() => setTab("settings")}>Settings</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="tab-content">
|
||||||
|
{tab === "collector" && (
|
||||||
|
<section className="grid-two">
|
||||||
|
<article className="card">
|
||||||
|
<h3>Authentifizierung</h3>
|
||||||
|
<label>API Token</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={settingsDraft.token}
|
||||||
|
onChange={(event) => setText("token", event.target.value)}
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingsDraft.rememberToken}
|
||||||
|
onChange={(event) => setBool("rememberToken", event.target.checked)}
|
||||||
|
/>
|
||||||
|
Token lokal speichern
|
||||||
|
</label>
|
||||||
|
<label>GitHub Repo</label>
|
||||||
|
<input value={settingsDraft.updateRepo} onChange={(event) => setText("updateRepo", event.target.value)} />
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingsDraft.autoUpdateCheck}
|
||||||
|
onChange={(event) => setBool("autoUpdateCheck", event.target.checked)}
|
||||||
|
/>
|
||||||
|
Beim Start auf Updates prüfen
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="card">
|
||||||
|
<h3>Paketierung & Zielpfade</h3>
|
||||||
|
<label>Download-Ordner</label>
|
||||||
|
<div className="input-row">
|
||||||
|
<input value={settingsDraft.outputDir} onChange={(event) => setText("outputDir", event.target.value)} />
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={async () => {
|
||||||
|
const selected = await window.rd.pickFolder();
|
||||||
|
if (selected) {
|
||||||
|
setText("outputDir", selected);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Wählen</button>
|
||||||
|
</div>
|
||||||
|
<label>Paketname (optional)</label>
|
||||||
|
<input value={settingsDraft.packageName} onChange={(event) => setText("packageName", event.target.value)} />
|
||||||
|
<label>Entpacken nach</label>
|
||||||
|
<div className="input-row">
|
||||||
|
<input value={settingsDraft.extractDir} onChange={(event) => setText("extractDir", event.target.value)} />
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={async () => {
|
||||||
|
const selected = await window.rd.pickFolder();
|
||||||
|
if (selected) {
|
||||||
|
setText("extractDir", selected);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Wählen</button>
|
||||||
|
</div>
|
||||||
|
<label><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(event) => setBool("autoExtract", event.target.checked)} /> Auto-Extract</label>
|
||||||
|
<label><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(event) => setBool("hybridExtract", event.target.checked)} /> Hybrid-Extract</label>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="card wide">
|
||||||
|
<h3>Linksammler</h3>
|
||||||
|
<div className="link-actions">
|
||||||
|
<button className="btn" onClick={onImportDlc}>DLC import</button>
|
||||||
|
<button className="btn accent" onClick={onAddLinks}>Zur Queue hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={linksRaw}
|
||||||
|
onChange={(event) => setLinksRaw(event.target.value)}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDrop={onDrop}
|
||||||
|
placeholder="# package: Release-Name\nhttps://...\nhttps://..."
|
||||||
|
/>
|
||||||
|
<p className="hint">.dlc einfach auf das Feld ziehen oder per Button importieren.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "downloads" && (
|
||||||
|
<section className="downloads-view">
|
||||||
|
{packages.length === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>}
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<PackageCard
|
||||||
|
key={pkg.id}
|
||||||
|
pkg={pkg}
|
||||||
|
items={pkg.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean)}
|
||||||
|
onCancel={() => window.rd.cancelPackage(pkg.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "settings" && (
|
||||||
|
<section className="grid-two settings-grid">
|
||||||
|
<article className="card">
|
||||||
|
<h3>Queue & Reconnect</h3>
|
||||||
|
<label>Max. gleichzeitige Downloads</label>
|
||||||
|
<input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(event) => setNum("maxParallel", Number(event.target.value) || 1)} />
|
||||||
|
<label><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(event) => setBool("autoReconnect", event.target.checked)} /> Automatischer Reconnect</label>
|
||||||
|
<label>Reconnect-Wartezeit (Sek.)</label>
|
||||||
|
<input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(event) => setNum("reconnectWaitSeconds", Number(event.target.value) || 45)} />
|
||||||
|
<label><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(event) => setBool("autoResumeOnStart", event.target.checked)} /> Auto-Resume beim Start</label>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="card">
|
||||||
|
<h3>Integrität & Cleanup</h3>
|
||||||
|
<label><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(event) => setBool("enableIntegrityCheck", event.target.checked)} /> SFV/CRC/MD5/SHA1 prüfen</label>
|
||||||
|
<label><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(event) => setBool("removeLinkFilesAfterExtract", event.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
|
||||||
|
<label><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(event) => setBool("removeSamplesAfterExtract", event.target.checked)} /> Samples nach Entpacken entfernen</label>
|
||||||
|
<label>Fertiggestellte Downloads entfernen</label>
|
||||||
|
<select value={settingsDraft.completedCleanupPolicy} onChange={(event) => setText("completedCleanupPolicy", event.target.value)}>
|
||||||
|
{Object.entries(cleanupLabels).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label>Cleanup nach Entpacken</label>
|
||||||
|
<select value={settingsDraft.cleanupMode} onChange={(event) => setText("cleanupMode", event.target.value)}>
|
||||||
|
<option value="none">keine Archive löschen</option>
|
||||||
|
<option value="trash">Archive in Papierkorb</option>
|
||||||
|
<option value="delete">Archive löschen</option>
|
||||||
|
</select>
|
||||||
|
<label>Konfliktmodus beim Entpacken</label>
|
||||||
|
<select value={settingsDraft.extractConflictMode} onChange={(event) => setText("extractConflictMode", event.target.value)}>
|
||||||
|
<option value="overwrite">überschreiben</option>
|
||||||
|
<option value="skip">überspringen</option>
|
||||||
|
<option value="rename">umbenennen</option>
|
||||||
|
<option value="ask">nachfragen</option>
|
||||||
|
</select>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="btn accent" onClick={onSaveSettings}>Settings speichern</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{statusToast && <div className="toast">{statusToast}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: DownloadItem[]; onCancel: () => void }): ReactElement {
|
||||||
|
const done = items.filter((item) => item.status === "completed").length;
|
||||||
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||||
|
const total = Math.max(1, items.length);
|
||||||
|
const progress = Math.floor((done / total) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="package-card">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h4>{pkg.name}</h4>
|
||||||
|
<span>{done}/{total} fertig · {failed} Fehler · {cancelled} abgebrochen</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn danger" onClick={onCancel}>Paket abbrechen</button>
|
||||||
|
</header>
|
||||||
|
<div className="progress">
|
||||||
|
<div style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datei</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Fortschritt</th>
|
||||||
|
<th>Speed</th>
|
||||||
|
<th>Retries</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td>{item.fileName}</td>
|
||||||
|
<td title={item.fullStatus}>{item.fullStatus}</td>
|
||||||
|
<td>{item.progressPercent}%</td>
|
||||||
|
<td>{item.speedBps > 0 ? `${Math.floor(item.speedBps / 1024)} KB/s` : "0 KB/s"}</td>
|
||||||
|
<td>{item.retries}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/renderer/index.html
Normal file
12
src/renderer/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Real-Debrid Download Manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
src/renderer/main.tsx
Normal file
15
src/renderer/main.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error("Root element fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
312
src/renderer/styles.css
Normal file
312
src/renderer/styles.css
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: "Segoe UI", "Inter", sans-serif;
|
||||||
|
--bg: #040912;
|
||||||
|
--surface: #0b1424;
|
||||||
|
--card: #101d31;
|
||||||
|
--field: #081120;
|
||||||
|
--border: #233954;
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--muted: #90a4bf;
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--danger: #f43f5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: radial-gradient(circle at 15% 10%, #10203b 0, #050b15 45%, #040912 100%);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto auto 1fr;
|
||||||
|
height: 100%;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-block h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 25px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-block span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-strip {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
background: linear-gradient(180deg, rgba(20, 34, 56, 0.95), rgba(9, 16, 28, 0.95));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons,
|
||||||
|
.speed-config,
|
||||||
|
.link-actions,
|
||||||
|
.input-row,
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: #0d1a2c;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: #12243d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.accent {
|
||||||
|
background: linear-gradient(180deg, #56d6ff, #33b9f4);
|
||||||
|
color: #07111c;
|
||||||
|
border-color: #41c6f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger {
|
||||||
|
border-color: rgba(244, 63, 94, 0.7);
|
||||||
|
color: #fda4af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: #0b1321;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: #14253e;
|
||||||
|
border-color: #2c4e77;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-two {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, rgba(17, 29, 49, 0.95), rgba(9, 16, 28, 0.95));
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card label,
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card input,
|
||||||
|
.card select,
|
||||||
|
.speed-config input,
|
||||||
|
.speed-config select,
|
||||||
|
.speed-config button,
|
||||||
|
.card textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--field);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card textarea {
|
||||||
|
min-height: 220px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-config input {
|
||||||
|
width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-config select {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-view {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, rgba(16, 29, 48, 0.95), rgba(7, 13, 22, 0.95));
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-card header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-card h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-card header span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
margin-top: 8px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #0b1628;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress > div {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #3bc9ff, #22d3ee);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border-bottom: 1px solid rgba(35, 57, 84, 0.7);
|
||||||
|
padding: 7px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
grid-column: span 2;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 18px;
|
||||||
|
background: #0f1f33;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid #2a4f78;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.control-strip {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-two,
|
||||||
|
.settings-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.wide,
|
||||||
|
.settings-actions {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/renderer/vite-env.d.ts
vendored
Normal file
11
src/renderer/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import type { ElectronApi } from "../shared/preload-api";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
rd: ElectronApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
15
src/shared/ipc.ts
Normal file
15
src/shared/ipc.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export const IPC_CHANNELS = {
|
||||||
|
GET_SNAPSHOT: "app:get-snapshot",
|
||||||
|
GET_VERSION: "app:get-version",
|
||||||
|
UPDATE_SETTINGS: "app:update-settings",
|
||||||
|
ADD_LINKS: "queue:add-links",
|
||||||
|
ADD_CONTAINERS: "queue:add-containers",
|
||||||
|
CLEAR_ALL: "queue:clear-all",
|
||||||
|
START: "queue:start",
|
||||||
|
STOP: "queue:stop",
|
||||||
|
TOGGLE_PAUSE: "queue:toggle-pause",
|
||||||
|
CANCEL_PACKAGE: "queue:cancel-package",
|
||||||
|
PICK_FOLDER: "dialog:pick-folder",
|
||||||
|
PICK_CONTAINERS: "dialog:pick-containers",
|
||||||
|
STATE_UPDATE: "state:update"
|
||||||
|
} as const;
|
||||||
17
src/shared/preload-api.ts
Normal file
17
src/shared/preload-api.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { AddLinksPayload, AppSettings, UiSnapshot } from "./types";
|
||||||
|
|
||||||
|
export interface ElectronApi {
|
||||||
|
getSnapshot: () => Promise<UiSnapshot>;
|
||||||
|
getVersion: () => Promise<string>;
|
||||||
|
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
||||||
|
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
||||||
|
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||||
|
clearAll: () => Promise<void>;
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
togglePause: () => Promise<boolean>;
|
||||||
|
cancelPackage: (packageId: string) => Promise<void>;
|
||||||
|
pickFolder: () => Promise<string | null>;
|
||||||
|
pickContainers: () => Promise<string[]>;
|
||||||
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||||
|
}
|
||||||
135
src/shared/types.ts
Normal file
135
src/shared/types.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
export type DownloadStatus =
|
||||||
|
| "queued"
|
||||||
|
| "validating"
|
||||||
|
| "downloading"
|
||||||
|
| "paused"
|
||||||
|
| "reconnect_wait"
|
||||||
|
| "extracting"
|
||||||
|
| "integrity_check"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
export type CleanupMode = "none" | "trash" | "delete";
|
||||||
|
export type ConflictMode = "overwrite" | "skip" | "rename" | "ask";
|
||||||
|
export type SpeedMode = "global" | "per_download";
|
||||||
|
export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done";
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
token: string;
|
||||||
|
rememberToken: boolean;
|
||||||
|
outputDir: string;
|
||||||
|
packageName: string;
|
||||||
|
autoExtract: boolean;
|
||||||
|
extractDir: string;
|
||||||
|
createExtractSubfolder: boolean;
|
||||||
|
hybridExtract: boolean;
|
||||||
|
cleanupMode: CleanupMode;
|
||||||
|
extractConflictMode: ConflictMode;
|
||||||
|
removeLinkFilesAfterExtract: boolean;
|
||||||
|
removeSamplesAfterExtract: boolean;
|
||||||
|
enableIntegrityCheck: boolean;
|
||||||
|
autoResumeOnStart: boolean;
|
||||||
|
autoReconnect: boolean;
|
||||||
|
reconnectWaitSeconds: number;
|
||||||
|
completedCleanupPolicy: FinishedCleanupPolicy;
|
||||||
|
maxParallel: number;
|
||||||
|
speedLimitEnabled: boolean;
|
||||||
|
speedLimitKbps: number;
|
||||||
|
speedLimitMode: SpeedMode;
|
||||||
|
updateRepo: string;
|
||||||
|
autoUpdateCheck: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadItem {
|
||||||
|
id: string;
|
||||||
|
packageId: string;
|
||||||
|
url: string;
|
||||||
|
status: DownloadStatus;
|
||||||
|
retries: number;
|
||||||
|
speedBps: number;
|
||||||
|
downloadedBytes: number;
|
||||||
|
totalBytes: number | null;
|
||||||
|
progressPercent: number;
|
||||||
|
fileName: string;
|
||||||
|
targetPath: string;
|
||||||
|
resumable: boolean;
|
||||||
|
attempts: number;
|
||||||
|
lastError: string;
|
||||||
|
fullStatus: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
outputDir: string;
|
||||||
|
extractDir: string;
|
||||||
|
status: DownloadStatus;
|
||||||
|
itemIds: string[];
|
||||||
|
cancelled: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionState {
|
||||||
|
version: number;
|
||||||
|
packageOrder: string[];
|
||||||
|
packages: Record<string, PackageEntry>;
|
||||||
|
items: Record<string, DownloadItem>;
|
||||||
|
runStartedAt: number;
|
||||||
|
totalDownloadedBytes: number;
|
||||||
|
summaryText: string;
|
||||||
|
reconnectUntil: number;
|
||||||
|
reconnectReason: string;
|
||||||
|
paused: boolean;
|
||||||
|
running: boolean;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadSummary {
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
cancelled: number;
|
||||||
|
extracted: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
averageSpeedBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedPackageInput {
|
||||||
|
name: string;
|
||||||
|
links: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerImportResult {
|
||||||
|
packages: ParsedPackageInput[];
|
||||||
|
source: "dlc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiSnapshot {
|
||||||
|
settings: AppSettings;
|
||||||
|
session: SessionState;
|
||||||
|
summary: DownloadSummary | null;
|
||||||
|
speedText: string;
|
||||||
|
etaText: string;
|
||||||
|
canStart: boolean;
|
||||||
|
canStop: boolean;
|
||||||
|
canPause: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddLinksPayload {
|
||||||
|
rawText: string;
|
||||||
|
packageName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddContainerPayload {
|
||||||
|
filePaths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedHashEntry {
|
||||||
|
fileName: string;
|
||||||
|
algorithm: "crc32" | "md5" | "sha1";
|
||||||
|
digest: string;
|
||||||
|
}
|
||||||
40
tests/cleanup.test.ts
Normal file
40
tests/cleanup.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanup", () => {
|
||||||
|
it("removes archive artifacts but keeps media", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
|
||||||
|
fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
|
||||||
|
|
||||||
|
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||||
|
expect(removed).toBeGreaterThan(0);
|
||||||
|
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes sample artifacts and link files", () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
|
||||||
|
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
|
||||||
|
|
||||||
|
const links = removeDownloadLinkArtifacts(dir);
|
||||||
|
const samples = removeSampleArtifacts(dir);
|
||||||
|
expect(links).toBeGreaterThan(0);
|
||||||
|
expect(samples.files + samples.dirs).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
tests/integrity.test.ts
Normal file
32
tests/integrity.test.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { parseHashLine, validateFileAgainstManifest } from "../src/main/integrity";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("integrity", () => {
|
||||||
|
it("parses md5 and sfv lines", () => {
|
||||||
|
const md = parseHashLine("d41d8cd98f00b204e9800998ecf8427e sample.bin");
|
||||||
|
expect(md?.algorithm).toBe("md5");
|
||||||
|
const sfv = parseHashLine("sample.bin 1A2B3C4D");
|
||||||
|
expect(sfv?.algorithm).toBe("crc32");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates file against md5 manifest", async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
const filePath = path.join(dir, "movie.bin");
|
||||||
|
fs.writeFileSync(filePath, Buffer.from("hello"));
|
||||||
|
fs.writeFileSync(path.join(dir, "hash.md5"), "5d41402abc4b2a76b9719d911017c592 movie.bin\n");
|
||||||
|
const result = await validateFileAgainstManifest(filePath, dir);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
201
tests/self-check.ts
Normal file
201
tests/self-check.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
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 { DownloadManager } from "../src/main/download-manager";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): void {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(`Self-check fehlgeschlagen: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error("Timeout während Self-check");
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDownloadCase(baseDir: string, baseUrl: string, url: string, options?: Partial<ReturnType<typeof defaultSettings>>): Promise<DownloadManager> {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "demo-token",
|
||||||
|
outputDir: path.join(baseDir, "downloads"),
|
||||||
|
extractDir: path.join(baseDir, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: true,
|
||||||
|
reconnectWaitSeconds: 1,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(baseDir, "state")));
|
||||||
|
manager.addPackages([
|
||||||
|
{
|
||||||
|
name: "test-package",
|
||||||
|
links: [url]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-node-self-"));
|
||||||
|
const binary = Buffer.alloc(512 * 1024, 7);
|
||||||
|
let flakyFailures = 1;
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = req.url || "/";
|
||||||
|
if (url.startsWith("/file.bin") || url.startsWith("/slow.bin") || url.startsWith("/rarcancel.bin") || url.startsWith("/flaky.bin")) {
|
||||||
|
if (url.startsWith("/flaky.bin") && flakyFailures > 0) {
|
||||||
|
flakyFailures -= 1;
|
||||||
|
res.statusCode = 503;
|
||||||
|
res.end("retry");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = req.headers.range;
|
||||||
|
let start = 0;
|
||||||
|
if (range) {
|
||||||
|
const match = String(range).match(/bytes=(\d+)-/i);
|
||||||
|
if (match) {
|
||||||
|
start = Number(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const chunk = binary.subarray(start);
|
||||||
|
if (start > 0) {
|
||||||
|
res.statusCode = 206;
|
||||||
|
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
||||||
|
}
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", chunk.length);
|
||||||
|
res.statusCode = res.statusCode || 200;
|
||||||
|
|
||||||
|
if (url.startsWith("/slow.bin") || url.startsWith("/rarcancel.bin")) {
|
||||||
|
const mid = Math.floor(chunk.length / 2);
|
||||||
|
res.write(chunk.subarray(0, mid));
|
||||||
|
setTimeout(() => {
|
||||||
|
res.end(chunk.subarray(mid));
|
||||||
|
}, 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.end(chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end("not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Server konnte nicht gestartet werden");
|
||||||
|
}
|
||||||
|
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
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")) {
|
||||||
|
const body = init?.body;
|
||||||
|
const params = body instanceof URLSearchParams ? body : new URLSearchParams(String(body || ""));
|
||||||
|
const link = params.get("link") || "";
|
||||||
|
const filename = link.includes("rarcancel") ? "release.part1.rar" : "file.bin";
|
||||||
|
const direct = link.includes("slow")
|
||||||
|
? `${baseUrl}/slow.bin`
|
||||||
|
: link.includes("rarcancel")
|
||||||
|
? `${baseUrl}/rarcancel.bin`
|
||||||
|
: link.includes("flaky")
|
||||||
|
? `${baseUrl}/flaky.bin`
|
||||||
|
: `${baseUrl}/file.bin`;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
download: direct,
|
||||||
|
filename,
|
||||||
|
filesize: binary.length
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager1 = await runDownloadCase(tempRoot, baseUrl, "https://dummy/file");
|
||||||
|
const snapshot1 = manager1.getSnapshot();
|
||||||
|
const item1 = Object.values(snapshot1.session.items)[0];
|
||||||
|
assert(item1?.status === "completed", "normaler Download wurde nicht abgeschlossen");
|
||||||
|
assert(fs.existsSync(item1.targetPath), "Datei fehlt nach Download");
|
||||||
|
|
||||||
|
const manager2 = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "demo-token",
|
||||||
|
outputDir: path.join(tempRoot, "downloads-pause"),
|
||||||
|
extractDir: path.join(tempRoot, "extract-pause"),
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(tempRoot, "state-pause"))
|
||||||
|
);
|
||||||
|
manager2.addPackages([{ name: "pause", links: ["https://dummy/slow"] }]);
|
||||||
|
manager2.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||||
|
const paused = manager2.togglePause();
|
||||||
|
assert(paused, "Pause konnte nicht aktiviert werden");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
manager2.togglePause();
|
||||||
|
await waitFor(() => !manager2.getSnapshot().session.running, 30000);
|
||||||
|
const item2 = Object.values(manager2.getSnapshot().session.items)[0];
|
||||||
|
assert(item2?.status === "completed", "Pause/Resume Download nicht abgeschlossen");
|
||||||
|
|
||||||
|
const manager3 = await runDownloadCase(tempRoot, baseUrl, "https://dummy/flaky", { autoReconnect: true, reconnectWaitSeconds: 1 });
|
||||||
|
const item3 = Object.values(manager3.getSnapshot().session.items)[0];
|
||||||
|
assert(item3?.status === "completed", "Reconnect-Fall nicht abgeschlossen");
|
||||||
|
|
||||||
|
const manager4 = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "demo-token",
|
||||||
|
outputDir: path.join(tempRoot, "downloads-cancel"),
|
||||||
|
extractDir: path.join(tempRoot, "extract-cancel"),
|
||||||
|
autoExtract: false
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(tempRoot, "state-cancel"))
|
||||||
|
);
|
||||||
|
manager4.addPackages([{ name: "cancel", links: ["https://dummy/rarcancel"] }]);
|
||||||
|
manager4.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
const pkgId = manager4.getSnapshot().session.packageOrder[0];
|
||||||
|
manager4.cancelPackage(pkgId);
|
||||||
|
await waitFor(() => !manager4.getSnapshot().session.running || Object.values(manager4.getSnapshot().session.items).every((item) => item.status !== "downloading"), 15000);
|
||||||
|
const cancelSnapshot = manager4.getSnapshot();
|
||||||
|
const cancelItem = Object.values(cancelSnapshot.session.items)[0];
|
||||||
|
assert(cancelItem?.status === "cancelled" || cancelItem?.status === "queued", "Paketabbruch nicht wirksam");
|
||||||
|
const packageDir = path.join(path.join(tempRoot, "downloads-cancel"), "cancel");
|
||||||
|
assert(!fs.existsSync(path.join(packageDir, "release.part1.rar")), "RAR-Artefakt wurde nicht gelöscht");
|
||||||
|
|
||||||
|
console.log("Node self-check erfolgreich");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
33
tests/utils.test.ts
Normal file
33
tests/utils.test.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parsePackagesFromLinksText, isHttpLink, sanitizeFilename, formatEta } from "../src/main/utils";
|
||||||
|
|
||||||
|
describe("utils", () => {
|
||||||
|
it("validates http links", () => {
|
||||||
|
expect(isHttpLink("https://example.com/file")).toBe(true);
|
||||||
|
expect(isHttpLink("http://example.com/file")).toBe(true);
|
||||||
|
expect(isHttpLink("ftp://example.com")).toBe(false);
|
||||||
|
expect(isHttpLink("foo bar")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes filenames", () => {
|
||||||
|
expect(sanitizeFilename("foo/bar:baz*")).toBe("foo bar baz");
|
||||||
|
expect(sanitizeFilename(" ")).toBe("Paket");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses package markers", () => {
|
||||||
|
const parsed = parsePackagesFromLinksText(
|
||||||
|
"# package: A\nhttps://a.com/1\nhttps://a.com/2\n# package: B\nhttps://b.com/1\n",
|
||||||
|
"Default"
|
||||||
|
);
|
||||||
|
expect(parsed).toHaveLength(2);
|
||||||
|
expect(parsed[0].name).toBe("A");
|
||||||
|
expect(parsed[0].links).toHaveLength(2);
|
||||||
|
expect(parsed[1].name).toBe("B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats eta", () => {
|
||||||
|
expect(formatEta(-1)).toBe("--");
|
||||||
|
expect(formatEta(65)).toBe("01:05");
|
||||||
|
expect(formatEta(3661)).toBe("01:01:01");
|
||||||
|
});
|
||||||
|
});
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": ["node", "vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "tests", "vite.config.ts"]
|
||||||
|
}
|
||||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: path.resolve(__dirname, "src/renderer"),
|
||||||
|
publicDir: path.resolve(__dirname, "assets"),
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(__dirname, "build/renderer"),
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
});
|
||||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
globals: true
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user