Migrate app to Node Electron with modern React UI
Build and Release / build (push) Has been cancelled
Build and Release / build (push) Has been cancelled
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./constants";
|
||||
import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils";
|
||||
import { ParsedPackageInput } from "../shared/types";
|
||||
|
||||
function decodeDcryptPayload(responseText: string): unknown {
|
||||
let text = String(responseText || "").trim();
|
||||
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
|
||||
if (m) {
|
||||
text = m[1].replace(/"/g, '"').replace(/&/g, "&").trim();
|
||||
}
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrlsRecursive(data: unknown): string[] {
|
||||
if (typeof data === "string") {
|
||||
const found = data.match(/https?:\/\/[^\s"'<>]+/gi) ?? [];
|
||||
return uniquePreserveOrder(found.filter((url) => isHttpLink(url)));
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return uniquePreserveOrder(data.flatMap((item) => extractUrlsRecursive(item)));
|
||||
}
|
||||
if (data && typeof data === "object") {
|
||||
return uniquePreserveOrder(Object.values(data as Record<string, unknown>).flatMap((value) => extractUrlsRecursive(value)));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function groupLinksByName(links: string[]): ParsedPackageInput[] {
|
||||
const unique = uniquePreserveOrder(links.filter((link) => isHttpLink(link)));
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const link of unique) {
|
||||
const name = sanitizeFilename(inferPackageNameFromLinks([link]) || "Paket");
|
||||
const current = grouped.get(name) ?? [];
|
||||
current.push(link);
|
||||
grouped.set(name, current);
|
||||
}
|
||||
return Array.from(grouped.entries()).map(([name, packageLinks]) => ({ name, links: packageLinks }));
|
||||
}
|
||||
|
||||
function extractPackagesFromPayload(payload: unknown): ParsedPackageInput[] {
|
||||
const urls = extractUrlsRecursive(payload);
|
||||
if (urls.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return groupLinksByName(urls);
|
||||
}
|
||||
|
||||
function decryptRcPayload(base64Rc: string): Buffer {
|
||||
const rcBytes = Buffer.from(base64Rc, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", DLC_AES_KEY, DLC_AES_IV);
|
||||
decipher.setAutoPadding(false);
|
||||
return Buffer.concat([decipher.update(rcBytes), decipher.final()]);
|
||||
}
|
||||
|
||||
function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
||||
const packages: ParsedPackageInput[] = [];
|
||||
const packageRegex = /<package\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi;
|
||||
|
||||
for (let m = packageRegex.exec(xml); m; m = packageRegex.exec(xml)) {
|
||||
const encodedName = m[1] || "";
|
||||
const packageBody = m[2] || "";
|
||||
let packageName = "";
|
||||
if (encodedName) {
|
||||
try {
|
||||
packageName = Buffer.from(encodedName, "base64").toString("utf8");
|
||||
} catch {
|
||||
packageName = encodedName;
|
||||
}
|
||||
}
|
||||
|
||||
const links: string[] = [];
|
||||
const urlRegex = /<url>(.*?)<\/url>/gi;
|
||||
for (let um = urlRegex.exec(packageBody); um; um = urlRegex.exec(packageBody)) {
|
||||
try {
|
||||
const url = Buffer.from((um[1] || "").trim(), "base64").toString("utf8").trim();
|
||||
if (isHttpLink(url)) {
|
||||
links.push(url);
|
||||
}
|
||||
} catch {
|
||||
// skip broken entries
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueLinks = uniquePreserveOrder(links);
|
||||
if (uniqueLinks.length > 0) {
|
||||
packages.push({
|
||||
name: sanitizeFilename(packageName || inferPackageNameFromLinks(uniqueLinks) || `Paket-${packages.length + 1}`),
|
||||
links: uniqueLinks
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]> {
|
||||
const content = fs.readFileSync(filePath, "ascii").trim();
|
||||
if (content.length < 89) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dlcKey = content.slice(-88);
|
||||
const dlcData = content.slice(0, -88);
|
||||
|
||||
const rcUrl = DLC_SERVICE_URL.replace("{KEY}", encodeURIComponent(dlcKey));
|
||||
const rcResponse = await fetch(rcUrl, { method: "GET" });
|
||||
if (!rcResponse.ok) {
|
||||
return [];
|
||||
}
|
||||
const rcText = await rcResponse.text();
|
||||
const rcMatch = rcText.match(/<rc>(.*?)<\/rc>/i);
|
||||
if (!rcMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const realKey = decryptRcPayload(rcMatch[1]).subarray(0, 16);
|
||||
const encrypted = Buffer.from(dlcData, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-128-cbc", realKey, realKey);
|
||||
decipher.setAutoPadding(false);
|
||||
let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
|
||||
const pad = decrypted[decrypted.length - 1];
|
||||
if (pad > 0 && pad <= 16) {
|
||||
decrypted = decrypted.subarray(0, decrypted.length - pad);
|
||||
}
|
||||
|
||||
const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8");
|
||||
return parsePackagesFromDlcXml(xmlData);
|
||||
}
|
||||
|
||||
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
|
||||
const fileName = path.basename(filePath);
|
||||
const blob = new Blob([fs.readFileSync(filePath)]);
|
||||
const form = new FormData();
|
||||
form.set("dlcfile", blob, fileName);
|
||||
|
||||
const response = await fetch(DCRYPT_UPLOAD_URL, {
|
||||
method: "POST",
|
||||
body: form
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(compactErrorText(text));
|
||||
}
|
||||
const payload = decodeDcryptPayload(text);
|
||||
let packages = extractPackagesFromPayload(payload);
|
||||
if (packages.length === 1) {
|
||||
const regrouped = groupLinksByName(packages[0].links);
|
||||
if (regrouped.length > 1) {
|
||||
packages = regrouped;
|
||||
}
|
||||
}
|
||||
if (packages.length === 0) {
|
||||
packages = groupLinksByName(extractUrlsRecursive(text));
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> {
|
||||
const out: ParsedPackageInput[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
if (path.extname(filePath).toLowerCase() !== ".dlc") {
|
||||
continue;
|
||||
}
|
||||
let packages: ParsedPackageInput[] = [];
|
||||
try {
|
||||
packages = await decryptDlcLocal(filePath);
|
||||
} catch {
|
||||
packages = [];
|
||||
}
|
||||
if (packages.length === 0) {
|
||||
packages = await decryptDlcViaDcrypt(filePath);
|
||||
}
|
||||
out.push(...packages);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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` };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user