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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller pillow
|
||||
run: npm ci
|
||||
|
||||
- name: Prepare release metadata
|
||||
- name: Apply tag version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ github.ref_name }}".TrimStart('v')
|
||||
python scripts/set_version.py $version
|
||||
python scripts/prepare_icon.py
|
||||
node scripts/set_version_node.mjs $version
|
||||
|
||||
- name: Build exe
|
||||
run: |
|
||||
pyinstaller --noconfirm --windowed --onedir --name "Real-Debrid-Downloader" --icon "assets/app_icon.ico" real_debrid_downloader_gui.py
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
|
||||
- name: Pack release zip
|
||||
- name: Build Windows artifacts
|
||||
run: npm run release:win
|
||||
|
||||
- name: Pack portable zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path release -Force | Out-Null
|
||||
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"
|
||||
Compress-Archive -Path "release\win-unpacked\*" -DestinationPath "Real-Debrid-Downloader-win64.zip" -Force
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
Real-Debrid-Downloader-win64.zip
|
||||
release/Real-Debrid-Downloader-Setup-*.exe
|
||||
release/*.exe
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -16,3 +16,10 @@ rd_downloader.log
|
||||
rd_download_manifest.json
|
||||
_update_staging/
|
||||
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,
|
||||
ueber Real-Debrid zu unrestricten und direkt auf deinen PC zu laden.
|
||||
Desktop-App auf **Node.js + Electron + React + TypeScript** mit JDownloader-Style Workflow, optimiert fuer Real-Debrid.
|
||||
|
||||
## Features
|
||||
## Highlights
|
||||
|
||||
- Mehrere Links auf einmal (ein Link pro Zeile)
|
||||
- DLC Import (`.dlc`) ueber dcrypt.it inklusive Paket-Gruppierung
|
||||
- DLC Drag-and-Drop: `.dlc` direkt in den Links-Bereich ziehen
|
||||
- Nutzt die Real-Debrid API (`/unrestrict/link`)
|
||||
- Download-Status pro Link
|
||||
- Paket-Ansicht: Paket ist aufklappbar, darunter alle Einzel-Links
|
||||
- Laufende Pakete koennen per Rechtsklick direkt abgebrochen/entfernt werden
|
||||
- Download-Speed pro Link und gesamt
|
||||
- Gesamt-Fortschritt
|
||||
- Download-Ordner und Paketname waehlbar
|
||||
- Einstellbare Parallel-Downloads (z. B. 20 gleichzeitig)
|
||||
- Parallel-Wert kann waehrend laufender Downloads live angepasst werden
|
||||
- Retry-Counter pro Link in der Tabelle
|
||||
- Automatisches Entpacken nach dem Download
|
||||
- Hybrid-Entpacken: entpackt sofort, sobald ein Archivsatz komplett 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
|
||||
- Modernes, dunkles UI mit Header-Steuerung (Start, Pause, Stop, Speed, ETA)
|
||||
- Tabs: **Linksammler**, **Downloads**, **Settings**
|
||||
- Paketbasierte Queue mit Datei-Status, Progress, Speed, Retries
|
||||
- Paket-Abbruch waehrend laufender Downloads inklusive sicherem Archiv-Cleanup
|
||||
- `.dlc` Import (Dateidialog und Drag-and-Drop)
|
||||
- Session-Persistenz (robustes JSON-State-Management)
|
||||
- Auto-Resume beim Start (optional)
|
||||
- Reconnect-Basislogik (429/503, Wartefenster, resumable priorisiert)
|
||||
- Integritaetscheck (SFV/CRC32/MD5/SHA1) nach Download
|
||||
- Auto-Retry bei Integritaetsfehlern
|
||||
- Cleanup-Trigger fuer fertige Tasks:
|
||||
- Nie
|
||||
- Sofort
|
||||
- Beim App-Start
|
||||
- Sobald Paket fertig ist
|
||||
|
||||
## Voraussetzung
|
||||
## Voraussetzungen
|
||||
|
||||
- Python 3.10+
|
||||
- Optional, aber empfohlen: 7-Zip im PATH fuer RAR/7Z-Entpackung
|
||||
- Alternative fuer RAR: WinRAR `UnRAR.exe` (wird automatisch erkannt)
|
||||
- Node.js 20+ (empfohlen 22+)
|
||||
- Windows 10/11 (fuer Release-Build)
|
||||
- Optional: 7-Zip/UnRAR fuer RAR/7Z Entpacken
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
npm install
|
||||
```
|
||||
|
||||
## Start
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
python real_debrid_downloader_gui.py
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Nutzung
|
||||
|
||||
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)
|
||||
## Build
|
||||
|
||||
```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`
|
||||
- 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:
|
||||
## Start (Production lokal)
|
||||
|
||||
```bash
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
npm run start
|
||||
```
|
||||
|
||||
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