Migrate app to Node Electron with modern React UI
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-02-27 03:25:56 +01:00
parent 56e4355d6b
commit b96ed1eb7a
35 changed files with 13180 additions and 119 deletions

View File

@ -16,50 +16,37 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Python - name: Setup Node
uses: actions/setup-python@v5 uses: actions/setup-node@v4
with: with:
python-version: "3.11" node-version: "22"
cache: "npm"
- name: Install dependencies - name: Install dependencies
run: | run: npm ci
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller pillow
- name: Prepare release metadata - name: Apply tag version
shell: pwsh shell: pwsh
run: | run: |
$version = "${{ github.ref_name }}".TrimStart('v') $version = "${{ github.ref_name }}".TrimStart('v')
python scripts/set_version.py $version node scripts/set_version_node.mjs $version
python scripts/prepare_icon.py
- name: Build exe - name: Build app
run: | run: npm run build
pyinstaller --noconfirm --windowed --onedir --name "Real-Debrid-Downloader" --icon "assets/app_icon.ico" real_debrid_downloader_gui.py
- name: Pack release zip - name: Build Windows artifacts
run: npm run release:win
- name: Pack portable zip
shell: pwsh shell: pwsh
run: | run: |
New-Item -ItemType Directory -Path release -Force | Out-Null Compress-Archive -Path "release\win-unpacked\*" -DestinationPath "Real-Debrid-Downloader-win64.zip" -Force
Compress-Archive -Path "dist/Real-Debrid-Downloader/*" -DestinationPath "Real-Debrid-Downloader-win64.zip" -Force
- name: Install Inno Setup
shell: pwsh
run: |
choco install innosetup --no-progress -y
- name: Build installer
shell: pwsh
run: |
$version = "${{ github.ref_name }}".TrimStart('v')
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "/DMyAppVersion=$version" "/DMySourceDir=..\\dist\\Real-Debrid-Downloader" "/DMyOutputDir=..\\release" "/DMyIconFile=..\\assets\\app_icon.ico" "installer\\RealDebridDownloader.iss"
- name: Publish GitHub Release - name: Publish GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: | files: |
Real-Debrid-Downloader-win64.zip Real-Debrid-Downloader-win64.zip
release/Real-Debrid-Downloader-Setup-*.exe release/*.exe
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

7
.gitignore vendored
View File

@ -16,3 +16,10 @@ rd_downloader.log
rd_download_manifest.json rd_download_manifest.json
_update_staging/ _update_staging/
apply_update.cmd apply_update.cmd
node_modules/
.vite/
coverage/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

149
README.md
View File

@ -1,116 +1,83 @@
# Real-Debrid Downloader GUI # Real-Debrid Download Manager (Node/Electron)
Kleine Desktop-App mit GUI (Tkinter), um mehrere Links (z. B. 20+) einzufuegen, Desktop-App auf **Node.js + Electron + React + TypeScript** mit JDownloader-Style Workflow, optimiert fuer Real-Debrid.
ueber Real-Debrid zu unrestricten und direkt auf deinen PC zu laden.
## Features ## Highlights
- Mehrere Links auf einmal (ein Link pro Zeile) - Modernes, dunkles UI mit Header-Steuerung (Start, Pause, Stop, Speed, ETA)
- DLC Import (`.dlc`) ueber dcrypt.it inklusive Paket-Gruppierung - Tabs: **Linksammler**, **Downloads**, **Settings**
- DLC Drag-and-Drop: `.dlc` direkt in den Links-Bereich ziehen - Paketbasierte Queue mit Datei-Status, Progress, Speed, Retries
- Nutzt die Real-Debrid API (`/unrestrict/link`) - Paket-Abbruch waehrend laufender Downloads inklusive sicherem Archiv-Cleanup
- Download-Status pro Link - `.dlc` Import (Dateidialog und Drag-and-Drop)
- Paket-Ansicht: Paket ist aufklappbar, darunter alle Einzel-Links - Session-Persistenz (robustes JSON-State-Management)
- Laufende Pakete koennen per Rechtsklick direkt abgebrochen/entfernt werden - Auto-Resume beim Start (optional)
- Download-Speed pro Link und gesamt - Reconnect-Basislogik (429/503, Wartefenster, resumable priorisiert)
- Gesamt-Fortschritt - Integritaetscheck (SFV/CRC32/MD5/SHA1) nach Download
- Download-Ordner und Paketname waehlbar - Auto-Retry bei Integritaetsfehlern
- Einstellbare Parallel-Downloads (z. B. 20 gleichzeitig) - Cleanup-Trigger fuer fertige Tasks:
- Parallel-Wert kann waehrend laufender Downloads live angepasst werden - Nie
- Retry-Counter pro Link in der Tabelle - Sofort
- Automatisches Entpacken nach dem Download - Beim App-Start
- Hybrid-Entpacken: entpackt sofort, sobald ein Archivsatz komplett ist - Sobald Paket fertig ist
- Optionales Auto-Cleanup: Archivteile nach erfolgreichem Entpacken loeschen
- Speed-Limit (global oder pro Download), live aenderbar
- Linklisten als `.txt` speichern/laden
- DLC-Dateien als Paketliste importieren (`DLC import`)
- `Entpacken nach` + optional `Unterordner erstellen (Paketname)` wie bei JDownloader
- `Settings` (JDownloader-Style):
- Nach erfolgreichem Entpacken: keine / Papierkorb / unwiderruflich loeschen
- Bei Konflikten: ueberschreiben / ueberspringen / umbenennen
- ZIP-Passwort-Check mit `serienfans.org` und `serienjunkies.net`
- Multi-Part-RAR wird ueber `part1` entpackt (nur wenn alle Parts vorhanden sind)
- Auto-Update Check ueber GitHub Releases (fuer .exe)
- Optionales lokales Speichern vom API Token
## Voraussetzung ## Voraussetzungen
- Python 3.10+ - Node.js 20+ (empfohlen 22+)
- Optional, aber empfohlen: 7-Zip im PATH fuer RAR/7Z-Entpackung - Windows 10/11 (fuer Release-Build)
- Alternative fuer RAR: WinRAR `UnRAR.exe` (wird automatisch erkannt) - Optional: 7-Zip/UnRAR fuer RAR/7Z Entpacken
## Installation ## Installation
```bash ```bash
python -m venv .venv npm install
.venv\Scripts\activate
pip install -r requirements.txt
``` ```
## Start ## Entwicklung
```bash ```bash
python real_debrid_downloader_gui.py npm run dev
``` ```
## Nutzung ## Build
1. API Token von Real-Debrid eintragen (`https://real-debrid.com/apitoken`)
2. Download-Ordner waehlen
3. Optional Paketname setzen (sonst wird automatisch einer erzeugt)
4. Optional Entpack-Ordner waehlen (`Entpacken nach`)
5. Optional `Unterordner erstellen (Paketname)` aktiv lassen
6. Optional `Hybrid-Entpacken` und `Cleanup` setzen
7. Parallel-Wert setzen (z. B. 20)
8. Optional Speed-Limit setzen (KB/s, Modus `global` oder `per_download`)
9. Links einfuegen oder per `Links laden` / `DLC import` importieren
10. `Download starten` klicken
Wenn du 20 Links einfuegst, werden sie als ein Paket behandelt. Downloads landen in einem Paketordner. Beim Entpacken kann derselbe Paketname automatisch als Unterordner genutzt werden.
Bei DLC-Import mit vielen Paketen setzt die App automatisch Paketmarker (`# package: ...`) und verarbeitet die Pakete in einer Queue.
## Auto-Update (GitHub)
1. Standard-Repo ist bereits gesetzt: `Sucukdeluxe/real-debrid-downloader`
2. Optional kannst du es in der App mit `GitHub Repo (owner/name)` ueberschreiben
3. Klicke `Update suchen` oder aktiviere `Beim Start auf Updates pruefen`
4. In der .exe wird ein neues Release heruntergeladen und beim Neustart installiert
Hinweis: Beim Python-Skript gibt es nur einen Release-Hinweis, kein Self-Replace.
## Release Build (.exe)
```bash ```bash
./build_exe.ps1 -Version 1.1.0 npm run build
``` ```
Danach liegt die App unter `dist/Real-Debrid-Downloader/`. Danach liegen die Artefakte in:
## GitHub Release Workflow - `build/main`
- `build/renderer`
- Workflow-Datei: `.github/workflows/release.yml` ## Start (Production lokal)
- Bei Tag-Push wie `v1.0.1` wird automatisch eine Windows-EXE gebaut
- Release-Asset fuer Auto-Update: `Real-Debrid-Downloader-win64.zip`
- Zusaetzlich wird ein Installer gebaut: `Real-Debrid-Downloader-Setup-<version>.exe`
- Installer legt automatisch eine Desktop-Verknuepfung an
## Auto-Installer
- Im GitHub Release findest du direkt die Setup-Datei (`...Setup-<version>.exe`)
- Setup installiert die App unter `Programme/Real-Debrid Downloader`
- Setup erstellt automatisch eine Desktop-Verknuepfung mit App-Icon
## App-Icon
- Das Projekt nutzt `assets/app_icon.png` (aus deinem aktuellen Downloads-Icon)
- Beim Build wird automatisch `assets/app_icon.ico` erzeugt
Beispiel:
```bash ```bash
git tag v1.0.1 npm run start
git push origin v1.0.1
``` ```
Hinweis: Die App kann nur Links laden, die von Real-Debrid unterstuetzt werden. ## Tests
```bash
npm test
npm run self-check
```
- `npm test`: Unit-Tests fuer Parser/Cleanup/Integrity
- `npm run self-check`: End-to-End-Checks mit lokalem Mock-Server (Queue, Pause/Resume, Reconnect, Paket-Cancel)
## Projektstruktur
- `src/main`: Electron Main Process + Download/Queue Logik
- `src/preload`: sichere IPC Bridge
- `src/renderer`: React UI
- `src/shared`: gemeinsame Typen und IPC-Channel
- `tests`: Unit- und Self-Check Tests
## Hinweise
- Runtime-Dateien liegen im Electron `userData` Verzeichnis:
- `rd_downloader_config.json`
- `rd_session_state.json`
- `rd_downloader.log`
- Die bisherige Python-Datei bleibt vorerst als Legacy-Referenz im Repo, die aktive App ist jetzt Node/Electron.

9654
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
package.json Normal file
View 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
}
}
}

View 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
View 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
View 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
View 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
View 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(/&quot;/g, '"').replace(/&amp;/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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
globals: true
}
});