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*</i, + /(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]+)/i, + /download\s+file\s+([^<\r\n]+)/i, + /([A-Za-z0-9][A-Za-z0-9._\-()[\] ]{2,220}\.(?: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 + ]; + + for (const pattern of patterns) { + const match = html.match(pattern); + const normalized = normalizeResolvedFilename(match?.[1] || ""); + if (normalized) { + return normalized; + } + } + + return ""; +} + async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> { if (items.length === 0) { return; @@ -161,31 +226,43 @@ async function resolveRapidgatorFilename(link: string): Promise<string> { 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>([^<]+)<\/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<string, string> = {}; 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<void> { - 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<typeof setInterval> | 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<AppSettings>) => controller.updateSettings(partial ?? {})); + ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => { + 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<BandwidthScheduleEntry>; + 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<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP), togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE), cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId), + renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName), + reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds), + removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId), + togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId), + exportQueue: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE), + importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json), + toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { @@ -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<string, string> = { - 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<DebridProvider, string> = { - 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<UiSnapshot>(emptySnapshot); const [tab, setTab] = useState<Tab>("collector"); - const [linksRaw, setLinksRaw] = useState(""); const [statusToast, setStatusToast] = useState(""); const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings); const latestStateRef = useRef<UiSnapshot | null>(null); const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const [dragOver, setDragOver] = useState(false); + const [editingPackageId, setEditingPackageId] = useState<string | null>(null); + const [editingName, setEditingName] = useState(""); + const [collectorTabs, setCollectorTabs] = useState<CollectorTab[]>([ + { id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" } + ]); + const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); + const activeCollectorTabRef = useRef(activeCollectorTab); + const draggedPackageIdRef = useRef<string | null>(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<void> => { 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<void> => { 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<void> => { 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<void> => { 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<void> => { 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<HTMLTextAreaElement>): Promise<void> => { + const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => { 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<void> => { 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<void> => { + 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<unknown>): Promise<void> => { - 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<string, number>(); + 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 ( - <div className="app-shell"> + <div + className={`app-shell${dragOver ? " drag-over" : ""}`} + onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + > <header className="top-header"> <div className="title-block"> <h1>Debrid Download Manager</h1> @@ -280,45 +377,31 @@ export function App(): ReactElement { <div className="metrics"> <div>{snapshot.speedText}</div> <div>{snapshot.etaText}</div> + {snapshot.reconnectSeconds > 0 && ( + <div className="reconnect-badge">Reconnect: {snapshot.reconnectSeconds}s</div> + )} </div> </header> <section className="control-strip"> <div className="buttons"> - <button - className="btn accent" - disabled={!snapshot.canStart} - onClick={async () => { - await performQuickAction(async () => { - await window.rd.updateSettings(settingsDraft); - await window.rd.start(); - }); - }} - >Start</button> + <button className="btn accent" disabled={!snapshot.canStart} onClick={async () => { + await performQuickAction(async () => { await window.rd.updateSettings(settingsDraft); await window.rd.start(); }); + }}>Start</button> <button className="btn" disabled={!snapshot.canPause} onClick={() => { void performQuickAction(() => window.rd.togglePause()); }}> {snapshot.session.paused ? "Resume" : "Pause"} </button> <button className="btn" disabled={!snapshot.canStop} onClick={() => { void performQuickAction(() => window.rd.stop()); }}>Stop</button> <button className="btn" onClick={() => { void performQuickAction(() => window.rd.clearAll()); }}>Alles leeren</button> + <button className={`btn${snapshot.clipboardActive ? " btn-active" : ""}`} onClick={() => { void performQuickAction(() => window.rd.toggleClipboard()); }}> + Clipboard {snapshot.clipboardActive ? "An" : "Aus"} + </button> </div> <div className="speed-config"> - <label> - <input - type="checkbox" - checked={settingsDraft.speedLimitEnabled} - onChange={(event) => setBool("speedLimitEnabled", event.target.checked)} - /> - Speed-Limit - </label> - <input - type="number" - min={0} - max={500000} - value={settingsDraft.speedLimitKbps} - onChange={(event) => setNum("speedLimitKbps", Number(event.target.value) || 0)} - /> + <label><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit</label> + <input type="number" min={0} max={500000} value={settingsDraft.speedLimitKbps} onChange={(e) => setNum("speedLimitKbps", Number(e.target.value) || 0)} /> <span>KB/s</span> - <select value={settingsDraft.speedLimitMode} onChange={(event) => setText("speedLimitMode", event.target.value)}> + <select value={settingsDraft.speedLimitMode} onChange={(e) => setText("speedLimitMode", e.target.value)}> <option value="global">global</option> <option value="per_download">per_download</option> </select> @@ -335,32 +418,69 @@ export function App(): ReactElement { {tab === "collector" && ( <section className="grid-two"> <article className="card wide"> - <h3>Linksammler</h3> - <div className="link-actions"> - <button className="btn" onClick={onImportDlc}>DLC import</button> - <button className="btn accent" onClick={onAddLinks}>Zur Queue hinzufügen</button> + <div className="collector-header"> + <h3>Linksammler</h3> + <div className="link-actions"> + <button className="btn" onClick={onImportDlc}>DLC import</button> + <button className="btn" onClick={onExportQueue}>Queue Export</button> + <button className="btn" onClick={onImportQueue}>Queue Import</button> + <button className="btn accent" onClick={onAddLinks}>Zur Queue hinzufugen</button> + </div> + </div> + <div className="collector-tabs"> + {collectorTabs.map((ct) => ( + <div key={ct.id} className={`collector-tab${ct.id === activeCollectorTab ? " active" : ""}`}> + <button onClick={() => setActiveCollectorTab(ct.id)}>{ct.name}</button> + {collectorTabs.length > 1 && <button className="close-tab" onClick={() => removeCollectorTab(ct.id)}>x</button>} + </div> + ))} + <button className="btn add-tab" onClick={addCollectorTab}>+</button> </div> <textarea - value={linksRaw} - onChange={(event) => setLinksRaw(event.target.value)} - onDragOver={(event) => event.preventDefault()} - onDrop={onDrop} - placeholder="# package: Release-Name\nhttps://...\nhttps://..." + value={currentCollectorTab.text} + onChange={(e) => setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: e.target.value } : t))} + onDragOver={(e) => e.preventDefault()} + placeholder={"# package: Release-Name\nhttps://...\nhttps://...\n\nLinks oder .dlc Dateien hier ablegen"} /> - <p className="hint">.dlc einfach auf das Feld ziehen oder per Button importieren.</p> </article> </section> )} {tab === "downloads" && ( <section className="downloads-view"> + {snapshot.reconnectSeconds > 0 && ( + <div className="reconnect-banner"> + Reconnect aktiv: {snapshot.reconnectSeconds}s verbleibend + {snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>} + </div> + )} + <div className="stats-bar"> + <span>Pakete: {snapshot.stats.totalPackages}</span> + <span>Dateien: {snapshot.stats.totalFiles} fertig</span> + <span>Gesamt: {humanSize(snapshot.stats.totalDownloaded)}</span> + </div> {packages.length === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>} - {packages.map((pkg) => ( + {packages.map((pkg, idx) => ( <PackageCard key={pkg.id} pkg={pkg} items={pkg.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean)} + packageSpeed={packageSpeedMap.get(pkg.id) ?? 0} + isFirst={idx === 0} + isLast={idx === packages.length - 1} + isEditing={editingPackageId === pkg.id} + editingName={editingName} + onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }} + onFinishEdit={(name) => { setEditingPackageId(null); if (name.trim()) { void window.rd.renamePackage(pkg.id, name); } }} + onEditChange={setEditingName} onCancel={() => { void performQuickAction(() => window.rd.cancelPackage(pkg.id)); }} + onMoveUp={() => movePackage(pkg.id, "up")} + onMoveDown={() => movePackage(pkg.id, "down")} + onToggle={() => { void window.rd.togglePackage(pkg.id); }} + onRemoveItem={(itemId) => { void window.rd.removeItem(itemId); }} + onDragStart={() => onPackageDragStart(pkg.id)} + onDrop={() => onPackageDrop(pkg.id)} + onDragEnd={onPackageDragEnd} /> ))} </section> @@ -374,7 +494,14 @@ export function App(): ReactElement { <span>Kompakt, schnell auffindbar und direkt speicherbar.</span> </div> <div className="settings-toolbar-actions"> - <button className="btn" onClick={onCheckUpdates}>Updates prüfen</button> + <button className="btn" onClick={onCheckUpdates}>Updates prufen</button> + <button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => { + const next = settingsDraft.theme === "dark" ? "light" : "dark"; + setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme })); + applyTheme(next as AppTheme); + }}> + {settingsDraft.theme === "dark" ? "Light Mode" : "Dark Mode"} + </button> <button className="btn accent" onClick={onSaveSettings}>Settings speichern</button> </div> </article> @@ -383,148 +510,100 @@ export function App(): ReactElement { <article className="card settings-card"> <h3>Provider & Zugang</h3> <label>Real-Debrid API Token</label> - <input type="password" value={settingsDraft.token} onChange={(event) => setText("token", event.target.value)} /> + <input type="password" value={settingsDraft.token} onChange={(e) => setText("token", e.target.value)} /> <label>Mega-Debrid Login</label> - <input value={settingsDraft.megaLogin} onChange={(event) => setText("megaLogin", event.target.value)} /> + <input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} /> <label>Mega-Debrid Passwort</label> - <input type="password" value={settingsDraft.megaPassword} onChange={(event) => setText("megaPassword", event.target.value)} /> + <input type="password" value={settingsDraft.megaPassword} onChange={(e) => setText("megaPassword", e.target.value)} /> <label>BestDebrid API Token</label> - <input type="password" value={settingsDraft.bestToken} onChange={(event) => setText("bestToken", event.target.value)} /> + <input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} /> <label>AllDebrid API Key</label> - <input type="password" value={settingsDraft.allDebridToken} onChange={(event) => setText("allDebridToken", event.target.value)} /> - + <input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} /> <div className="field-grid three"> - <div> - <label>Primär</label> - <select value={settingsDraft.providerPrimary} onChange={(event) => setText("providerPrimary", event.target.value)}> - {Object.entries(providerLabels).map(([key, label]) => ( - <option key={key} value={key}>{label}</option> - ))} - </select> - </div> - <div> - <label>Sekundär</label> - <select value={settingsDraft.providerSecondary} onChange={(event) => setText("providerSecondary", event.target.value)}> - {fallbackProviderOptions.map((option) => ( - <option key={option.value} value={option.value}>{option.label}</option> - ))} - </select> - </div> - <div> - <label>Tertiär</label> - <select value={settingsDraft.providerTertiary} onChange={(event) => setText("providerTertiary", event.target.value)}> - {fallbackProviderOptions.map((option) => ( - <option key={option.value} value={option.value}>{option.label}</option> - ))} - </select> - </div> + <div><label>Primar</label><select value={settingsDraft.providerPrimary} onChange={(e) => setText("providerPrimary", e.target.value)}> + {Object.entries(providerLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))} + </select></div> + <div><label>Sekundar</label><select value={settingsDraft.providerSecondary} onChange={(e) => setText("providerSecondary", e.target.value)}> + {fallbackProviderOptions.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))} + </select></div> + <div><label>Tertiar</label><select value={settingsDraft.providerTertiary} onChange={(e) => setText("providerTertiary", e.target.value)}> + {fallbackProviderOptions.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))} + </select></div> </div> - - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(event) => setBool("autoProviderFallback", event.target.checked)} /> Bei Fehler/Fair-Use automatisch zum nächsten Provider wechseln</label> - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(event) => setBool("rememberToken", event.target.checked)} /> Zugangsdaten lokal speichern</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(e) => setBool("autoProviderFallback", e.target.checked)} /> Bei Fehler/Fair-Use automatisch zum nachsten Provider wechseln</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(e) => setBool("rememberToken", e.target.checked)} /> Zugangsdaten lokal speichern</label> </article> <article className="card settings-card"> <h3>Pfade & Paketierung</h3> <label>Download-Ordner</label> <div className="input-row"> - <input value={settingsDraft.outputDir} onChange={(event) => setText("outputDir", event.target.value)} /> - <button - className="btn" - onClick={() => { - void performQuickAction(async () => { - const selected = await window.rd.pickFolder(); - if (selected) { - setText("outputDir", selected); - } - }); - }} - >Wählen</button> + <input value={settingsDraft.outputDir} onChange={(e) => setText("outputDir", e.target.value)} /> + <button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("outputDir", s); } }); }}>Wahlen</button> </div> <label>Paketname (optional)</label> - <input value={settingsDraft.packageName} onChange={(event) => setText("packageName", event.target.value)} /> + <input value={settingsDraft.packageName} onChange={(e) => setText("packageName", e.target.value)} /> <label>Entpacken nach</label> <div className="input-row"> - <input value={settingsDraft.extractDir} onChange={(event) => setText("extractDir", event.target.value)} /> - <button - className="btn" - onClick={() => { - void performQuickAction(async () => { - const selected = await window.rd.pickFolder(); - if (selected) { - setText("extractDir", selected); - } - }); - }} - >Wählen</button> + <input value={settingsDraft.extractDir} onChange={(e) => setText("extractDir", e.target.value)} /> + <button className="btn" onClick={() => { void performQuickAction(async () => { const s = await window.rd.pickFolder(); if (s) { setText("extractDir", s); } }); }}>Wahlen</button> </div> - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(event) => setBool("autoExtract", event.target.checked)} /> Auto-Extract</label> - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(event) => setBool("hybridExtract", event.target.checked)} /> Hybrid-Extract</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Auto-Extract</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label> </article> <article className="card settings-card"> <h3>Queue, Limits & Reconnect</h3> <div className="field-grid two"> - <div> - <label>Max. Downloads</label> - <input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(event) => setNum("maxParallel", Number(event.target.value) || 1)} /> - </div> - <div> - <label>Reconnect-Wartezeit (Sek.)</label> - <input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(event) => setNum("reconnectWaitSeconds", Number(event.target.value) || 45)} /> - </div> + <div><label>Max. Downloads</label><input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(e) => setNum("maxParallel", Number(e.target.value) || 1)} /></div> + <div><label>Reconnect-Wartezeit (Sek.)</label><input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(e) => setNum("reconnectWaitSeconds", Number(e.target.value) || 45)} /></div> </div> - <div className="field-grid two"> - <div> - <label>Speed-Limit (KB/s)</label> - <input type="number" min={0} max={500000} value={settingsDraft.speedLimitKbps} onChange={(event) => setNum("speedLimitKbps", Number(event.target.value) || 0)} /> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(e) => setBool("speedLimitEnabled", e.target.checked)} /> Speed-Limit aktivieren</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(e) => setBool("autoReconnect", e.target.checked)} /> Automatischer Reconnect</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(e) => setBool("autoResumeOnStart", e.target.checked)} /> Auto-Resume beim Start</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage uberwachen</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label> + <h4>Bandbreitenplanung</h4> + {schedules.map((s, i) => ( + <div key={i} className="schedule-row"> + <input type="number" min={0} max={23} value={s.startHour} onChange={(e) => updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" /> + <span>-</span> + <input type="number" min={0} max={23} value={s.endHour} onChange={(e) => updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" /> + <span>Uhr</span> + <input type="number" min={0} value={s.speedLimitKbps} onChange={(e) => updateSchedule(i, "speedLimitKbps", Number(e.target.value) || 0)} title="KB/s (0=unbegrenzt)" /> + <span>KB/s</span> + <input type="checkbox" checked={s.enabled} onChange={(e) => updateSchedule(i, "enabled", e.target.checked)} /> + <button className="btn danger" onClick={() => removeSchedule(i)}>X</button> </div> - <div> - <label>Speed-Modus</label> - <select value={settingsDraft.speedLimitMode} onChange={(event) => setText("speedLimitMode", event.target.value)}> - <option value="global">global</option> - <option value="per_download">per_download</option> - </select> - </div> - </div> - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(event) => setBool("speedLimitEnabled", event.target.checked)} /> Speed-Limit aktivieren</label> - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(event) => setBool("autoReconnect", event.target.checked)} /> Automatischer Reconnect</label> - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(event) => setBool("autoResumeOnStart", event.target.checked)} /> Auto-Resume beim Start</label> + ))} + <button className="btn" onClick={addSchedule}>Zeitregel hinzufugen</button> </article> <article className="card settings-card"> - <h3>Integrität, Cleanup & Updates</h3> - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(event) => setBool("enableIntegrityCheck", event.target.checked)} /> SFV/CRC/MD5/SHA1 prüfen</label> - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(event) => setBool("removeLinkFilesAfterExtract", event.target.checked)} /> Link-Dateien nach Entpacken entfernen</label> - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(event) => setBool("removeSamplesAfterExtract", event.target.checked)} /> Samples nach Entpacken entfernen</label> + <h3>Integritat, Cleanup & Updates</h3> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(e) => setBool("enableIntegrityCheck", e.target.checked)} /> SFV/CRC/MD5/SHA1 prufen</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(e) => setBool("removeLinkFilesAfterExtract", e.target.checked)} /> Link-Dateien nach Entpacken entfernen</label> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(e) => setBool("removeSamplesAfterExtract", e.target.checked)} /> Samples nach Entpacken entfernen</label> <label>Fertiggestellte Downloads entfernen</label> - <select value={settingsDraft.completedCleanupPolicy} onChange={(event) => setText("completedCleanupPolicy", event.target.value)}> - {Object.entries(cleanupLabels).map(([key, label]) => ( - <option key={key} value={key}>{label}</option> - ))} + <select value={settingsDraft.completedCleanupPolicy} onChange={(e) => setText("completedCleanupPolicy", e.target.value)}> + {Object.entries(cleanupLabels).map(([key, label]) => (<option key={key} value={key}>{label}</option>))} </select> <div className="field-grid two"> - <div> - <label>Cleanup nach Entpacken</label> - <select value={settingsDraft.cleanupMode} onChange={(event) => setText("cleanupMode", event.target.value)}> - <option value="none">keine Archive löschen</option> - <option value="trash">Archive in Papierkorb</option> - <option value="delete">Archive löschen</option> - </select> - </div> - <div> - <label>Konfliktmodus</label> - <select value={settingsDraft.extractConflictMode} onChange={(event) => setText("extractConflictMode", event.target.value)}> - <option value="overwrite">überschreiben</option> - <option value="skip">überspringen</option> - <option value="rename">umbenennen</option> - <option value="ask">nachfragen</option> - </select> - </div> + <div><label>Cleanup nach Entpacken</label><select value={settingsDraft.cleanupMode} onChange={(e) => setText("cleanupMode", e.target.value)}> + <option value="none">keine Archive loschen</option> + <option value="trash">Archive in Papierkorb</option> + <option value="delete">Archive loschen</option> + </select></div> + <div><label>Konfliktmodus</label><select value={settingsDraft.extractConflictMode} onChange={(e) => setText("extractConflictMode", e.target.value)}> + <option value="overwrite">uberschreiben</option> + <option value="skip">uberspringen</option> + <option value="rename">umbenennen</option> + <option value="ask">nachfragen</option> + </select></div> </div> <label>GitHub Repo</label> - <input value={settingsDraft.updateRepo} onChange={(event) => setText("updateRepo", event.target.value)} /> - <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(event) => setBool("autoUpdateCheck", event.target.checked)} /> Beim Start auf Updates prüfen</label> + <input value={settingsDraft.updateRepo} onChange={(e) => setText("updateRepo", e.target.value)} /> + <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(e) => setBool("autoUpdateCheck", e.target.checked)} /> Beim Start auf Updates prufen</label> </article> </section> </section> @@ -532,40 +611,85 @@ export function App(): ReactElement { </main> {statusToast && <div className="toast">{statusToast}</div>} + {dragOver && <div className="drop-overlay">Links oder .dlc Dateien hier ablegen</div>} </div> ); } -function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: DownloadItem[]; onCancel: () => void }): ReactElement { +interface PackageCardProps { + pkg: PackageEntry; + items: DownloadItem[]; + packageSpeed: number; + isFirst: boolean; + isLast: boolean; + isEditing: boolean; + editingName: string; + onStartEdit: () => void; + onFinishEdit: (name: string) => void; + onEditChange: (name: string) => void; + onCancel: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + onToggle: () => void; + onRemoveItem: (itemId: string) => void; + onDragStart: () => void; + onDrop: () => void; + onDragEnd: () => void; +} + +function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, onStartEdit, onFinishEdit, onEditChange, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { const done = items.filter((item) => item.status === "completed").length; const failed = items.filter((item) => item.status === "failed").length; const cancelled = items.filter((item) => item.status === "cancelled").length; const total = Math.max(1, items.length); const progress = Math.floor((done / total) * 100); + const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => { + if (e.key === "Enter") { onFinishEdit(editingName); } + if (e.key === "Escape") { onFinishEdit(pkg.name); } + }; + return ( - <article className="package-card"> + <article + className={`package-card${pkg.enabled ? "" : " disabled-pkg"}`} + draggable + onDragStart={(event) => { event.stopPropagation(); onDragStart(); }} + onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }} + onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(); }} + onDragEnd={(event) => { event.stopPropagation(); onDragEnd(); }} + > <header> - <div> - <h4>{pkg.name}</h4> - <span>{done}/{total} fertig · {failed} Fehler · {cancelled} abgebrochen</span> + <div className="pkg-info"> + <div className="pkg-name-row"> + <input type="checkbox" checked={pkg.enabled} onChange={onToggle} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} /> + {isEditing ? ( + <input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(editingName)} onKeyDown={onKeyDown} autoFocus /> + ) : ( + <h4 onDoubleClick={onStartEdit} title="Doppelklick zum Umbenennen">{pkg.name}</h4> + )} + </div> + <span>{done}/{total} fertig {failed > 0 && `· ${failed} Fehler `}{cancelled > 0 && `· ${cancelled} abgebrochen `} + {packageSpeed > 0 && <span className="pkg-speed">{formatSpeedMbps(packageSpeed)}</span>} + </span> + </div> + <div className="pkg-actions"> + <button className="btn" disabled={isFirst} onClick={onMoveUp} title="Nach oben">▲</button> + <button className="btn" disabled={isLast} onClick={onMoveDown} title="Nach unten">▼</button> + <button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={onToggle}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button> + <button className="btn danger" onClick={onCancel}>Paket abbrechen</button> </div> - <button className="btn danger" onClick={onCancel}>Paket abbrechen</button> </header> - <div className="progress"> - <div style={{ width: `${progress}%` }} /> - </div> + <div className="progress"><div style={{ width: `${progress}%` }} /></div> <table> - <thead> - <tr> - <th className="col-file">Datei</th> - <th className="col-provider">Provider</th> - <th className="col-status">Status</th> - <th className="col-progress">Fortschritt</th> - <th className="col-speed">Speed</th> - <th className="col-retries">Retries</th> - </tr> - </thead> + <thead><tr> + <th className="col-file">Datei</th> + <th className="col-provider">Provider</th> + <th className="col-status">Status</th> + <th className="col-progress">Fortschritt</th> + <th className="col-speed">Speed</th> + <th className="col-retries">Retries</th> + <th className="col-actions">Aktion</th> + </tr></thead> <tbody> {items.map((item) => ( <tr key={item.id}> @@ -575,6 +699,7 @@ function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: Downl <td className="col-progress num">{item.progressPercent}%</td> <td className="col-speed num">{formatSpeedMbps(item.speedBps)}</td> <td className="col-retries num">{item.retries}</td> + <td className="col-actions"><button className="btn-icon danger" onClick={() => onRemoveItem(item.id)} title="Entfernen">X</button></td> </tr> ))} </tbody> diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 0dbc9ce..ac896ef 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1,7 +1,7 @@ :root { - color-scheme: dark; font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif; --bg: #040912; + --bg-glow: #10203b; --surface: #0b1424; --card: #101d31; --field: #081120; @@ -10,6 +10,40 @@ --muted: #90a4bf; --accent: #38bdf8; --danger: #f43f5e; + --button-bg: #0d1a2c; + --button-bg-hover: #12243d; + --tab-bg: #0b1321; + --tab-active: #14253e; + --toast-bg: #0f1f33; + --toast-border: #2a4f78; + --progress-track: #0b1628; + --overlay: rgba(8, 13, 23, 0.85); +} + +:root[data-theme="dark"] { + color-scheme: dark; +} + +:root[data-theme="light"] { + color-scheme: light; + --bg: #eef3fb; + --bg-glow: #d7e5ff; + --surface: #f7faff; + --card: #ffffff; + --field: #ffffff; + --border: #c7d5ea; + --text: #0f223d; + --muted: #4e6482; + --accent: #1168d9; + --danger: #c0392b; + --button-bg: #f3f7ff; + --button-bg-hover: #e6efff; + --tab-bg: #edf3ff; + --tab-active: #dce8ff; + --toast-bg: #ffffff; + --toast-border: #a9c1e8; + --progress-track: #dfe8f8; + --overlay: rgba(229, 238, 252, 0.88); } * { @@ -22,7 +56,7 @@ body, margin: 0; width: 100%; height: 100%; - background: radial-gradient(circle at 15% 10%, #10203b 0, #050b15 45%, #040912 100%); + background: radial-gradient(circle at 15% 10%, var(--bg-glow) 0, var(--surface) 45%, var(--bg) 100%); color: var(--text); } @@ -63,7 +97,7 @@ body, display: flex; justify-content: space-between; gap: 12px; - background: linear-gradient(180deg, rgba(20, 34, 56, 0.95), rgba(9, 16, 28, 0.95)); + background: linear-gradient(180deg, color-mix(in srgb, var(--surface) 92%, transparent), color-mix(in srgb, var(--bg) 92%, transparent)); border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; @@ -80,7 +114,7 @@ body, } .btn { - background: #0d1a2c; + background: var(--button-bg); color: var(--text); border: 1px solid var(--border); border-radius: 9px; @@ -99,7 +133,7 @@ body, .btn:hover:not(:disabled) { transform: translateY(-1px); border-color: var(--accent); - background: #12243d; + background: var(--button-bg-hover); } .btn.accent { @@ -113,13 +147,23 @@ body, color: #fda4af; } +:root[data-theme="light"] .btn.danger { + border-color: color-mix(in srgb, var(--danger) 60%, transparent); + color: var(--danger); +} + +.btn.btn-active { + border-color: var(--accent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); +} + .tabs { display: flex; gap: 8px; } .tab { - background: #0b1321; + background: var(--tab-bg); border: 1px solid var(--border); color: var(--muted); border-radius: 9px; @@ -131,8 +175,8 @@ body, .tab.active { color: var(--text); - background: #14253e; - border-color: #2c4e77; + background: var(--tab-active); + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } .tab-content { @@ -149,7 +193,7 @@ body, .card { border: 1px solid var(--border); border-radius: 12px; - background: linear-gradient(180deg, rgba(17, 29, 49, 0.95), rgba(9, 16, 28, 0.95)); + background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent)); padding: 10px; display: flex; flex-direction: column; @@ -212,6 +256,82 @@ body, gap: 10px; } +.collector-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.collector-tabs { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.collector-tab { + display: flex; + align-items: center; + border: 1px solid var(--border); + border-radius: 9px; + overflow: hidden; + background: var(--tab-bg); +} + +.collector-tab > button { + border: 0; + background: transparent; + color: var(--muted); + padding: 6px 10px; + cursor: pointer; +} + +.collector-tab.active > button { + color: var(--text); +} + +.collector-tab .close-tab { + border: 0; + border-left: 1px solid var(--border); + background: transparent; + color: var(--muted); + width: 26px; + cursor: pointer; +} + +.collector-tab .close-tab:hover { + color: var(--danger); +} + +.add-tab { + min-width: 32px; + padding-inline: 0; +} + +.reconnect-badge { + color: color-mix(in srgb, var(--danger) 70%, var(--text)); + font-weight: 600; +} + +.reconnect-banner { + border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border)); + background: color-mix(in srgb, var(--danger) 12%, var(--surface)); + color: var(--text); + border-radius: 10px; + padding: 8px 10px; + font-size: 13px; +} + +.stats-bar { + display: flex; + flex-wrap: wrap; + gap: 14px; + color: var(--muted); + font-size: 13px; +} + .settings-shell { display: grid; grid-template-rows: auto 1fr; @@ -246,6 +366,8 @@ body, } .settings-grid { + display: grid; + gap: 10px; min-height: 0; overflow: auto; align-content: start; @@ -286,10 +408,22 @@ body, .package-card { border: 1px solid var(--border); border-radius: 14px; - background: linear-gradient(180deg, rgba(16, 29, 48, 0.95), rgba(7, 13, 22, 0.95)); + background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 96%, transparent)); padding: 12px; } +.package-card[draggable="true"] { + cursor: grab; +} + +.package-card[draggable="true"]:active { + cursor: grabbing; +} + +.disabled-pkg { + opacity: 0.72; +} + .package-card header { display: flex; justify-content: space-between; @@ -307,11 +441,43 @@ body, font-size: 13px; } +.pkg-info { + min-width: 0; +} + +.pkg-name-row { + display: flex; + align-items: center; + gap: 8px; +} + +.pkg-name-row input[type="checkbox"] { + width: 16px; + height: 16px; +} + +.rename-input { + min-width: 220px; + max-width: 440px; +} + +.pkg-speed { + margin-left: 7px; + color: color-mix(in srgb, var(--accent) 70%, var(--text)); +} + +.pkg-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + .progress { margin-top: 8px; height: 7px; border-radius: 999px; - background: #0b1628; + background: var(--progress-track); overflow: hidden; } @@ -379,6 +545,39 @@ td { grid-template-columns: 1.1fr 1fr; } +.schedule-row { + display: grid; + grid-template-columns: 56px auto 56px auto 92px auto auto auto; + align-items: center; + gap: 6px; +} + +.schedule-row input[type="number"] { + text-align: center; +} + +.schedule-row .btn { + padding: 5px 8px; +} + +.btn-icon { + border: 1px solid var(--border); + background: var(--button-bg); + color: var(--text); + border-radius: 7px; + width: 28px; + height: 28px; + cursor: pointer; +} + +.btn-icon.danger { + color: var(--danger); +} + +.col-actions { + width: 8%; +} + .empty { border: 1px dashed var(--border); border-radius: 12px; @@ -391,20 +590,39 @@ td { position: fixed; right: 20px; bottom: 18px; - background: #0f1f33; + background: var(--toast-bg); color: var(--text); - border: 1px solid #2a4f78; + border: 1px solid var(--toast-border); border-radius: 12px; padding: 10px 14px; box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35); } +.drop-overlay { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: var(--overlay); + border: 2px dashed color-mix(in srgb, var(--accent) 72%, transparent); + color: var(--text); + font-size: 20px; + font-weight: 600; + pointer-events: none; + backdrop-filter: blur(2px); +} + @media (max-width: 1100px) { .control-strip { flex-direction: column; align-items: flex-start; } + .metrics { + flex-direction: column; + align-items: flex-end; + } + .settings-toolbar { flex-direction: column; align-items: flex-start; @@ -424,6 +642,24 @@ td { grid-template-columns: 1fr; } + .schedule-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .schedule-row span { + display: none; + } + + .package-card header { + flex-direction: column; + align-items: flex-start; + } + + .pkg-actions { + width: 100%; + justify-content: flex-start; + } + .card.wide, .settings-actions { grid-column: span 1; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index e22571c..9450cf3 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -12,7 +12,15 @@ export const IPC_CHANNELS = { STOP: "queue:stop", TOGGLE_PAUSE: "queue:toggle-pause", CANCEL_PACKAGE: "queue:cancel-package", + RENAME_PACKAGE: "queue:rename-package", + REORDER_PACKAGES: "queue:reorder-packages", + REMOVE_ITEM: "queue:remove-item", + TOGGLE_PACKAGE: "queue:toggle-package", + EXPORT_QUEUE: "queue:export", + IMPORT_QUEUE: "queue:import", PICK_FOLDER: "dialog:pick-folder", PICK_CONTAINERS: "dialog:pick-containers", - STATE_UPDATE: "state:update" + STATE_UPDATE: "state:update", + CLIPBOARD_DETECTED: "clipboard:detected", + TOGGLE_CLIPBOARD: "clipboard:toggle" } as const; diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 145df21..40c309a 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -14,7 +14,15 @@ export interface ElectronApi { stop: () => Promise<void>; togglePause: () => Promise<boolean>; cancelPackage: (packageId: string) => Promise<void>; + renamePackage: (packageId: string, newName: string) => Promise<void>; + reorderPackages: (packageIds: string[]) => Promise<void>; + removeItem: (itemId: string) => Promise<void>; + togglePackage: (packageId: string) => Promise<void>; + exportQueue: () => Promise<string>; + importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>; + toggleClipboard: () => Promise<boolean>; pickFolder: () => Promise<string | null>; pickContainers: () => Promise<string[]>; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; + onClipboardDetected: (callback: (links: string[]) => void) => () => void; } diff --git a/src/shared/types.ts b/src/shared/types.ts index 43581ea..9fef881 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -16,6 +16,21 @@ export type SpeedMode = "global" | "per_download"; export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done"; export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid"; export type DebridFallbackProvider = DebridProvider | "none"; +export type AppTheme = "dark" | "light"; + +export interface BandwidthScheduleEntry { + startHour: number; + endHour: number; + speedLimitKbps: number; + enabled: boolean; +} + +export interface DownloadStats { + totalDownloaded: number; + totalFiles: number; + totalPackages: number; + sessionStartedAt: number; +} export interface AppSettings { token: string; @@ -49,6 +64,10 @@ export interface AppSettings { speedLimitMode: SpeedMode; updateRepo: string; autoUpdateCheck: boolean; + clipboardWatch: boolean; + minimizeToTray: boolean; + theme: AppTheme; + bandwidthSchedules: BandwidthScheduleEntry[]; } export interface DownloadItem { @@ -80,6 +99,7 @@ export interface PackageEntry { status: DownloadStatus; itemIds: string[]; cancelled: boolean; + enabled: boolean; createdAt: number; updatedAt: number; } @@ -123,11 +143,14 @@ export interface UiSnapshot { settings: AppSettings; session: SessionState; summary: DownloadSummary | null; + stats: DownloadStats; speedText: string; etaText: string; canStart: boolean; canStop: boolean; canPause: boolean; + clipboardActive: boolean; + reconnectSeconds: number; } export interface AddLinksPayload { diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 091e312..c1518a6 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -250,4 +250,41 @@ describe("debrid service", () => { await expect(service.unrestrictLink("https://rapidgator.net/file/example.part6.rar.html")).rejects.toThrow(); expect(megaWeb).toHaveBeenCalledTimes(0); }); + + it("resolves rapidgator filename from page when provider returns hash", async () => { + const settings = { + ...defaultSettings(), + token: "rd-token", + providerPrimary: "realdebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: true + }; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ + download: "https://cdn.example/file.bin", + filename: "6f09df2984fe01378537c7cd8d7fa7ce", + filesize: 2048 + }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url.includes("rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce")) { + return new Response("<html><head><title>download file Banshee.S04E01.German.DL.720p.part01.rar - Rapidgator", { + status: 200, + headers: { "Content-Type": "text/html" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const result = await service.unrestrictLink("https://rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce"); + expect(result.provider).toBe("realdebrid"); + expect(result.fileName).toBe("Banshee.S04E01.German.DL.720p.part01.rar"); + }); }); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 5ea0b80..e1df7c7 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -204,6 +204,79 @@ describe("download manager", () => { } }); + it("uses content-disposition filename when provider filename is opaque", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(96 * 1024, 13); + const expectedName = "Banshee.S04E01.German.DL.720p.part01.rar"; + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/content-name") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.setHeader("Content-Disposition", `attachment; filename="${expectedName}"`); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/content-name`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "6f09df2984fe01378537c7cd8d7fa7ce", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "content-name", links: ["https://rapidgator.net/file/6f09df2984fe01378537c7cd8d7fa7ce"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.fileName).toBe(expectedName); + expect(path.basename(item?.targetPath || "")).toBe(expectedName); + } finally { + server.close(); + await once(server, "close"); + } + }); + it("reuses stored partial target path when queued item resumes", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -281,6 +354,7 @@ describe("download manager", () => { status: "queued", itemIds: [itemId], cancelled: false, + enabled: true, createdAt, updatedAt: createdAt }; @@ -412,6 +486,7 @@ describe("download manager", () => { status: "queued", itemIds: [itemId], cancelled: false, + enabled: true, createdAt, updatedAt: createdAt }; @@ -484,6 +559,7 @@ describe("download manager", () => { status: "reconnect_wait", itemIds: [itemId], cancelled: false, + enabled: true, createdAt, updatedAt: createdAt }; @@ -650,6 +726,7 @@ describe("download manager", () => { status: "completed", itemIds: [oldItemId], cancelled: false, + enabled: true, createdAt: oldNow, updatedAt: oldNow }; @@ -1025,6 +1102,175 @@ describe("download manager", () => { } }); + it("finishes run when remaining packages are disabled", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(96 * 1024, 8); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/enabled") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/enabled`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "enabled.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([ + { name: "enabled", links: ["https://dummy/enabled"] }, + { name: "disabled", links: ["https://dummy/disabled"] } + ]); + const initial = manager.getSnapshot(); + const enabledPkgId = initial.session.packageOrder[0]; + const disabledPkgId = initial.session.packageOrder[1]; + const enabledItemId = initial.session.packages[enabledPkgId]?.itemIds[0] || ""; + const disabledItemId = initial.session.packages[disabledPkgId]?.itemIds[0] || ""; + + manager.togglePackage(disabledPkgId); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[disabledPkgId]?.enabled).toBe(false); + expect(snapshot.session.items[enabledItemId]?.status).toBe("completed"); + expect(snapshot.session.items[disabledItemId]?.status).toBe("queued"); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("stops active package and keeps items queued", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(320 * 1024, 15); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/toggle") { + res.statusCode = 404; + res.end("not-found"); + return; + } + const half = Math.floor(binary.length / 2); + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, half)); + setTimeout(() => { + res.end(binary.subarray(half)); + }, 1200); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/toggle`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "toggle.bin", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "toggle", links: ["https://dummy/toggle"] }]); + const initial = manager.getSnapshot(); + const pkgId = initial.session.packageOrder[0]; + const itemId = initial.session.packages[pkgId]?.itemIds[0] || ""; + + manager.start(); + await waitFor(() => { + const item = manager.getSnapshot().session.items[itemId]; + return item?.status === "downloading"; + }, 12000); + + manager.togglePackage(pkgId); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(snapshot.session.packages[pkgId]?.enabled).toBe(false); + expect(item?.status).toBe("queued"); + expect(item?.fullStatus).toBe("Paket gestoppt"); + } finally { + server.close(); + await once(server, "close"); + } + }); + it("shows stable ETA while paused", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -1129,6 +1375,7 @@ describe("download manager", () => { status: "downloading", itemIds: [itemId], cancelled: false, + enabled: true, createdAt, updatedAt: createdAt };