Migrate app to Node Electron with modern React UI
Build and Release / build (push) Has been cancelled

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
+106
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
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
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
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;
}
+846
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
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
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
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
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
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
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
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
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")}`;
}