diff --git a/package.json b/package.json
index 93d9c55..1498070 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
- "version": "1.3.1",
+ "version": "1.3.2",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts
index b7cb185..3b9f74c 100644
--- a/src/main/app-controller.ts
+++ b/src/main/app-controller.ts
@@ -126,6 +126,30 @@ export class AppController {
this.manager.cancelPackage(packageId);
}
+ public renamePackage(packageId: string, newName: string): void {
+ this.manager.renamePackage(packageId, newName);
+ }
+
+ public reorderPackages(packageIds: string[]): void {
+ this.manager.reorderPackages(packageIds);
+ }
+
+ public removeItem(itemId: string): void {
+ this.manager.removeItem(itemId);
+ }
+
+ public togglePackage(packageId: string): void {
+ this.manager.togglePackage(packageId);
+ }
+
+ public exportQueue(): string {
+ return this.manager.exportQueue();
+ }
+
+ public importQueue(json: string): { addedPackages: number; addedLinks: number } {
+ return this.manager.importQueue(json);
+ }
+
public shutdown(): void {
this.manager.stop();
this.megaWebFallback.dispose();
diff --git a/src/main/constants.ts b/src/main/constants.ts
index 735ca9f..9886050 100644
--- a/src/main/constants.ts
+++ b/src/main/constants.ts
@@ -58,6 +58,10 @@ export function defaultSettings(): AppSettings {
speedLimitKbps: 0,
speedLimitMode: "global",
updateRepo: DEFAULT_UPDATE_REPO,
- autoUpdateCheck: true
+ autoUpdateCheck: true,
+ clipboardWatch: false,
+ minimizeToTray: false,
+ theme: "dark" as const,
+ bandwidthSchedules: []
};
}
diff --git a/src/main/debrid.ts b/src/main/debrid.ts
index 1f310ae..6866466 100644
--- a/src/main/debrid.ts
+++ b/src/main/debrid.ts
@@ -141,6 +141,71 @@ function decodeHtmlEntities(text: string): string {
.replace(/>/g, ">");
}
+function safeDecode(value: string): string {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function looksLikeFileName(value: string): boolean {
+ return /\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub)$/i.test(value);
+}
+
+function normalizeResolvedFilename(value: string): string {
+ const candidate = decodeHtmlEntities(String(value || ""))
+ .replace(/<[^>]*>/g, " ")
+ .replace(/\s+/g, " ")
+ .replace(/^['"]+|['"]+$/g, "")
+ .replace(/^download\s+file\s+/i, "")
+ .replace(/\s*[-|]\s*rapidgator.*$/i, "")
+ .trim();
+ if (!candidate || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) {
+ return "";
+ }
+ return candidate;
+}
+
+function filenameFromRapidgatorUrlPath(link: string): string {
+ try {
+ const parsed = new URL(link);
+ const pathParts = parsed.pathname.split("/").filter(Boolean);
+ for (let index = pathParts.length - 1; index >= 0; index -= 1) {
+ const raw = safeDecode(pathParts[index]).replace(/\.html?$/i, "").trim();
+ const normalized = normalizeResolvedFilename(raw);
+ if (normalized) {
+ return normalized;
+ }
+ }
+ return "";
+ } catch {
+ return "";
+ }
+}
+
+function extractRapidgatorFilenameFromHtml(html: string): string {
+ const patterns = [
+ /]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
+ /]+name=["']title["'][^>]+content=["']([^"']+)["']/i,
+ /
([^<]+)<\/title>/i,
+ /(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*(items: T[], concurrency: number, worker: (item: T) => Promise): Promise {
if (items.length === 0) {
return;
@@ -161,31 +226,43 @@ async function resolveRapidgatorFilename(link: string): Promise {
if (!isRapidgatorLink(link)) {
return "";
}
- try {
- const response = await fetch(link, {
- method: "GET",
- headers: {
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
- }
- });
- if (!response.ok) {
- return "";
- }
- const html = await response.text();
- const titleMatch = html.match(/([^<]+)<\/title>/i);
- const title = decodeHtmlEntities((titleMatch?.[1] || "").trim());
- if (!title) {
- return "";
- }
- const preferred = title.match(/download\s+file\s+(.+)$/i)?.[1]?.trim() || title;
- if (!preferred) {
- return "";
- }
- const withoutSuffix = preferred.replace(/\s*-\s*rapidgator.*$/i, "").trim();
- return withoutSuffix;
- } catch {
- return "";
+ const fromUrl = filenameFromRapidgatorUrlPath(link);
+ if (fromUrl) {
+ return fromUrl;
}
+
+ for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) {
+ try {
+ const response = await fetch(link, {
+ method: "GET",
+ headers: {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "en-US,en;q=0.9,de;q=0.8"
+ }
+ });
+ if (!response.ok) {
+ if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
+ await sleep(retryDelay(attempt));
+ continue;
+ }
+ return "";
+ }
+ const html = await response.text();
+ const fromHtml = extractRapidgatorFilenameFromHtml(html);
+ if (fromHtml) {
+ return fromHtml;
+ }
+ } catch {
+ // retry below
+ }
+
+ if (attempt < REQUEST_RETRIES + 2) {
+ await sleep(retryDelay(attempt));
+ }
+ }
+
+ return "";
}
function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] {
@@ -492,7 +569,7 @@ export class DebridService {
}
const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link));
- await runWithConcurrency(remaining, 6, async (link) => {
+ await runWithConcurrency(remaining, 3, async (link) => {
const fromPage = await resolveRapidgatorFilename(link);
if (fromPage && !looksLikeOpaqueFilename(fromPage)) {
clean.set(link, fromPage);
@@ -523,8 +600,16 @@ export class DebridService {
try {
const result = await this.unrestrictViaProvider(provider, link);
+ let fileName = result.fileName;
+ if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
+ const fromPage = await resolveRapidgatorFilename(link);
+ if (fromPage) {
+ fileName = fromPage;
+ }
+ }
return {
...result,
+ fileName,
provider,
providerLabel: PROVIDER_LABELS[provider]
};
diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts
index 2be2b88..4dabed5 100644
--- a/src/main/download-manager.ts
+++ b/src/main/download-manager.ts
@@ -3,8 +3,8 @@ 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 { AppSettings, DownloadItem, DownloadStats, DownloadSummary, DownloadStatus, PackageEntry, ParsedPackageInput, SessionState, UiSnapshot } from "../shared/types";
+import { REQUEST_RETRIES } from "./constants";
import { cleanupCancelledPackageArtifactsAsync } from "./cleanup";
import { DebridService, MegaWebUnrestrictor } from "./debrid";
import { extractPackageArchives } from "./extractor";
@@ -17,7 +17,7 @@ type ActiveTask = {
itemId: string;
packageId: string;
abortController: AbortController;
- abortReason: "stop" | "cancel" | "reconnect" | "none";
+ abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "none";
resumable: boolean;
speedEvents: Array<{ at: number; bytes: number }>;
nonResumableCounted: boolean;
@@ -43,6 +43,35 @@ function parseContentRangeTotal(contentRange: string | null): number | null {
return Number.isFinite(value) ? value : null;
}
+function parseContentDispositionFilename(contentDisposition: string | null): string {
+ if (!contentDisposition) {
+ return "";
+ }
+
+ const encodedMatch = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i);
+ if (encodedMatch?.[1]) {
+ let value = encodedMatch[1].trim();
+ value = value.replace(/^UTF-8''/i, "");
+ value = value.replace(/^['"]+|['"]+$/g, "");
+ try {
+ const decoded = decodeURIComponent(value).trim();
+ if (decoded) {
+ return decoded;
+ }
+ } catch {
+ if (value) {
+ return value;
+ }
+ }
+ }
+
+ const plainMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i);
+ if (!plainMatch?.[1]) {
+ return "";
+ }
+ return plainMatch[1].trim().replace(/^['"]+|['"]+$/g, "");
+}
+
function canRetryStatus(status: number): boolean {
return status === 429 || status >= 500;
}
@@ -186,18 +215,189 @@ export class DownloadManager extends EventEmitter {
const remaining = totalItems - doneItems;
const eta = remaining > 0 && rate > 0 ? remaining / rate : -1;
+ const reconnectMs = Math.max(0, this.session.reconnectUntil - now);
+
return {
settings: this.settings,
session: this.getSession(),
summary: this.summary,
+ stats: this.getStats(),
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`,
canStart: !this.session.running,
canStop: this.session.running,
- canPause: this.session.running
+ canPause: this.session.running,
+ clipboardActive: this.settings.clipboardWatch,
+ reconnectSeconds: Math.ceil(reconnectMs / 1000)
};
}
+ public getStats(): DownloadStats {
+ let totalDownloaded = 0;
+ let totalFiles = 0;
+ for (const item of Object.values(this.session.items)) {
+ if (item.status === "completed") {
+ totalDownloaded += item.downloadedBytes;
+ totalFiles += 1;
+ }
+ }
+
+ if (this.session.running) {
+ let visibleRunBytes = 0;
+ for (const itemId of this.runItemIds) {
+ const item = this.session.items[itemId];
+ if (item) {
+ visibleRunBytes += item.downloadedBytes;
+ }
+ }
+ totalDownloaded += Math.max(0, this.session.totalDownloadedBytes - visibleRunBytes);
+ } else {
+ totalDownloaded = Math.max(totalDownloaded, this.session.totalDownloadedBytes);
+ }
+
+ return {
+ totalDownloaded,
+ totalFiles,
+ totalPackages: Object.keys(this.session.packages).length,
+ sessionStartedAt: this.session.runStartedAt
+ };
+ }
+
+ public renamePackage(packageId: string, newName: string): void {
+ const pkg = this.session.packages[packageId];
+ if (!pkg) {
+ return;
+ }
+ pkg.name = sanitizeFilename(newName) || pkg.name;
+ pkg.updatedAt = nowMs();
+ this.persistSoon();
+ this.emitState(true);
+ }
+
+ public reorderPackages(packageIds: string[]): void {
+ const valid = packageIds.filter((id) => this.session.packages[id]);
+ const remaining = this.session.packageOrder.filter((id) => !valid.includes(id));
+ this.session.packageOrder = [...valid, ...remaining];
+ this.persistSoon();
+ this.emitState(true);
+ }
+
+ public removeItem(itemId: string): void {
+ const item = this.session.items[itemId];
+ if (!item) {
+ return;
+ }
+ this.recordRunOutcome(itemId, "cancelled");
+ const active = this.activeTasks.get(itemId);
+ if (active) {
+ active.abortReason = "cancel";
+ active.abortController.abort("cancel");
+ }
+ const pkg = this.session.packages[item.packageId];
+ if (pkg) {
+ pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
+ if (pkg.itemIds.length === 0) {
+ this.removePackageFromSession(item.packageId, []);
+ } else {
+ pkg.updatedAt = nowMs();
+ }
+ }
+ delete this.session.items[itemId];
+ this.releaseTargetPath(itemId);
+ this.persistSoon();
+ this.emitState(true);
+ }
+
+ public togglePackage(packageId: string): void {
+ const pkg = this.session.packages[packageId];
+ if (!pkg) {
+ return;
+ }
+
+ const nextEnabled = !pkg.enabled;
+ pkg.enabled = nextEnabled;
+
+ if (!nextEnabled) {
+ if (pkg.status === "downloading") {
+ pkg.status = "paused";
+ }
+ for (const itemId of pkg.itemIds) {
+ const item = this.session.items[itemId];
+ if (!item) {
+ continue;
+ }
+ if (this.session.running && !isFinishedStatus(item.status) && !this.runOutcomes.has(itemId)) {
+ this.runItemIds.delete(itemId);
+ }
+ const active = this.activeTasks.get(itemId);
+ if (active) {
+ active.abortReason = "package_toggle";
+ active.abortController.abort("package_toggle");
+ continue;
+ }
+ if (item.status === "queued" || item.status === "reconnect_wait") {
+ item.status = "queued";
+ item.speedBps = 0;
+ item.fullStatus = "Paket gestoppt";
+ item.updatedAt = nowMs();
+ }
+ }
+ this.runPackageIds.delete(packageId);
+ this.runCompletedPackages.delete(packageId);
+ } else {
+ if (pkg.status === "paused") {
+ pkg.status = "queued";
+ }
+ for (const itemId of pkg.itemIds) {
+ const item = this.session.items[itemId];
+ if (!item) {
+ continue;
+ }
+ if (item.status === "queued" && item.fullStatus === "Paket gestoppt") {
+ item.fullStatus = "Wartet";
+ item.updatedAt = nowMs();
+ }
+ }
+ if (this.session.running) {
+ void this.ensureScheduler();
+ }
+ }
+
+ pkg.updatedAt = nowMs();
+ this.persistSoon();
+ this.emitState(true);
+ }
+
+ public exportQueue(): string {
+ const exportData = {
+ version: 1,
+ packages: this.session.packageOrder.map((id) => {
+ const pkg = this.session.packages[id];
+ if (!pkg) {
+ return null;
+ }
+ return {
+ name: pkg.name,
+ links: pkg.itemIds
+ .map((itemId) => this.session.items[itemId]?.url)
+ .filter(Boolean)
+ };
+ }).filter(Boolean)
+ };
+ return JSON.stringify(exportData, null, 2);
+ }
+
+ public importQueue(json: string): { addedPackages: number; addedLinks: number } {
+ const data = JSON.parse(json) as { packages?: Array<{ name: string; links: string[] }> };
+ if (!Array.isArray(data.packages)) {
+ return { addedPackages: 0, addedLinks: 0 };
+ }
+ const inputs: ParsedPackageInput[] = data.packages
+ .filter((pkg) => pkg.name && Array.isArray(pkg.links) && pkg.links.length > 0)
+ .map((pkg) => ({ name: pkg.name, links: pkg.links }));
+ return this.addPackages(inputs);
+ }
+
public clearAll(): void {
this.stop();
this.session.packageOrder = [];
@@ -238,6 +438,7 @@ export class DownloadManager extends EventEmitter {
status: "queued",
itemIds: [],
cancelled: false,
+ enabled: true,
createdAt: nowMs(),
updatedAt: nowMs()
};
@@ -378,7 +579,13 @@ export class DownloadManager extends EventEmitter {
return;
}
const runItems = Object.values(this.session.items)
- .filter((item) => item.status === "queued" || item.status === "reconnect_wait");
+ .filter((item) => {
+ if (item.status !== "queued" && item.status !== "reconnect_wait") {
+ return false;
+ }
+ const pkg = this.session.packages[item.packageId];
+ return Boolean(pkg && !pkg.cancelled && pkg.enabled);
+ });
if (runItems.length === 0) {
this.runItemIds.clear();
this.runPackageIds.clear();
@@ -464,6 +671,9 @@ export class DownloadManager extends EventEmitter {
}
}
for (const pkg of Object.values(this.session.packages)) {
+ if (pkg.enabled === undefined) {
+ pkg.enabled = true;
+ }
if (pkg.status === "downloading"
|| pkg.status === "validating"
|| pkg.status === "extracting"
@@ -749,6 +959,10 @@ export class DownloadManager extends EventEmitter {
private markQueuedAsReconnectWait(): void {
for (const item of Object.values(this.session.items)) {
+ const pkg = this.session.packages[item.packageId];
+ if (!pkg || pkg.cancelled || !pkg.enabled) {
+ continue;
+ }
if (item.status === "queued") {
item.status = "reconnect_wait";
item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
@@ -761,7 +975,7 @@ export class DownloadManager extends EventEmitter {
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
- if (!pkg || pkg.cancelled) {
+ if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
for (const itemId of pkg.itemIds) {
@@ -778,13 +992,28 @@ export class DownloadManager extends EventEmitter {
}
private hasQueuedItems(): boolean {
- return Object.values(this.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
+ for (const packageId of this.session.packageOrder) {
+ const pkg = this.session.packages[packageId];
+ if (!pkg || pkg.cancelled || !pkg.enabled) {
+ 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 true;
+ }
+ }
+ }
+ return false;
}
private startItem(packageId: string, itemId: string): void {
const item = this.session.items[itemId];
const pkg = this.session.packages[packageId];
- if (!item || !pkg || pkg.cancelled) {
+ if (!item || !pkg || pkg.cancelled || !pkg.enabled) {
return;
}
@@ -927,6 +1156,10 @@ export class DownloadManager extends EventEmitter {
} else if (reason === "reconnect") {
item.status = "queued";
item.fullStatus = "Wartet auf Reconnect";
+ } else if (reason === "package_toggle") {
+ item.status = "queued";
+ item.speedBps = 0;
+ item.fullStatus = "Paket gestoppt";
} else {
const errorText = compactErrorText(error);
const shouldFreshRetry = !freshRetryUsed && isFetchFailure(errorText);
@@ -978,8 +1211,9 @@ export class DownloadManager extends EventEmitter {
}
let lastError = "";
+ let effectiveTargetPath = targetPath;
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
- const existingBytes = fs.existsSync(targetPath) ? fs.statSync(targetPath).size : 0;
+ const existingBytes = fs.existsSync(effectiveTargetPath) ? fs.statSync(effectiveTargetPath).size : 0;
const headers: Record = {};
if (existingBytes > 0) {
headers.Range = `bytes=${existingBytes}-`;
@@ -1042,6 +1276,22 @@ export class DownloadManager extends EventEmitter {
const acceptRanges = (response.headers.get("accept-ranges") || "").toLowerCase().includes("bytes");
try {
+ if (existingBytes === 0) {
+ const rawHeaderName = parseContentDispositionFilename(response.headers.get("content-disposition")).trim();
+ const fromHeader = rawHeaderName ? sanitizeFilename(rawHeaderName) : "";
+ if (fromHeader && !looksLikeOpaqueFilename(fromHeader) && fromHeader !== item.fileName) {
+ const pkg = this.session.packages[item.packageId];
+ if (pkg) {
+ this.releaseTargetPath(item.id);
+ effectiveTargetPath = this.claimTargetPath(item.id, path.join(pkg.outputDir, fromHeader));
+ item.fileName = fromHeader;
+ item.targetPath = effectiveTargetPath;
+ item.updatedAt = nowMs();
+ this.emitState();
+ }
+ }
+ }
+
const resumable = response.status === 206 || acceptRanges;
active.resumable = resumable;
@@ -1057,10 +1307,10 @@ export class DownloadManager extends EventEmitter {
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
if (writeMode === "w" && existingBytes > 0) {
- fs.rmSync(targetPath, { force: true });
+ fs.rmSync(effectiveTargetPath, { force: true });
}
- const stream = fs.createWriteStream(targetPath, { flags: writeMode });
+ const stream = fs.createWriteStream(effectiveTargetPath, { flags: writeMode });
let written = writeMode === "a" ? existingBytes : 0;
let windowBytes = 0;
let windowStarted = nowMs();
@@ -1183,11 +1433,35 @@ export class DownloadManager extends EventEmitter {
throw new Error(lastError || "Download fehlgeschlagen");
}
+ private getEffectiveSpeedLimitKbps(): number {
+ const schedules = this.settings.bandwidthSchedules;
+ if (schedules.length > 0) {
+ const hour = new Date().getHours();
+ for (const entry of schedules) {
+ if (!entry.enabled) {
+ continue;
+ }
+ const wraps = entry.startHour > entry.endHour;
+ const inRange = wraps
+ ? hour >= entry.startHour || hour < entry.endHour
+ : hour >= entry.startHour && hour < entry.endHour;
+ if (inRange) {
+ return entry.speedLimitKbps;
+ }
+ }
+ }
+ if (this.settings.speedLimitEnabled && this.settings.speedLimitKbps > 0) {
+ return this.settings.speedLimitKbps;
+ }
+ return 0;
+ }
+
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise {
- if (!this.settings.speedLimitEnabled || this.settings.speedLimitKbps <= 0) {
+ const limitKbps = this.getEffectiveSpeedLimitKbps();
+ if (limitKbps <= 0) {
return;
}
- const bytesPerSecond = this.settings.speedLimitKbps * 1024;
+ const bytesPerSecond = limitKbps * 1024;
const now = nowMs();
const elapsed = Math.max((now - localWindowStarted) / 1000, 0.1);
if (this.settings.speedLimitMode === "per_download") {
diff --git a/src/main/main.ts b/src/main/main.ts
index 1ec11ec..402718c 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -1,5 +1,5 @@
import path from "node:path";
-import { app, BrowserWindow, dialog, ipcMain, IpcMainInvokeEvent, shell } from "electron";
+import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
import { AddLinksPayload, AppSettings } from "../shared/types";
import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc";
@@ -7,6 +7,9 @@ import { logger } from "./logger";
import { APP_NAME } from "./constants";
let mainWindow: BrowserWindow | null = null;
+let tray: Tray | null = null;
+let clipboardTimer: ReturnType | null = null;
+let lastClipboardText = "";
const controller = new AppController();
function isDevMode(): boolean {
@@ -37,6 +40,87 @@ function createWindow(): BrowserWindow {
return window;
}
+function createTray(): void {
+ if (tray) {
+ return;
+ }
+ const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
+ try {
+ tray = new Tray(iconPath);
+ } catch {
+ return;
+ }
+ tray.setToolTip(APP_NAME);
+ const contextMenu = Menu.buildFromTemplate([
+ { label: "Anzeigen", click: () => { mainWindow?.show(); mainWindow?.focus(); } },
+ { type: "separator" },
+ { label: "Start", click: () => { controller.start(); } },
+ { label: "Stop", click: () => { controller.stop(); } },
+ { type: "separator" },
+ { label: "Beenden", click: () => { app.quit(); } }
+ ]);
+ tray.setContextMenu(contextMenu);
+ tray.on("double-click", () => {
+ mainWindow?.show();
+ mainWindow?.focus();
+ });
+}
+
+function destroyTray(): void {
+ if (tray) {
+ tray.destroy();
+ tray = null;
+ }
+}
+
+function extractLinksFromText(text: string): string[] {
+ const matches = text.match(/https?:\/\/[^\s<>"']+/gi);
+ return matches ? Array.from(new Set(matches)) : [];
+}
+
+function startClipboardWatcher(): void {
+ if (clipboardTimer) {
+ return;
+ }
+ lastClipboardText = clipboard.readText();
+ clipboardTimer = setInterval(() => {
+ const text = clipboard.readText();
+ if (text === lastClipboardText || !text.trim()) {
+ return;
+ }
+ lastClipboardText = text;
+ const links = extractLinksFromText(text);
+ if (links.length > 0 && mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send(IPC_CHANNELS.CLIPBOARD_DETECTED, links);
+ }
+ }, 2000);
+}
+
+function stopClipboardWatcher(): void {
+ if (clipboardTimer) {
+ clearInterval(clipboardTimer);
+ clipboardTimer = null;
+ }
+}
+
+function updateClipboardWatcher(): void {
+ const settings = controller.getSettings();
+ if (settings.clipboardWatch) {
+ startClipboardWatcher();
+ } else {
+ stopClipboardWatcher();
+ }
+}
+
+function updateTray(): void {
+ const settings = controller.getSettings();
+ if (settings.minimizeToTray) {
+ createTray();
+ } else {
+ destroyTray();
+ }
+}
+
function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot());
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
@@ -62,7 +146,12 @@ function registerIpcHandlers(): void {
return false;
}
});
- ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial) => controller.updateSettings(partial ?? {}));
+ ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial) => {
+ const result = controller.updateSettings(partial ?? {});
+ updateClipboardWatcher();
+ updateTray();
+ return result;
+ });
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());
@@ -70,6 +159,19 @@ function registerIpcHandlers(): void {
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.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => controller.renamePackage(packageId, newName));
+ ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => controller.reorderPackages(packageIds));
+ ipcMain.handle(IPC_CHANNELS.REMOVE_ITEM, (_event: IpcMainInvokeEvent, itemId: string) => controller.removeItem(itemId));
+ ipcMain.handle(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => controller.togglePackage(packageId));
+ ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, () => controller.exportQueue());
+ ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => controller.importQueue(json));
+ ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {
+ const settings = controller.getSettings();
+ const next = !settings.clipboardWatch;
+ controller.updateSettings({ clipboardWatch: next });
+ updateClipboardWatcher();
+ return next;
+ });
ipcMain.handle(IPC_CHANNELS.PICK_FOLDER, async () => {
const options = {
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
@@ -100,6 +202,16 @@ function registerIpcHandlers(): void {
app.whenReady().then(() => {
registerIpcHandlers();
mainWindow = createWindow();
+ updateClipboardWatcher();
+ updateTray();
+
+ mainWindow.on("close", (event) => {
+ const settings = controller.getSettings();
+ if (settings.minimizeToTray && tray) {
+ event.preventDefault();
+ mainWindow?.hide();
+ }
+ });
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
@@ -115,6 +227,8 @@ app.on("window-all-closed", () => {
});
app.on("before-quit", () => {
+ stopClipboardWatcher();
+ destroyTray();
try {
controller.shutdown();
} catch (error) {
diff --git a/src/main/storage.ts b/src/main/storage.ts
index c30aaa2..f1f855c 100644
--- a/src/main/storage.ts
+++ b/src/main/storage.ts
@@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
-import { AppSettings, SessionState } from "../shared/types";
+import { AppSettings, BandwidthScheduleEntry, SessionState } from "../shared/types";
import { defaultSettings } from "./constants";
import { logger } from "./logger";
@@ -10,6 +10,7 @@ const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]);
const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
const VALID_SPEED_MODES = new Set(["global", "per_download"]);
+const VALID_THEMES = new Set(["dark", "light"]);
function asText(value: unknown): string {
return String(value ?? "").trim();
@@ -23,6 +24,27 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number)
return Math.max(min, Math.min(max, Math.floor(num)));
}
+function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] {
+ if (!Array.isArray(raw)) {
+ return [];
+ }
+
+ const normalized: BandwidthScheduleEntry[] = [];
+ for (const entry of raw) {
+ if (!entry || typeof entry !== "object") {
+ continue;
+ }
+ const value = entry as Partial;
+ normalized.push({
+ startHour: clampNumber(value.startHour, 0, 0, 23),
+ endHour: clampNumber(value.endHour, 8, 0, 23),
+ speedLimitKbps: clampNumber(value.speedLimitKbps, 0, 0, 500000),
+ enabled: value.enabled === undefined ? true : Boolean(value.enabled)
+ });
+ }
+ return normalized;
+}
+
export function normalizeSettings(settings: AppSettings): AppSettings {
const defaults = defaultSettings();
const normalized: AppSettings = {
@@ -51,7 +73,11 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000),
reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600),
autoUpdateCheck: Boolean(settings.autoUpdateCheck),
- updateRepo: asText(settings.updateRepo) || defaults.updateRepo
+ updateRepo: asText(settings.updateRepo) || defaults.updateRepo,
+ clipboardWatch: Boolean(settings.clipboardWatch),
+ minimizeToTray: Boolean(settings.minimizeToTray),
+ theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
+ bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules)
};
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
diff --git a/src/preload/preload.ts b/src/preload/preload.ts
index 894e400..018f767 100644
--- a/src/preload/preload.ts
+++ b/src/preload/preload.ts
@@ -19,6 +19,13 @@ const api: ElectronApi = {
stop: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.STOP),
togglePause: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
cancelPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
+ renamePackage: (packageId: string, newName: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
+ reorderPackages: (packageIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
+ removeItem: (itemId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
+ togglePackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId),
+ exportQueue: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
+ importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
+ toggleClipboard: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
pickFolder: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
pickContainers: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
@@ -27,6 +34,13 @@ const api: ElectronApi = {
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
};
+ },
+ onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
+ const listener = (_event: unknown, links: string[]): void => callback(links);
+ ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
+ return () => {
+ ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
+ };
}
};
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 6573b19..988b46c 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1,76 +1,49 @@
-import { DragEvent, ReactElement, useEffect, useMemo, useRef, useState } from "react";
-import type { AppSettings, DebridFallbackProvider, DebridProvider, DownloadItem, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types";
+import { DragEvent, KeyboardEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { AppSettings, AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStats, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types";
type Tab = "collector" | "downloads" | "settings";
+interface CollectorTab {
+ id: string;
+ name: string;
+ text: string;
+}
+
+const emptyStats = (): DownloadStats => ({
+ totalDownloaded: 0,
+ totalFiles: 0,
+ totalPackages: 0,
+ sessionStartedAt: 0
+});
+
const emptySnapshot = (): UiSnapshot => ({
settings: {
- token: "",
- megaLogin: "",
- megaPassword: "",
- bestToken: "",
- allDebridToken: "",
- rememberToken: true,
- providerPrimary: "realdebrid",
- providerSecondary: "megadebrid",
- providerTertiary: "bestdebrid",
- autoProviderFallback: true,
- outputDir: "",
- packageName: "",
- autoExtract: true,
- extractDir: "",
- createExtractSubfolder: true,
- hybridExtract: true,
- cleanupMode: "none",
- extractConflictMode: "overwrite",
- removeLinkFilesAfterExtract: false,
- removeSamplesAfterExtract: false,
- enableIntegrityCheck: true,
- autoResumeOnStart: true,
- autoReconnect: false,
- reconnectWaitSeconds: 45,
- completedCleanupPolicy: "never",
- maxParallel: 4,
- speedLimitEnabled: false,
- speedLimitKbps: 0,
- speedLimitMode: "global",
- updateRepo: "",
- autoUpdateCheck: true
+ token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "",
+ rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid",
+ providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "",
+ autoExtract: true, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
+ cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
+ removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
+ autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
+ maxParallel: 4, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
+ updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
+ theme: "dark", bandwidthSchedules: []
},
session: {
- version: 2,
- packageOrder: [],
- packages: {},
- items: {},
- runStartedAt: 0,
- totalDownloadedBytes: 0,
- summaryText: "",
- reconnectUntil: 0,
- reconnectReason: "",
- paused: false,
- running: false,
- updatedAt: Date.now()
+ version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
+ totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "",
+ paused: false, running: false, updatedAt: Date.now()
},
- summary: null,
- speedText: "Geschwindigkeit: 0 B/s",
- etaText: "ETA: --",
- canStart: true,
- canStop: false,
- canPause: false
+ summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
+ canStart: true, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0
});
const cleanupLabels: Record = {
- never: "Nie",
- immediate: "Sofort",
- on_start: "Beim App-Start",
- package_done: "Sobald Paket fertig ist"
+ never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist"
};
const providerLabels: Record = {
- realdebrid: "Real-Debrid",
- megadebrid: "Mega-Debrid",
- bestdebrid: "BestDebrid",
- alldebrid: "AllDebrid"
+ realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid"
};
const fallbackProviderOptions: Array<{ value: DebridFallbackProvider; label: string }> = [
@@ -86,21 +59,42 @@ function formatSpeedMbps(speedBps: number): string {
return `${mbps.toFixed(2)} MB/s`;
}
+function humanSize(bytes: number): string {
+ if (bytes < 1024) { return `${bytes} B`; }
+ if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; }
+ if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+}
+
+let nextCollectorId = 1;
+
export function App(): ReactElement {
const [snapshot, setSnapshot] = useState(emptySnapshot);
const [tab, setTab] = useState("collector");
- const [linksRaw, setLinksRaw] = useState("");
const [statusToast, setStatusToast] = useState("");
const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings);
const latestStateRef = useRef(null);
const stateFlushTimerRef = useRef | null>(null);
const toastTimerRef = useRef | null>(null);
+ const [dragOver, setDragOver] = useState(false);
+ const [editingPackageId, setEditingPackageId] = useState(null);
+ const [editingName, setEditingName] = useState("");
+ const [collectorTabs, setCollectorTabs] = useState([
+ { id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" }
+ ]);
+ const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
+ const activeCollectorTabRef = useRef(activeCollectorTab);
+ const draggedPackageIdRef = useRef(null);
+
+ const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
+
+ useEffect(() => {
+ activeCollectorTabRef.current = activeCollectorTab;
+ }, [activeCollectorTab]);
const showToast = (message: string, timeoutMs = 2200): void => {
setStatusToast(message);
- if (toastTimerRef.current) {
- clearTimeout(toastTimerRef.current);
- }
+ if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
toastTimerRef.current = setTimeout(() => {
setStatusToast("");
toastTimerRef.current = null;
@@ -109,9 +103,11 @@ export function App(): ReactElement {
useEffect(() => {
let unsubscribe: (() => void) | null = null;
+ let unsubClipboard: (() => void) | null = null;
void window.rd.getSnapshot().then((state) => {
setSnapshot(state);
setSettingsDraft(state.settings);
+ applyTheme(state.settings.theme);
if (state.settings.autoUpdateCheck) {
void window.rd.checkUpdates().then((result) => {
void handleUpdateResult(result, "startup");
@@ -122,9 +118,7 @@ export function App(): ReactElement {
});
unsubscribe = window.rd.onStateUpdate((state) => {
latestStateRef.current = state;
- if (stateFlushTimerRef.current) {
- return;
- }
+ if (stateFlushTimerRef.current) { return; }
stateFlushTimerRef.current = setTimeout(() => {
stateFlushTimerRef.current = null;
if (latestStateRef.current) {
@@ -133,18 +127,20 @@ export function App(): ReactElement {
}
}, 220);
});
+ unsubClipboard = window.rd.onClipboardDetected((links) => {
+ showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000);
+ setCollectorTabs((prev) => {
+ const active = prev.find((t) => t.id === activeCollectorTabRef.current) ?? prev[0];
+ if (!active) { return prev; }
+ const newText = active.text ? `${active.text}\n${links.join("\n")}` : links.join("\n");
+ return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t);
+ });
+ });
return () => {
- if (stateFlushTimerRef.current) {
- clearTimeout(stateFlushTimerRef.current);
- stateFlushTimerRef.current = null;
- }
- if (toastTimerRef.current) {
- clearTimeout(toastTimerRef.current);
- toastTimerRef.current = null;
- }
- if (unsubscribe) {
- unsubscribe();
- }
+ if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
+ if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
+ if (unsubscribe) { unsubscribe(); }
+ if (unsubClipboard) { unsubClipboard(); }
};
}, []);
@@ -154,124 +150,225 @@ export function App(): ReactElement {
const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise => {
if (result.error) {
- if (source === "manual") {
- showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800);
- }
+ if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); }
return;
}
-
if (!result.updateAvailable) {
- if (source === "manual") {
- showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000);
- }
+ if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); }
return;
}
-
- const approved = window.confirm(
- `Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`
- );
- if (!approved) {
- showToast(`Update verfügbar: ${result.latestTag}`, 2600);
- return;
- }
-
+ const approved = window.confirm(`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`);
+ if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; }
const install = await window.rd.installUpdate();
- if (install.started) {
- showToast("Updater gestartet - App wird geschlossen", 2600);
- return;
- }
-
+ if (install.started) { showToast("Updater gestartet - App wird geschlossen", 2600); return; }
showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200);
};
const onSaveSettings = async (): Promise => {
try {
- await window.rd.updateSettings(settingsDraft);
+ const result = await window.rd.updateSettings(settingsDraft);
+ setSettingsDraft(result);
+ applyTheme(result.theme);
showToast("Settings gespeichert", 1800);
- } catch (error) {
- showToast(`Settings konnten nicht gespeichert werden: ${String(error)}`, 2800);
- }
+ } catch (error) { showToast(`Settings konnten nicht gespeichert werden: ${String(error)}`, 2800); }
};
const onCheckUpdates = async (): Promise => {
try {
const result = await window.rd.checkUpdates();
await handleUpdateResult(result, "manual");
- } catch (error) {
- showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800);
- }
+ } catch (error) { showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800); }
};
const onAddLinks = async (): Promise => {
try {
await window.rd.updateSettings(settingsDraft);
- const result = await window.rd.addLinks({ rawText: linksRaw, packageName: settingsDraft.packageName });
+ const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName });
if (result.addedLinks > 0) {
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
- setLinksRaw("");
- } else {
- showToast("Keine gültigen Links gefunden");
- }
- } catch (error) {
- showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600);
- }
+ setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: "" } : t));
+ } else { showToast("Keine gültigen Links gefunden"); }
+ } catch (error) { showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); }
};
const onImportDlc = async (): Promise => {
try {
const files = await window.rd.pickContainers();
- if (files.length === 0) {
- return;
- }
+ if (files.length === 0) { return; }
await window.rd.updateSettings(settingsDraft);
const result = await window.rd.addContainers(files);
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
- } catch (error) {
- showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
- }
+ } catch (error) { showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); }
};
- const onDrop = async (event: DragEvent): Promise => {
+ const onDrop = async (event: DragEvent): Promise => {
event.preventDefault();
+ setDragOver(false);
const files = Array.from(event.dataTransfer.files ?? []) as File[];
- const dlc = files
- .filter((file) => file.name.toLowerCase().endsWith(".dlc"))
- .map((file) => (file as unknown as { path?: string }).path)
- .filter((value): value is string => !!value);
- if (dlc.length === 0) {
- return;
+ const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v);
+ const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
+ if (dlc.length > 0) {
+ try {
+ await window.rd.updateSettings(settingsDraft);
+ const result = await window.rd.addContainers(dlc);
+ showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
+ } catch (error) { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); }
+ } else if (droppedText.trim()) {
+ setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id
+ ? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t));
+ setTab("collector");
+ showToast("Links per Drag-and-Drop eingefügt");
}
+ };
+
+ const onExportQueue = async (): Promise => {
try {
- await window.rd.updateSettings(settingsDraft);
- const result = await window.rd.addContainers(dlc);
- showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
- } catch (error) {
- showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
- }
+ const json = await window.rd.exportQueue();
+ const blob = new Blob([json], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "rd-queue-export.json";
+ a.click();
+ URL.revokeObjectURL(url);
+ showToast("Queue exportiert");
+ } catch (error) { showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); }
};
- const setBool = (key: keyof AppSettings, value: boolean): void => {
- setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
+ const onImportQueue = async (): Promise => {
+ try {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = ".json";
+ input.onchange = async () => {
+ const file = input.files?.[0];
+ if (!file) { return; }
+ try {
+ const text = await file.text();
+ const result = await window.rd.importQueue(text);
+ showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
+ } catch (error) {
+ showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
+ }
+ };
+ input.click();
+ } catch (error) { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); }
};
- const setText = (key: keyof AppSettings, value: string): void => {
- setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
- };
-
- const setNum = (key: keyof AppSettings, value: number): void => {
- setSettingsDraft((prev: AppSettings) => ({ ...prev, [key]: value }));
- };
+ const setBool = (key: keyof AppSettings, value: boolean): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
+ const setText = (key: keyof AppSettings, value: string): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
+ const setNum = (key: keyof AppSettings, value: number): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
const performQuickAction = async (action: () => Promise): Promise => {
- try {
- await action();
- } catch (error) {
- showToast(`Fehler: ${String(error)}`, 2600);
- }
+ try { await action(); } catch (error) { showToast(`Fehler: ${String(error)}`, 2600); }
};
+ const movePackage = useCallback((packageId: string, direction: "up" | "down") => {
+ const order = [...snapshot.session.packageOrder];
+ const idx = order.indexOf(packageId);
+ if (idx < 0) { return; }
+ const target = direction === "up" ? idx - 1 : idx + 1;
+ if (target < 0 || target >= order.length) { return; }
+ [order[idx], order[target]] = [order[target], order[idx]];
+ void window.rd.reorderPackages(order);
+ }, [snapshot.session.packageOrder]);
+
+ const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
+ const order = [...snapshot.session.packageOrder];
+ const fromIndex = order.indexOf(draggedPackageId);
+ const toIndex = order.indexOf(targetPackageId);
+ if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
+ return;
+ }
+ const [dragged] = order.splice(fromIndex, 1);
+ const insertIndex = fromIndex < toIndex ? toIndex - 1 : toIndex;
+ order.splice(insertIndex, 0, dragged);
+ void window.rd.reorderPackages(order);
+ }, [snapshot.session.packageOrder]);
+
+ const addCollectorTab = (): void => {
+ const id = `tab-${nextCollectorId++}`;
+ const name = `Tab ${collectorTabs.length + 1}`;
+ setCollectorTabs((prev) => [...prev, { id, name, text: "" }]);
+ setActiveCollectorTab(id);
+ };
+
+ const removeCollectorTab = (id: string): void => {
+ setCollectorTabs((prev) => {
+ if (prev.length <= 1) {
+ return prev;
+ }
+ const index = prev.findIndex((tabEntry) => tabEntry.id === id);
+ if (index < 0) {
+ return prev;
+ }
+ const next = prev.filter((tabEntry) => tabEntry.id !== id);
+ if (activeCollectorTabRef.current === id) {
+ const fallback = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? "";
+ setActiveCollectorTab(fallback);
+ }
+ return next;
+ });
+ };
+
+ const onPackageDragStart = useCallback((packageId: string) => {
+ draggedPackageIdRef.current = packageId;
+ }, []);
+
+ const onPackageDrop = useCallback((targetPackageId: string) => {
+ const draggedPackageId = draggedPackageIdRef.current;
+ draggedPackageIdRef.current = null;
+ if (!draggedPackageId || draggedPackageId === targetPackageId) {
+ return;
+ }
+ reorderPackagesByDrop(draggedPackageId, targetPackageId);
+ }, [reorderPackagesByDrop]);
+
+ const onPackageDragEnd = useCallback(() => {
+ draggedPackageIdRef.current = null;
+ }, []);
+
+ const schedules = settingsDraft.bandwidthSchedules ?? [];
+ const addSchedule = (): void => {
+ setSettingsDraft((prev) => ({
+ ...prev,
+ bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }]
+ }));
+ };
+ const removeSchedule = (idx: number): void => {
+ setSettingsDraft((prev) => ({
+ ...prev,
+ bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx)
+ }));
+ };
+ const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => {
+ setSettingsDraft((prev) => ({
+ ...prev,
+ bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s)
+ }));
+ };
+
+ const applyTheme = (theme: AppTheme): void => {
+ document.documentElement.setAttribute("data-theme", theme);
+ };
+
+ const packageSpeedMap = useMemo(() => {
+ const map = new Map();
+ for (const item of Object.values(snapshot.session.items)) {
+ if (item.speedBps > 0) {
+ map.set(item.packageId, (map.get(item.packageId) ?? 0) + item.speedBps);
+ }
+ }
+ return map;
+ }, [snapshot]);
+
return (
-
+
{ e.preventDefault(); setDragOver(true); }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={onDrop}
+ >