diff --git a/package.json b/package.json index 4334c3a..e252d8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.90", + "version": "1.4.95", "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 6885c08..8cb5001 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -19,7 +19,7 @@ import { DownloadManager } from "./download-manager"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; import { MegaWebFallback } from "./mega-web-fallback"; -import { createStoragePaths, loadSession, loadSettings, normalizeSettings, saveSettings } from "./storage"; +import { createStoragePaths, loadSession, loadSettings, normalizeSettings, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { startDebugServer, stopDebugServer } from "./debug-server"; @@ -237,6 +237,31 @@ export class AppController { return this.manager.getSessionStats(); } + public exportBackup(): string { + const settings = this.settings; + const session = this.manager.getSession(); + return JSON.stringify({ version: 1, settings, session }, null, 2); + } + + public importBackup(json: string): { restored: boolean; message: string } { + let parsed: Record; + try { + parsed = JSON.parse(json) as Record; + } catch { + return { restored: false, message: "Ungültiges JSON" }; + } + if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) { + return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" }; + } + const restoredSettings = normalizeSettings(parsed.settings as AppSettings); + this.settings = restoredSettings; + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings); + const restoredSession = parsed.session as ReturnType; + saveSession(this.storagePaths, restoredSession); + return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; + } + public shutdown(): void { stopDebugServer(); abortActiveUpdateDownload(); diff --git a/src/main/constants.ts b/src/main/constants.ts index 5735876..af111e9 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -73,6 +73,8 @@ export function defaultSettings(): AppSettings { clipboardWatch: false, minimizeToTray: false, theme: "dark" as const, + collapseNewPackages: true, + autoSkipExtracted: false, bandwidthSchedules: [] }; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index d8f3683..4a63b55 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -867,7 +867,7 @@ export class DownloadManager extends EventEmitter { summary: snapshotSummary, stats: this.getStats(now), speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, - etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`, + etaText: paused || !this.session.running ? "ETA: --" : `ETA: ${formatEta(eta)}`, canStart: !this.session.running, canStop: this.session.running, canPause: this.session.running, diff --git a/src/main/main.ts b/src/main/main.ts index d9d09a4..867ef51 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,9 +1,10 @@ +import fs from "node:fs"; import path from "node:path"; import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron"; import { AddLinksPayload, AppSettings, UpdateInstallProgress } from "../shared/types"; import { AppController } from "./app-controller"; import { IPC_CHANNELS } from "../shared/ipc"; -import { logger } from "./logger"; +import { getLogFilePath, logger } from "./logger"; import { APP_NAME } from "./constants"; import { extractHttpLinksFromText } from "./utils"; @@ -86,6 +87,9 @@ function createWindow(): BrowserWindow { }); } + window.setMenuBarVisibility(false); + window.setAutoHideMenuBar(true); + if (isDevMode()) { void window.loadURL("http://localhost:5173"); } else { @@ -346,6 +350,51 @@ function registerIpcHandlers(): void { }); ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); + ipcMain.handle(IPC_CHANNELS.RESTART, () => { + app.relaunch(); + app.quit(); + }); + + ipcMain.handle(IPC_CHANNELS.QUIT, () => { + app.quit(); + }); + + ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { + const options = { + defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.json`, + filters: [{ name: "Backup", extensions: ["json"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false }; + } + const json = controller.exportBackup(); + await fs.promises.writeFile(result.filePath, json, "utf8"); + return { saved: true }; + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => { + const logPath = getLogFilePath(); + await shell.openPath(logPath); + }); + + ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => { + const options = { + properties: ["openFile"] as Array<"openFile">, + filters: [ + { name: "Backup", extensions: ["json"] }, + { name: "Alle Dateien", extensions: ["*"] } + ] + }; + const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); + if (result.canceled || result.filePaths.length === 0) { + return { restored: false, message: "Abgebrochen" }; + } + const filePath = result.filePaths[0]; + const json = await fs.promises.readFile(filePath, "utf8"); + return controller.importBackup(json); + }); + controller.onState = (snapshot) => { if (!mainWindow || mainWindow.isDestroyed()) { return; diff --git a/src/main/storage.ts b/src/main/storage.ts index c597e33..1b8e390 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -106,6 +106,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { updateRepo: asText(settings.updateRepo) || defaults.updateRepo, clipboardWatch: Boolean(settings.clipboardWatch), minimizeToTray: Boolean(settings.minimizeToTray), + collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages, + autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted, theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme, bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules) }; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index dd3a17f..9493f7b 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -42,6 +42,11 @@ const api: ElectronApi = { pickFolder: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), pickContainers: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), getSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS), + restart: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESTART), + quit: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.QUIT), + exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), + importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), + openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7111a3d..e1e48db 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -16,6 +16,7 @@ import type { } from "../shared/types"; type Tab = "collector" | "downloads" | "statistics" | "settings"; +type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates"; interface CollectorTab { id: string; @@ -35,6 +36,19 @@ interface ConfirmPromptState { danger?: boolean; } +interface ContextMenuState { + x: number; + y: number; + packageId: string; + itemId?: string; +} + +interface LinkPopupState { + title: string; + links: { name: string; url: string }[]; + isPackage: boolean; +} + const emptyStats = (): DownloadStats => ({ totalDownloaded: 0, totalFiles: 0, @@ -55,7 +69,7 @@ const emptySnapshot = (): UiSnapshot => ({ autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, - theme: "dark", bandwidthSchedules: [] + theme: "dark", collapseNewPackages: true, bandwidthSchedules: [] }, session: { version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0, @@ -76,6 +90,32 @@ const providerLabels: Record = { realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid" }; +function extractHoster(url: string): string { + try { + const host = new URL(url).hostname.replace(/^www\./, ""); + const parts = host.split("."); + return parts.length >= 2 ? parts.slice(-2).join(".") : host; + } catch { return ""; } +} + +function formatHoster(item: DownloadItem): string { + const hoster = extractHoster(item.url); + const label = hoster || "-"; + if (item.provider) { + return `${label} via. ${providerLabels[item.provider]}`; + } + return label; +} + +const settingsSubTabs: { key: SettingsSubTab; label: string }[] = [ + { key: "allgemein", label: "Allgemein" }, + { key: "accounts", label: "Accounts" }, + { key: "entpacken", label: "Entpacken" }, + { key: "geschwindigkeit", label: "Geschwindigkeit" }, + { key: "bereinigung", label: "Bereinigung" }, + { key: "updates", label: "Updates" }, +]; + function formatSpeedMbps(speedBps: number): string { const mbps = Math.max(0, speedBps) / (1024 * 1024); return `${mbps.toFixed(2)} MB/s`; @@ -305,6 +345,30 @@ export function sortPackageOrderByName(order: string[], packages: Record, items: Record, descending: boolean): string[] { + const sorted = [...order]; + sorted.sort((a, b) => { + const sizeA = (packages[a]?.itemIds ?? []).reduce((sum, id) => sum + (items[id]?.totalBytes || items[id]?.downloadedBytes || 0), 0); + const sizeB = (packages[b]?.itemIds ?? []).reduce((sum, id) => sum + (items[id]?.totalBytes || items[id]?.downloadedBytes || 0), 0); + const cmp = sizeA - sizeB; + return descending ? -cmp : cmp; + }); + return sorted; +} + +function sortPackageOrderByHoster(order: string[], packages: Record, items: Record, descending: boolean): string[] { + const sorted = [...order]; + sorted.sort((a, b) => { + const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => items[id]?.provider).filter(Boolean))].join(",").toLowerCase(); + const hosterB = [...new Set((packages[b]?.itemIds ?? []).map((id) => items[id]?.provider).filter(Boolean))].join(",").toLowerCase(); + const cmp = hosterA.localeCompare(hosterB); + return descending ? -cmp : cmp; + }); + return sorted; +} + +type PkgSortColumn = "name" | "size" | "hoster"; + function sameStringArray(a: string[], b: string[]): boolean { if (a.length !== b.length) { return false; @@ -359,7 +423,7 @@ function formatUpdateInstallProgress(progress: UpdateInstallProgress): string { export function App(): ReactElement { const [snapshot, setSnapshot] = useState(emptySnapshot); const [appVersion, setAppVersion] = useState(""); - const [tab, setTab] = useState("collector"); + const [tab, setTab] = useState("downloads"); const [statusToast, setStatusToast] = useState(""); const [updateInstallProgress, setUpdateInstallProgress] = useState(null); const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); @@ -388,6 +452,7 @@ export function App(): ReactElement { const draggedPackageIdRef = useRef(null); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); + const [downloadsSortColumn, setDownloadsSortColumn] = useState("name"); const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false); const [actionBusy, setActionBusy] = useState(false); @@ -396,12 +461,18 @@ export function App(): ReactElement { const mountedRef = useRef(true); const dragOverRef = useRef(false); const dragDepthRef = useRef(0); + const [openMenu, setOpenMenu] = useState(null); + const [settingsSubTab, setSettingsSubTab] = useState("allgemein"); + const [openSubmenu, setOpenSubmenu] = useState(null); const [startConflictPrompt, setStartConflictPrompt] = useState(null); const startConflictResolverRef = useRef<((result: { policy: Extract; applyToAll: boolean } | null) => void) | null>(null); const [confirmPrompt, setConfirmPrompt] = useState(null); const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const confirmQueueRef = useRef void }>>([]); const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); + const [contextMenu, setContextMenu] = useState(null); + const [linkPopup, setLinkPopup] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; @@ -914,6 +985,10 @@ export function App(): ReactElement { let overwritten = 0; let rememberedPolicy: Extract | null = null; + if (settingsDraft.autoSkipExtracted && conflicts.length > 0) { + rememberedPolicy = "skip"; + } + for (const conflict of conflicts) { let decisionPolicy = rememberedPolicy; if (!decisionPolicy) { @@ -937,7 +1012,7 @@ export function App(): ReactElement { } } - if (conflicts.length > 0) { + if (conflicts.length > 0 && !settingsDraft.autoSkipExtracted) { showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800); } @@ -945,12 +1020,16 @@ export function App(): ReactElement { }); }; - const onStartPauseClick = async (): Promise => { - if (snapshot.session.running) { - await performQuickAction(() => window.rd.togglePause()); - return; + const collapseNewPackages = async (existingIds: Set): Promise => { + const fresh = await window.rd.getSnapshot(); + const newIds = Object.keys(fresh.session.packages).filter((id) => !existingIds.has(id)); + if (newIds.length > 0) { + setCollapsedPackages((prev) => { + const next = { ...prev }; + for (const id of newIds) { next[id] = true; } + return next; + }); } - await onStartDownloads(); }; const onAddLinks = async (): Promise => { @@ -959,10 +1038,12 @@ export function App(): ReactElement { const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0]; const rawText = active?.text ?? ""; const persisted = await persistDraftSettings(); + const existingIds = new Set(Object.keys(snapshot.session.packages)); const result = await window.rd.addLinks({ rawText, packageName: persisted.packageName }); if (result.addedLinks > 0) { showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); setCollectorTabs((prev) => prev.map((t) => t.id === activeId ? { ...t, text: "" } : t)); + if (snapshot.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } } else { showToast("Keine gültigen Links gefunden"); } @@ -976,9 +1057,11 @@ export function App(): ReactElement { const files = await window.rd.pickContainers(); if (files.length === 0) { return; } await persistDraftSettings(); + const existingIds = new Set(Object.keys(snapshot.session.packages)); const result = await window.rd.addContainers(files); if (result.addedLinks > 0) { showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + if (snapshot.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } } else { showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); } @@ -992,15 +1075,20 @@ export function App(): ReactElement { dragDepthRef.current = 0; dragOverRef.current = false; setDragOver(false); + const hasFiles = event.dataTransfer.types.includes("Files"); + const hasUri = event.dataTransfer.types.includes("text/uri-list"); + if (!hasFiles && !hasUri) { return; } const files = Array.from(event.dataTransfer.files ?? []) as File[]; 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) { await performQuickAction(async () => { await persistDraftSettings(); + const existingIds = new Set(Object.keys(snapshot.session.packages)); const result = await window.rd.addContainers(dlc); if (result.addedLinks > 0) { showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); + if (snapshot.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } } else { showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); } @@ -1320,6 +1408,81 @@ export function App(): ReactElement { }); }, [showToast]); + const onPackageContextMenu = useCallback((packageId: string, itemId: string | undefined, x: number, y: number): void => { + const clickedId = itemId ?? packageId; + setSelectedIds((prev) => { + if (prev.has(clickedId)) return prev; + return new Set([clickedId]); + }); + setContextMenu({ x, y, packageId, itemId }); + }, []); + + const dragSelectRef = useRef(false); + + const onSelectId = useCallback((id: string, ctrlKey: boolean): void => { + setSelectedIds((prev) => { + if (ctrlKey) { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + } + if (prev.size === 1 && prev.has(id)) return new Set(); + return new Set([id]); + }); + }, []); + + const onSelectMouseDown = useCallback((id: string, e: React.MouseEvent): void => { + if (!e.ctrlKey || e.button !== 0) return; + e.preventDefault(); + dragSelectRef.current = true; + setSelectedIds((prev) => { const next = new Set(prev); next.add(id); return next; }); + const onUp = (): void => { dragSelectRef.current = false; window.removeEventListener("mouseup", onUp); }; + window.addEventListener("mouseup", onUp); + }, []); + + const onSelectMouseEnter = useCallback((id: string): void => { + if (!dragSelectRef.current) return; + setSelectedIds((prev) => { if (prev.has(id)) return prev; const next = new Set(prev); next.add(id); return next; }); + }, []); + + const showLinksPopup = useCallback((packageId: string, itemId?: string): void => { + const sel = selectedIds; + // Multi-select: collect links from all selected packages/items + if (sel.size > 1) { + const allLinks: { name: string; url: string }[] = []; + for (const id of sel) { + const pkg = snapshot.session.packages[id]; + if (pkg) { + for (const iid of pkg.itemIds) { + const item = snapshot.session.items[iid]; + if (item) allLinks.push({ name: item.fileName, url: item.url }); + } + } else { + const item = snapshot.session.items[id]; + if (item) allLinks.push({ name: item.fileName, url: item.url }); + } + } + setLinkPopup({ title: `${sel.size} ausgewählt`, links: allLinks, isPackage: allLinks.length > 1 }); + setContextMenu(null); + return; + } + const pkg = snapshot.session.packages[packageId]; + if (!pkg) { return; } + if (itemId) { + const item = snapshot.session.items[itemId]; + if (item) { + setLinkPopup({ title: item.fileName, links: [{ name: item.fileName, url: item.url }], isPackage: false }); + } + } else { + const links = pkg.itemIds + .map((id) => snapshot.session.items[id]) + .filter(Boolean) + .map((item) => ({ name: item.fileName, url: item.url })); + setLinkPopup({ title: pkg.name, links, isPackage: true }); + } + setContextMenu(null); + }, [snapshot.session.packages, snapshot.session.items, selectedIds]); + const schedules = settingsDraft.bandwidthSchedules ?? []; useEffect(() => { @@ -1380,6 +1543,124 @@ export function App(): ReactElement { document.documentElement.setAttribute("data-theme", theme); }; + const closeMenus = (): void => { + setOpenMenu(null); + setOpenSubmenu(null); + }; + + useEffect(() => { + if (!contextMenu) { return; } + const close = (): void => setContextMenu(null); + window.addEventListener("click", close); + window.addEventListener("contextmenu", close); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("contextmenu", close); + }; + }, [contextMenu]); + + useEffect(() => { + if (selectedIds.size === 0) return; + const onKey = (e: KeyboardEvent): void => { if (e.key === "Escape") setSelectedIds(new Set()); }; + const onClick = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + if (target.closest(".package-card")) return; + setSelectedIds(new Set()); + }; + window.addEventListener("keydown", onKey); + window.addEventListener("click", onClick); + return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("click", onClick); }; + }, [selectedIds.size]); + + const onExportBackup = async (): Promise => { + closeMenus(); + try { + const result = await window.rd.exportBackup(); + if (result.saved) { + showToast("Sicherung exportiert"); + } + } catch (error) { + showToast(`Sicherung fehlgeschlagen: ${String(error)}`, 2600); + } + }; + + const onImportBackup = async (): Promise => { + closeMenus(); + try { + const result = await window.rd.importBackup(); + if (result.restored) { + showToast(result.message, 4000); + } else if (result.message !== "Abgebrochen") { + showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000); + } + } catch (error) { + showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600); + } + }; + + const onMenuRestart = (): void => { + closeMenus(); + void window.rd.restart(); + }; + + const onMenuQuit = (): void => { + closeMenus(); + void window.rd.quit(); + }; + + useEffect(() => { + const handler = (e: globalThis.KeyboardEvent): void => { + if (e.ctrlKey && !e.altKey && !e.metaKey) { + if (e.shiftKey && e.key.toLowerCase() === "r") { + e.preventDefault(); + void window.rd.restart(); + return; + } + if (!e.shiftKey && e.key.toLowerCase() === "q") { + e.preventDefault(); + void window.rd.quit(); + return; + } + if (!e.shiftKey && e.key.toLowerCase() === "l") { + e.preventDefault(); + setTab("collector"); + setOpenMenu(null); + return; + } + if (!e.shiftKey && e.key.toLowerCase() === "p") { + e.preventDefault(); + setTab("settings"); + setOpenMenu(null); + return; + } + if (!e.shiftKey && e.key.toLowerCase() === "o") { + e.preventDefault(); + setOpenMenu(null); + void window.rd.pickContainers().then(async (files) => { + if (files.length === 0) { return; } + await window.rd.addContainers(files); + }).catch(() => undefined); + return; + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + useEffect(() => { + if (!openMenu) { return; } + const handler = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + if (!target.closest(".menu-bar")) { + setOpenMenu(null); + setOpenSubmenu(null); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [openMenu]); + const packageSpeedMap = useMemo(() => { const map = new Map(); for (const item of Object.values(snapshot.session.items)) { @@ -1396,6 +1677,9 @@ export function App(): ReactElement { onDragEnter={(event) => { event.preventDefault(); if (draggedPackageIdRef.current) { return; } + const hasFiles = event.dataTransfer.types.includes("Files"); + const hasUri = event.dataTransfer.types.includes("text/uri-list"); + if (!hasFiles && !hasUri) { return; } dragDepthRef.current += 1; if (!dragOverRef.current) { dragOverRef.current = true; @@ -1415,61 +1699,220 @@ export function App(): ReactElement { }} onDrop={onDrop} > -
-
-
-

Multi Debrid Downloader{appVersion ? ` - v${appVersion}` : ""}

+
+
+ + {openMenu === "einstellungen" && ( +
+ +
+
e.stopPropagation()}> + Max. gleichzeitige Downloads + +
+ { + const val = Math.max(1, Math.min(50, Number(e.target.value) || 1)); + setSettingsDraft((prev) => ({ ...prev, maxParallel: val })); + void window.rd.updateSettings({ maxParallel: val }); + }} + /> +
+ + +
+
+ + + Geschwindigkeitslimit + { + const next = e.target.checked; + setSettingsDraft((prev) => ({ ...prev, speedLimitEnabled: next })); + void window.rd.updateSettings({ speedLimitEnabled: next }); + }} + /> +
+ { + const parsed = parseMbpsInput(e.target.value); + if (parsed !== null) { + const kbps = Math.floor(parsed * 1024); + setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: kbps })); + void window.rd.updateSettings({ speedLimitKbps: kbps }); + } + }} + /> +
+ + +
+
+ MB/s +
+
+ )} +
+
+ + {openMenu === "hilfe" && ( +
+ + +
+ )} +
+
- -
-
+ +
+ -
+ {snapshot.reconnectSeconds > 0 && ( +
Reconnect: {snapshot.reconnectSeconds}s
+ )}
@@ -1485,7 +1928,6 @@ export function App(): ReactElement {
-
{snapshot.speedText} | {snapshot.etaText}
{collectorTabs.map((ct) => (
@@ -1513,61 +1955,84 @@ export function App(): ReactElement { {snapshot.session.reconnectReason && ({snapshot.session.reconnectReason})}
)} -
-
- + - -
- setDownloadSearch(event.target.value)} - placeholder="Pakete durchsuchen..." - /> + if (!confirmed) { + return; + } + await window.rd.clearAll(); + }); + }} + > + Alles leeren + +
-
- Pakete: {snapshot.stats.totalPackages} - Dateien: {snapshot.stats.totalFiles} fertig - Gesamt: {humanSize(snapshot.stats.totalDownloaded)} - {snapshot.session.running && !snapshot.session.paused && ( - {snapshot.speedText.replace("Geschwindigkeit: ", "Speed: ")} - )} +
+ {(["name", "size", "hoster"] as PkgSortColumn[]).map((col) => { + const labels: Record = { name: "Name", size: "Größe", hoster: "Hoster" }; + const isActive = downloadsSortColumn === col; + return ( + { + const nextDesc = isActive ? !downloadsSortDescending : false; + setDownloadsSortColumn(col); + setDownloadsSortDescending(nextDesc); + const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder; + let sorted: string[]; + if (col === "size") { + sorted = sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc); + } else if (col === "hoster") { + sorted = sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDesc); + } else { + sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDesc); + } + pendingPackageOrderRef.current = [...sorted]; + pendingPackageOrderAtRef.current = Date.now(); + packageOrderRef.current = sorted; + void window.rd.reorderPackages(sorted).catch((error) => { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = serverPackageOrderRef.current; + showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); + }); + }} + > + {labels[col]} {isActive ? (downloadsSortDescending ? "\u25BC" : "\u25B2") : ""} + + ); + })} + Status + Geschwindigkeit
{totalPackageCount === 0 &&
Noch keine Pakete in der Queue.
} {totalPackageCount > 0 && packages.length === 0 &&
Keine Pakete passend zur Suche.
} @@ -1588,6 +2053,10 @@ export function App(): ReactElement { isEditing={editingPackageId === pkg.id} editingName={editingName} collapsed={collapsedPackages[pkg.id] ?? false} + selectedIds={selectedIds} + onSelect={onSelectId} + onSelectMouseDown={onSelectMouseDown} + onSelectMouseEnter={onSelectMouseEnter} onStartEdit={onPackageStartEdit} onFinishEdit={onPackageFinishEdit} onEditChange={setEditingName} @@ -1597,6 +2066,7 @@ export function App(): ReactElement { onMoveDown={onPackageMoveDown} onToggle={onPackageToggle} onRemoveItem={onPackageRemoveItem} + onContextMenu={onPackageContextMenu} onDragStart={onPackageDragStart} onDrop={onPackageDrop} onDragEnd={onPackageDragEnd} @@ -1696,220 +2166,180 @@ export function App(): ReactElement {
- -
- {updateInstallProgress && ( -
- {formatUpdateInstallProgress(updateInstallProgress)} -
- )}
-
-
-

Provider & Zugang

- - setText("token", e.target.value)} /> - - setText("megaLogin", e.target.value)} /> - - setText("megaPassword", e.target.value)} /> - - setText("bestToken", e.target.value)} /> - - setText("allDebridToken", e.target.value)} /> - {configuredProviders.length === 0 && ( -
Füge mindestens einen Account hinzu, dann erscheint die Hoster-Auswahl.
- )} - {configuredProviders.length >= 1 && ( -
- )} - {configuredProviders.length >= 2 && ( -
- )} - {configuredProviders.length >= 3 && ( -
- )} - - -
- -
-

Pfade & Paketierung

- -
- setText("outputDir", e.target.value)} /> - -
- - setText("packageName", e.target.value)} /> - -
- setText("extractDir", e.target.value)} /> - -
- - - - - - -
- setText("mkvLibraryDir", e.target.value)} disabled={!settingsDraft.collectMkvToLibrary} /> - -
- -